3

vue-cli 源码阅读

前一阵写了一个平时自己用的 React 模板项目 boone-react,想着以后每次写新项目的时候就可以直接拉取现成的模板就不用自己配置了。但完成后,就发现每次都需要 git clone 下来还是比较麻烦的,而且如果之后有不同的模板需求,不方便扩展,于是自己写了一个 CLI 工具 boone-cli,这样在本地安装执行相关命令就可以完成基本需求了。但感觉还是有很多不足,想着去看看之前用过多次的 vue-cli 是怎么实现的。

Tip:阅读的是 vue-cli 的 v2.9.6 版本。因为 CRA 和 vue-cli@3.0 都是整合了一个插件系统来完成的项目新建工作,重点就不在命令行工具了而是插件系统如何实现了,增加阅读负担,自己菜还找借口

从项目结构开始

把测试文件、构建配置文件、文档文件去除后,项目的源码结构如下。其中:

  • package.json 不多说,注意里面的 bin 字段,这里定义了安装后执行的命令以及命令所对应的执行脚本
  • bin 目录下是命令文件,在敲入 vue init 等命令后会执行对应的文件
  • lib 目录下是一些自定义函数,会在各个执行脚本中用到

分析完结构就知道了,主要需要看的就是 binlib 两个目录下的文件了。

├── bin
│   ├── vue
│   ├── vue-build
│   ├── vue-create
│   ├── vue-init
│   └── vue-list
├── lib
│   ├── ask.js
│   ├── check-version.js
│   ├── eval.js
│   ├── filter.js
│   ├── generate.js
│   ├── git-user.js
│   ├── local-path.js
│   ├── logger.js
│   ├── options.js
│   └── warnings.js
├── package.json

bin 目录

vue 脚本

这里使用了 commander,主要用来处理命令行工具的开发。
vue 这个脚本文件主要功能就是给用户提示,提示 vue-cli 工具的用法和所有命令。PASS!

// vue 脚本
const program = require('commander')

program
  .version(require('../package').version)
  .usage('<command> [options]')
  .command('init', 'generate a new project from a template')
  .command('list', 'list available official templates')
  .command('build', 'prototype a new project')
  .command('create', '(for v3 warning only)')

program.parse(process.argv)

执行后,终端显示
clipboard.png

vue build 脚本

这里使用了 chalk,可以让终端打印的文本更加漂亮。

// vue-build 脚本
const chalk = require('chalk')

console.log(chalk.yellow(
  '\n' +
  '  We are slimming down vue-cli to optimize the initial installation by ' +
  'removing the `vue build` command.\n' +
  '  Check out Poi (https://github.com/egoist/poi) which offers the same functionality!' +
  '\n'
))

这个脚本在当前版本中已经被移除了,因此目前只有一些提示信息。PASS!

vue create 脚本

和上面那个类似,也是打印一些提示信息。vue create 是在 3.0 版本引入的功能。

// vue-create 脚本
const chalk = require('chalk')

console.log()
console.log(
  `  ` +
  chalk.yellow(`vue create`) +
  ' is a Vue CLI 3 only command and you are using Vue CLI ' +
  require('../package.json').version + '.'
)
console.log(`  You may want to run the following to upgrade to Vue CLI 3:`)
console.log()
console.log(chalk.cyan(`  npm uninstall -g vue-cli`))
console.log(chalk.cyan(`  npm install -g @vue/cli`))
console.log()

执行后,终端显示

clipboard.png

vue list 脚本

该脚本用于向用户展示目前的官方模板的信息

// vue list 脚本
const logger = require('../lib/logger')    // 自定义的 log 对象,可以调用不同方法输出不同颜色的 log 信息
const request = require('request') // 用于发 HTTP 请求
const chalk = require('chalk')

// 在开始输出和结束输出的时候加一空行 padding
console.log()
process.on('exit', () => {
  console.log()
})

// 请求官方模板信息
request({
  url: 'https://api.github.com/users/vuejs-templates/repos',
  headers: {
    'User-Agent': 'vue-cli'
  }
}, (err, res, body) => {
  if (err) logger.fatal(err)
  const requestBody = JSON.parse(body)
  if (Array.isArray(requestBody)) {
    console.log('  Available official templates:')
    console.log()
    requestBody.forEach(repo => {
      console.log(
        '  ' + chalk.yellow('★') +
        '  ' + chalk.blue(repo.name) +
        ' - ' + repo.description)
    })
  } else {
    console.error(requestBody.message)
  }
})

执行后终端显示
clipboard.png

✨ vue init 脚本

该脚本是最关键的脚本,用于帮助用户初始化 vue 项目。

// vue init 脚本
const download = require('download-git-repo') // 下载 github 上的仓库至用户本地
const program = require('commander')    // 命令行工具
const exists = require('fs').existsSync    // 用于检查文件是否存在(同步调用) 
const path = require('path') // node 的 path 模块,用于路径处理
const ora = require('ora') // 命令行工具下的加载动画
const home = require('user-home') // 获取用户的根路径
const tildify = require('tildify') // 将绝对路径转换为以波浪号 ~ 开头的路径
const chalk = require('chalk') // 多颜色输出
const inquirer = require('inquirer') // 用于用户与命令行工具的交互,以询问问题的方式完成交互
const rm = require('rimraf').sync // 类似于删除命令
const logger = require('../lib/logger') // 自定义的 log 对象,可以调用不同方法输出不同颜色的 log 信息
const generate = require('../lib/generate') // 用于生成最终文件
const checkVersion = require('../lib/check-version') // 检查 node 版本和 vue-cli 版本
const warnings = require('../lib/warnings') // 用于提示用户模板名的变化,主要用于 vue@1.0 和 vue@2.0 过渡时期
const localPath = require('../lib/local-path') // 根据模板路径判断是否是本地路径

const isLocalPath = localPath.isLocalPath
const getTemplatePath = localPath.getTemplatePath
// 配置使用说明,直接敲入 vue init 后会展示该命令的使用信息
program
  .usage('<template-name> [project-name]')
  .option('-c, --clone', 'use git clone')
  .option('--offline', 'use cached template')

// 配置帮助信息内容
program.on('--help', () => {
  console.log('  Examples:')
  console.log()
  console.log(chalk.gray('    # create a new project with an official template'))
  console.log('    $ vue init webpack my-project')
  console.log()
  console.log(chalk.gray('    # create a new project straight from a github template'))
  console.log('    $ vue init username/repo my-project')
  console.log()
})

// 处理传入命令行程序的参数,当用户不输入额外参数的时候提示帮助信息
function help () {
  program.parse(process.argv)
  if (program.args.length < 1) return program.help()
}
help()
// 一些变量
let template = program.args[0]    // vue init 后面跟着的第一个参数,表示 template 名字
// 判断上面的 template 参数中是否有斜杠 /,如果有斜杠说明不是官方模板(看一下 help 内容就知道了)
const hasSlash = template.indexOf('/') > -1
const rawName = program.args[1]    // vue init 后面跟着的第二个参数,表示构建的项目名
const inPlace = !rawName || rawName === '.'    // 如果第二个参数为空或者为 '.',则表示在当前目录下新建项目
// 如果是在当前目录下构建,则构建目录名为当前目录名,否则以 rawName 作为构建目录名,可以理解为项目名
const name = inPlace ? path.relative('../', process.cwd()) : rawName
const to = path.resolve(rawName || '.') // 构建目录的绝对路径
// 用于传给 download-git-repo 的参数,如果该参数为 true 则使用 git clone 来下载项目而不是 HTTP 下载,默认 false
const clone = program.clone || false
// 模板下载到用户本地的地方,并且把 template 中的斜杠 / 和冒号 : 替换为短线 -
const tmp = path.join(home, '.vue-templates', template.replace(/[\/:]/g, '-'))
// 检测用户环境是否联网,如果没有联网则使用本地之前存储的模板,把 template 赋值为本地存储的模板的路径
if (program.offline) {
  console.log(`> Use cached template at ${chalk.yellow(tildify(tmp))}`)
  template = tmp
}

// 开始和结束输出一个空行作为 padding
console.log()
process.on('exit', () => {
  console.log()
})

// 如果是没传构建项目名,或传入 '.',则让用户确定是否在当前目录下构建项目,确定后继续执行 run
// 如果传入了项目名,但项目名已经存在,则让用户确定是否继续在已有项目目录下新建项目,确定后继续执行 run
// 如果没有以上两种特殊情况,继续执行 run
if (inPlace || exists(to)) {
  inquirer.prompt([{
    type: 'confirm',
    message: inPlace
      ? 'Generate project in current directory?'
      : 'Target directory exists. Continue?',
    name: 'ok'
  }]).then(answers => {
    if (answers.ok) {
      run()
    }
  }).catch(logger.fatal)
} else {
  run()
}
// 主函数,用于下载模板,生成项目目录等
function run () {
  // 判断模板是否是本地模板
  if (isLocalPath(template)) {
    // 获取本地模板的绝对路径
    const templatePath = getTemplatePath(template)
    // 本地模板路径存在的话,通过 generate 函数生成项目文件;不存在的话打印错误信息并退出
    if (exists(templatePath)) {
      generate(name, templatePath, to, err => {
        if (err) logger.fatal(err)
        console.log()
        logger.success('Generated "%s".', name)
      })
    } else {
      logger.fatal('Local template "%s" not found.', template)
    }
  } else {
    // 如果模板不是本地模板,则先检查使用者的 node 版本和 vue-cli 版本,符合后执行回调函数
    checkVersion(() => {
      if (!hasSlash) {
        // 如果用的是官方模板,拼接出官方模板的路径,官方模板路径 https://github.com/vuejs-templates/模板名
        const officialTemplate = 'vuejs-templates/' + template
        if (template.indexOf('#') !== -1) {
          // 带 # 号说明是安装 vue@1.0 版本的模板
          // 可以在 https://github.com/vuejs-templates/webpack/tree/1.0 中看到如何初始化 1.0 项目
          downloadAndGenerate(officialTemplate)
        } else {
          // 处理一些模板名的兼容问题,并报出警告
          if (template.indexOf('-2.0') !== -1) {
            warnings.v2SuffixTemplatesDeprecated(template, inPlace ? '' : name)
            return
          }

          // warnings.v2BranchIsNowDefault(template, inPlace ? '' : name)
          downloadAndGenerate(officialTemplate)
        }
      } else {
        // 如果用的不是官方模板,则直接下载模板并生成项目文件
        downloadAndGenerate(template)
      }
    })
  }
}

// 下载模板文件并且生成项目文件
function downloadAndGenerate (template) {
  // 启动 loading 指示器
  const spinner = ora('downloading template')
  spinner.start()
  // 如果本地的模板已经存在,把存在的模板删掉,这样可以确保本地缓存的文件是尽量新的模板文件
  if (exists(tmp)) rm(tmp)
  // 利用 download-git-repo 下载模板文件到本地存储模板的目录,下载成功后执行回调
  download(template, tmp, { clone }, err => {
    // 停止 loading 指示器
    spinner.stop()
    if (err) logger.fatal('Failed to download repo ' + template + ': ' + err.message.trim())
    // 生成项目文件,name -> 项目名称, tmp -> 模板文件路径,to -> 项目生成路径
    generate(name, tmp, to, err => {
      if (err) logger.fatal(err)
      console.log()
      logger.success('Generated "%s".', name)
    })
  })
}

lib 目录

ask.js

该文件负责处理 prompt,实现与用户的问答交互

const async = require('async') // 封装了很多异步处理的 API,用于处理异步流程
const inquirer = require('inquirer') // 命令行问答交互
const evaluate = require('./eval')

// Support types from prompt-for which was used before
const promptMapping = {
  string: 'input',
  boolean: 'confirm'
}

module.exports = function ask (prompts, data, done) {
  // 按顺序依次问问题,所有问题问完后结束流程
  async.eachSeries(Object.keys(prompts), (key, next) => {
    prompt(data, key, prompts[key], next)
  }, done)
}

function prompt (data, key, prompt, done) {
  // 当 prompt 条件不符的时候跳过询问
  if (prompt.when && !evaluate(prompt.when, data)) {
    return done()
  }
  // 默认值处理
  let promptDefault = prompt.default
  if (typeof prompt.default === 'function') {
    promptDefault = function () {
      return prompt.default.bind(this)(data)
    }
  }
  // 根据 meta.json 里 prompt 的格式进行处理,拼装问题,将答案赋予在 data(metalsmith.metadata) 属性上
  inquirer.prompt([{
    type: promptMapping[prompt.type] || prompt.type,
    name: key,
    message: prompt.message || prompt.label || key,
    default: promptDefault,
    choices: prompt.choices || [],
    validate: prompt.validate || (() => true)
  }]).then(answers => {
    if (Array.isArray(answers[key])) {
      data[key] = {}
      answers[key].forEach(multiChoiceAnswer => {
        data[key][multiChoiceAnswer] = true
      })
    } else if (typeof answers[key] === 'string') {
      data[key] = answers[key].replace(/"/g, '\\"')
    } else {
      data[key] = answers[key]
    }
    done()
  }).catch(done)
}

check-version.js

这个文件主要用来检查:

  1. 使用者的 node 是否满足 vue-cli 要求的 node 版本,如果不满足则给出提示要求使用者更新 node 版本,并且不会继续执行脚本
  2. 通过发出 HTTP 请求获取 vue-cli 的最新版本号,与用户当前安装的版本进行比对,如果当前安装版本较低,给用户提示有新版本 vue-cli 可以更新。但即便不更新脚本仍可以继续执行下去
const request = require('request') // 发 HTTP 请求
const semver = require('semver') // 用于 npm 包的版本比较
const chalk = require('chalk') // 多颜色输出
const packageConfig = require('../package.json')

module.exports = done => {
  // Ensure minimum supported node version is used
  if (!semver.satisfies(process.version, packageConfig.engines.node)) {
    return console.log(chalk.red(
      '  You must upgrade node to >=' + packageConfig.engines.node + '.x to use vue-cli'
    ))
  }

  request({
    url: 'https://registry.npmjs.org/vue-cli',
    timeout: 1000
  }, (err, res, body) => {
    if (!err && res.statusCode === 200) {
      const latestVersion = JSON.parse(body)['dist-tags'].latest
      const localVersion = packageConfig.version
      if (semver.lt(localVersion, latestVersion)) {
        console.log(chalk.yellow('  A newer version of vue-cli is available.'))
        console.log()
        console.log('  latest:    ' + chalk.green(latestVersion))
        console.log('  installed: ' + chalk.red(localVersion))
        console.log()
      }
    }
    done()
  })
}

eval.js

const chalk = require('chalk')

// 执行脚本获取 meta.json 里的 prompts 中的各个字段的 when 属性的实际值
module.exports = function evaluate (exp, data) {
  const fn = new Function('data', 'with (data) { return ' + exp + '}')
  try {
    return fn(data)
  } catch (e) {
    console.error(chalk.red('Error when evaluating filter condition: ' + exp))
  }
}

filter.js

const match = require('minimatch')    // 用于文件名匹配使用
const evaluate = require('./eval')

module.exports = (files, filters, data, done) => {
  // 如果没有 filters 部分,直接跳过这个中间件
  if (!filters) {
    return done()
  }
  // 获取所有的文件名
  const fileNames = Object.keys(files)
  Object.keys(filters).forEach(glob => {
    // 遍历所有的文件名,如果文件名与 filter 中的 key 值匹配到,那么判断 key 值对应的 value 值是否为 true(根据用户交互的答案
    // 判断)如果不为 true 那么删除掉文件
    fileNames.forEach(file => {
      if (match(file, glob, { dot: true })) {
        const condition = filters[glob]
        if (!evaluate(condition, data)) {
          delete files[file]
        }
      }
    })
  })
  done()
}

✨ generate.js

重要文件,根据用户与命令行工具的交互生成的项目配置,再结合模板文件,生成最终的项目文件。

读这个文件的时候一定要结合一个模板库来阅读,比如 vue webpack 模板
引入相关包
// 引入的一堆包
const chalk = require('chalk')
const Metalsmith = require('metalsmith') // 一个插件化的静态网站生成器
const Handlebars = require('handlebars') // 模板引擎
const async = require('async') // 封装了很多异步处理的 API
const render = require('consolidate').handlebars.render // 用于渲染各种模板引擎
const path = require('path')
const multimatch = require('multimatch') // 文件系统中多种条件的匹配
const getOptions = require('./options') // 获取模板文件中的元数据信息并进行一些扩展处理
const ask = require('./ask')    // 用于向用户询问问题,完成命令行中的问答交互
const filter = require('./filter') // 用于根据问题答案过滤掉不需要生成的文件
const logger = require('./logger') // 自定义的 log 对象,可以调用不同方法输出不同颜色的 log 信息
注册 Handlebarshelper
// 注册 handlebars 的 helper
Handlebars.registerHelper('if_eq', function (a, b, opts) {
  return a === b
    ? opts.fn(this)
    : opts.inverse(this)
})

Handlebars.registerHelper('unless_eq', function (a, b, opts) {
  return a === b
    ? opts.inverse(this)
    : opts.fn(this)
})

关于 helper 可以查看 Handlebars 官方文档 配合 vue 的模板文件就可以理解了。这里以 vue 模板 webpack 中的 eslint 配置文件 简单举例说明:

这里由 #if_eq/if_eq 包裹的区域是一个 block#if_eq 作为 helper 后面跟着两个参数,对应注册 helper 时的回调函数中的 ab 参数,第三个参数中包含一些渲染模板所需的方法和数据等信息。其中 opts.fn 代表用数据渲染出块中内容,opts.inverse 则不渲染内容并且把模板标记去掉。

因此下面的代码可以理解为:当 lintConfig 的变量值等于 standard 的时候,渲染出中间关于 standard 的配置,否则不去渲染,这就实现了根据用户的交互结果进行动态配置。

{{#if_eq lintConfig "standard"}}
  extends: [
    // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
    // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
    'plugin:vue/essential', 
    // https://github.com/standard/standard/blob/master/docs/RULES-en.md
    'standard'
  ],
{{/if_eq}}
✨ 根据传入的项目名,模板路径,生成路径,生成项目文件
module.exports = function generate (name, src, dest, done) {
  // 获取模板元信息
  const opts = getOptions(name, src)
  // 根据模板路径初始化一个 metalsmith 实例
  const metalsmith = Metalsmith(path.join(src, 'template'))
  // 声明 data 变量(由 metalsmith 中的全局 metadata 和扩展的对象,即第二个参数组成)
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd(),
    noEscape: true
  })
  // 元信息中是否有 helper 字段,有则注册
  opts.helpers && Object.keys(opts.helpers).map(key => {
    Handlebars.registerHelper(key, opts.helpers[key])
  })

  const helpers = { chalk, logger }

  // 元信息中的 metalsmith 字段中是否有 before 函数,有则执行
  if (opts.metalsmith && typeof opts.metalsmith.before === 'function') {
    opts.metalsmith.before(metalsmith, opts, helpers)
  }

  // 1. 问问题并获取答案 2. 过滤掉不需要的文件 3. 跳过不需要渲染的文件
  metalsmith.use(askQuestions(opts.prompts))
    .use(filterFiles(opts.filters))
    .use(renderTemplateFiles(opts.skipInterpolation))

  // 判断元信息中的 metalsmith 字段值是否是函数,是则执行
  // 否则判断 metalsmith 字段值中的 after 字段是否存在且为函数,如果是函数则执行
  if (typeof opts.metalsmith === 'function') {
    opts.metalsmith(metalsmith, opts, helpers)
  } else if (opts.metalsmith && typeof opts.metalsmith.after === 'function') {
    opts.metalsmith.after(metalsmith, opts, helpers)
  }
  // 在写入目标目录的时候不删除原有目录 
  metalsmith.clean(false)
    .source('.') // metalsmith 默认源目录为 './src',修改为 '.'
    .destination(dest) // 设置文件输出路径
    .build((err, files) => {
      done(err)
      // 如果 meta.json 里有 complete 并且为 function,则执行该函数,没有的话打印 meta.json 里的 completeMessage
      if (typeof opts.complete === 'function') {
        const helpers = { chalk, logger, files }
        opts.complete(data, helpers)
      } else {
        logMessage(opts.completeMessage, data)
      }
    })

  return data
}

这一步基本就是在走流程,主要靠 metalsmith 这个包来完成。这个包的工作原理大概三步走:

- 读取源目录下的所有文件
- 引入一些插件来完成对文件的操作和处理
- 输出结果文件至指定的目标目录

其中第二步的插件是类似中间件的机制,metalsmith 的中间件接收三个参数:要处理的文件,metalsmith 实例和一个回调,通过调用回调来触发下一个插件(中间件)

// metalsmith 中间件,用于向用户询问问题
function askQuestions (prompts) {
  return (files, metalsmith, done) => {
    ask(prompts, metalsmith.metadata(), done)
  }
}
// metalsmith 中间件,根据上一个中间件用户交互的结果来筛选删除掉不用的文件
function filterFiles (filters) {
  return (files, metalsmith, done) => {
    filter(files, filters, metalsmith.metadata(), done)
  }
}
// metalsmith 中间件,渲染模板,如果 meta.json 里有 skipInterpolation 字段则跳过与其匹配的文件,不需要对其进行 handlebars 模板渲染
function renderTemplateFiles (skipInterpolation) {
  skipInterpolation = typeof skipInterpolation === 'string'
    ? [skipInterpolation]
    : skipInterpolation
  return (files, metalsmith, done) => {
    const keys = Object.keys(files)
    const metalsmithMetadata = metalsmith.metadata()
    async.each(keys, (file, next) => {
      // 如果有 skipInterpolation,并且与文件有匹配则跳过渲染过程
      if (skipInterpolation && multimatch([file], skipInterpolation, { dot: true }).length) {
        return next()
      }
      // 获取到文件的内容
      const str = files[file].contents.toString()
      // 如果文件内容中没有 handlebars 的标记 {{{ }}} 则跳过渲染
      if (!/{{([^{}]+)}}/g.test(str)) {
        return next()
      }
      // 渲染 handlebars 部分,利用 metalsmithMetadata,其中已经包括了之前用户交互的答案
      render(str, metalsmithMetadata, (err, res) => {
        if (err) {
          err.message = `[${file}] ${err.message}`
          return next(err)
        }
        // 将文件内容替换为渲染出来的结果
        files[file].contents = new Buffer(res)
        next()
      })
    }, done)
  }
}
// 用于打印结束信息
function logMessage (message, data) {
  if (!message) return
  render(message, data, (err, res) => {
    if (err) {
      console.error('\n   Error when rendering template complete message: ' + err.message.trim())
    } else {
      console.log('\n' + res.split(/\r?\n/g).map(line => '   ' + line).join('\n'))
    }
  })
}

git-user.js

用于获取用户所配置的 git 信息

const exec = require('child_process').execSync // node 自带模块,可以执行 shell 命令,并返回相应输出

module.exports = () => {
  let name
  let email

  try {
    name = exec('git config --get user.name')
    email = exec('git config --get user.email')
  } catch (e) {}

  name = name && JSON.stringify(name.toString().trim()).slice(1, -1)
  email = email && (' <' + email.toString().trim() + '>')
  return (name || '') + (email || '')
}

local-path.js

const path = require('path')

module.exports = {
  // 根据模板路径,判断是否是本地模板文件
  // 正则说明:以 . 或 ./ 开头,说明是 UNIX 系统的本地文件;以 C: 或 c: 开头说明是 WINDOWS 系统的本地文件
  isLocalPath (templatePath) {
    return /^[./]|(^[a-zA-Z]:)/.test(templatePath)
  },
  // 判断传入函数的模板路径是否是绝对路径,如果不是的话转成绝对路径
  getTemplatePath (templatePath) {
    return path.isAbsolute(templatePath)
      ? templatePath
      : path.normalize(path.join(process.cwd(), templatePath))
  }
}

logger.js

用于打印格式化的 log 信息

const chalk = require('chalk')
const format = require('util').format // node 中的 format 方法,用于格式化处理

// 设置打印的前缀
const prefix = '   vue-cli'
const sep = chalk.gray('·')

// 打印普通信息并退出
exports.log = function (...args) {
  const msg = format.apply(format, args)
  console.log(chalk.white(prefix), sep, msg)
}

// 打印错误信息并退出
exports.fatal = function (...args) {
  if (args[0] instanceof Error) args[0] = args[0].message.trim()
  const msg = format.apply(format, args)
  console.error(chalk.red(prefix), sep, msg)
  process.exit(1)
}

// 打印成功信息并退出
exports.success = function (...args) {
  const msg = format.apply(format, args)
  console.log(chalk.white(prefix), sep, msg)
}

options.js

获取模板文件中的元数据信息并进行一些扩展

const path = require('path')
const metadata = require('read-metadata') // 读取 JSON 或者 YAML 格式元数据文件,并将其转为对象
const exists = require('fs').existsSync
const getGitUser = require('./git-user') // 获取用户的 git 配置信息:用户名<用户邮箱>
const validateName = require('validate-npm-package-name') // 校验传入的字符串是否是合法的 npm 包名

// 读取模板库中的元数据文件,并进行扩展
module.exports = function options (name, dir) {
  const opts = getMetadata(dir)

  // 设置项目默认名字
  setDefault(opts, 'name', name)
  // 设置名字的合法校验规则
  setValidateName(opts)

  // 设置项目的 author 信息
  const author = getGitUser()
  if (author) {
    setDefault(opts, 'author', author)
  }

  return opts
}

// 获取模板库中 meta.js 或者 meta.json 文件中的配置信息
function getMetadata (dir) {
  const json = path.join(dir, 'meta.json')
  const js = path.join(dir, 'meta.js')
  let opts = {}

  if (exists(json)) {
    opts = metadata.sync(json)
  } else if (exists(js)) {
    const req = require(path.resolve(js))
    if (req !== Object(req)) {
      throw new Error('meta.js needs to expose an object')
    }
    opts = req
  }

  return opts
}

// 为某个交互问题设置默认值
function setDefault (opts, key, val) {
  if (opts.schema) {
    opts.prompts = opts.schema
    delete opts.schema
  }
  const prompts = opts.prompts || (opts.prompts = {})
  if (!prompts[key] || typeof prompts[key] !== 'object') {
    prompts[key] = {
      'type': 'string',
      'default': val
    }
  } else {
    prompts[key]['default'] = val
  }
}

// 在 opts 中的对 name 的 validate 基础上,添加了对项目名是否合法的校验(是否是合法的 npm 包)
function setValidateName (opts) {
  const name = opts.prompts.name
  const customValidate = name.validate
  name.validate = name => {
    const its = validateName(name)
    if (!its.validForNewPackages) {
      const errors = (its.errors || []).concat(its.warnings || [])
      return 'Sorry, ' + errors.join(' and ') + '.'
    }
    if (typeof customValidate === 'function') return customValidate(name)
    return true
  }
}

warning.js

用于提示用户模板名的变化,主要用于 vue@1.0 和 vue@2.0 过渡时期

const chalk = require('chalk')

module.exports = {
  // 表示模板名中带有 '-2.0' 的模板已经弃用了,默认用 2.0 模板
  v2SuffixTemplatesDeprecated (template, name) {
    const initCommand = 'vue init ' + template.replace('-2.0', '') + ' ' + name

    console.log(chalk.red('  This template is deprecated, as the original template now uses Vue 2.0 by default.'))
    console.log()
    console.log(chalk.yellow('  Please use this command instead: ') + chalk.green(initCommand))
    console.log()
  },
  // 告知用户如果想安装 vue@1.0 的模板应该如何输入命令
  // 但目前在 vue init 的 run 函数中已经被注释掉了,可见 vue 的版本过渡已经完成了
  v2BranchIsNowDefault (template, name) {
    const vue1InitCommand = 'vue init ' + template + '#1.0' + ' ' + name

    console.log(chalk.green('  This will install Vue 2.x version of the template.'))
    console.log()
    console.log(chalk.yellow('  For Vue 1.x use: ') + chalk.green(vue1InitCommand))
    console.log()
  }
}

结语

读完整个代码下来难啃的主要就是 vue initgenerate.js 两个文件,只要理清这两个文件中的处理流程,读起来就比较容易了。在读代码的过程中也学习到了很多东西,比如说 CLI 工具的实现、与之相关的各种包的用法与适用场景、对于路径的处理、模板与 CLI 工具的配合、模块化的处理等等。总之 vue-cli 这个项目对于新手而言读起来还是比较友好的,如果是和我一样比较惧怕读源码的小白,不妨试试看!


breezymelon
132 声望3 粉丝

« 上一篇
Vue SSR 初探