Vue-Cli 源码分析之 create 指令(持续更新中)

355 阅读6分钟

前言

由于平时工作比较忙,基本上都是利用课余时间写文章的,vue-cli 源码分析这个系列比较长,估计要耗费比较长的时间才能写完,但为了大家能提前看到,所以文章会一直持续更新。前言、开始等部分目前还没有,就直接从代码分析开始,后续文章写完之后,会补上,请大家见谅。如果大家喜欢的话,请给我一个赞或者评论下,谢谢。

开始

vue create

create 入口

vue create 命令的入口在 packages/@vue/cli/bin/vue.js 中。

program
  .command('create <app-name>')
  .description('create a new project powered by vue-cli-service')
  .option('-p, --preset <presetName>', 'Skip prompts and use saved or remote preset')
  .option('-d, --default', 'Skip prompts and use default preset')
  .option('-i, --inlinePreset <json>', 'Skip prompts and use inline JSON string as preset')
  .option('-m, --packageManager <command>', 'Use specified npm client when installing dependencies')
  .option('-r, --registry <url>', 'Use specified npm registry when installing dependencies (only for npm)')
  .option('-g, --git [message]', 'Force git initialization with initial commit message')
  .option('-n, --no-git', 'Skip git initialization')
  .option('-f, --force', 'Overwrite target directory if it exists')
  .option('--merge', 'Merge target directory if it exists')
  .option('-c, --clone', 'Use git clone when fetching remote preset')
  .option('-x, --proxy', 'Use specified proxy when creating project')
  .option('-b, --bare', 'Scaffold project without beginner instructions')
  .option('--skipGetStarted', 'Skip displaying "Get started" instructions')
  .action((name, cmd) => {
    // 返回 options 对象
    const options = cleanArgs(cmd)

    if (minimist(process.argv.slice(3))._.length > 1) {
      console.log(chalk.yellow('\n Info: You provided more than one argument. The first one will be used as the app\'s name, the rest are ignored.'))
    }
    // -g,--git,则 options.forceGit 为 true
    if (process.argv.includes('-g') || process.argv.includes('--git')) {
      options.forceGit = true
    }
    require('../lib/create')(name, options)
  })

部分参数的解释(来源于 vue-cli 官网)。

-p, --preset <presetName>       忽略提示符并使用已保存的或远程的预设选项
-d, --default                   忽略提示符并使用默认预设选项
-i, --inlinePreset <json>       忽略提示符并使用内联的 JSON 字符串预设选项
-m, --packageManager <command>  在安装依赖时使用指定的 npm 客户端
-r, --registry <url>            在安装依赖时使用指定的 npm registry
-g, --git [message]             强制 / 跳过 git 初始化,并可选的指定初始化提交信息
-n, --no-git                    跳过 git 初始化
-f, --force                     覆写目标目录可能存在的配置
-c, --clone                     使用 git clone 获取远程预设选项
-x, --proxy                     使用指定的代理创建项目
-b, --bare                      创建项目时省略默认组件中的新手指导信息
-h, --help                      输出使用帮助信息

当我们从命令行参数中获取到了 options 对象,然后就去加载 packages/@vue/cli/lib/create.js 并执行。

下一节我们将整体介绍 vue create 命令由哪几部分构成。

整体分析

先通过一张图来整体感受下 vue create 的整体过程:

vue create 整体过程

可以看到整个过程还是比较复杂的,为了便于讲解,我把整个过程划分为五个部分:

  • 基础验证
  • 获取预设选项
  • 依赖安装
  • Generator
  • 结尾分析

基础验证

当我们加载了 create.js 之后,会预先定义几个变量:

// -x, --proxy
if (options.proxy) {
  process.env.HTTP_PROXY = options.proxy
}

// 当前工作目录
const cwd = options.cwd || process.cwd()
// 项目名称是否为 .
const inCurrent = projectName === '.'
// 当前项目名称
const name = inCurrent ? path.relative('../', cwd) : projectName
// 当前项目的绝对路径
const targetDir = path.resolve(cwd, projectName || '.')

其中 cwd 表示当前的工作目录,inCurrent 表示当前的项目名称是否为 .name 表示当前的项目名称,targetDir 表示当前项目的绝对路径。

声明完变量之后,紧接着就判断 name 是否符合 npm 包名规范,如果不符合的话,就会打印相应的 error 信息,程序并终止。

// 验证项目名称是否符合 npm 包名规范
const result = validateProjectName(name)
if (!result.validForNewPackages) {
  console.error(chalk.red(`Invalid project name: "${name}"`))
  result.errors && result.errors.forEach(err => {
    console.error(chalk.red.dim('Error: ' + err))
  })
  result.warnings && result.warnings.forEach(warn => {
    console.error(chalk.red.dim('Warning: ' + warn))
  })
  exit(1)
}

validate-npm-package-name 是判断项目名称是否符合 npm 包名规范的库,由 npm 团队负责维护。

如果包名符合规范,就走下面的逻辑。

// targetDir 已经存在且无 --merge
if (fs.existsSync(targetDir) && !options.merge) {
  // -f, --force
  if (options.force) {
    // 删除 targetDir
    await fs.remove(targetDir)
  } else {
    await clearConsole()
    if (inCurrent) {
      // 当前项目名称为 .
      const { ok } = await inquirer.prompt([
        {
          name: 'ok',
          type: 'confirm',
          message: `Generate project in current directory?`
        }
      ])
      if (!ok) {
        return
      }
    } else {
      // 当前项目名称不为 .
      const { action } = await inquirer.prompt([
        {
          name: 'action',
          type: 'list',
          message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
          choices: [
            { name: 'Overwrite', value: 'overwrite' },
            { name: 'Merge', value: 'merge' },
            { name: 'Cancel', value: false }
          ]
        }
      ])
      if (!action) {
        // 选择 Cancel,退出
        return
      } else if (action === 'overwrite') {
        // 选择 Overwrite,则删除 targetDir
        console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
        await fs.remove(targetDir)
      }
    }
  }
}

为了比较直观的看懂这段代码,我特意画了一个流程图方便大家理解。

基础验证

获取预设选项

先来了解下什么是 Preset

Preset

一个 Vue CLI Preset 是一个包含创建新项目所需预定义选项和插件的 JSON 对象,让用户无需在命令提示中选择它们。

vue create 过程中保存的 preset 会被放在你的 home 目录下的一个配置文件中(~/.vuerc)。你可以通过直接编辑这个文件夹来调整、添加、删除保存好的 preset

这里有一个 preset 的示例:

{
  "useConfigFiles": true,
  "cssPreprocessor": "sass",
  "plugins": {
    "@vue/cli-plugin-babel": {},
    "@vue/cli-plugin-eslint": {
      "config": "airbnb",
      "lintOn": ["save", "commit"]
    },
    "@vue/cli-plugin-router": {},
    "@vue/cli-plugin-vuex": {}
  }
}

更多关于 preset 可以前往 vue-cli 官网 插件和 Preset

基础验证完成之后会创建一个 Creator 实例。

// 创建 Creator 实例
const creator = new Creator(name, targetDir, getPromptModules())

getPromptModules

getPromptModules 的代码在 packages/@vue/cli/lib/util/createTool.js 中。

exports.getPromptModules = () => {
  return [
    'babel',
    'typescript',
    'pwa',
    'router',
    'vuex',
    'cssPreprocessors',
    'linter',
    'unit',
    'e2e'
  ].map(file => require(`../promptModules/${file}`))
}

getPromptModules() 获取到的是 babelcssPreprocessore2elinterpwaroutertypescriptunitvuexPrompt 的配置信息。以 unit 为例:

module.exports = cli => {
  cli.injectFeature({
    name: 'Unit Testing',
    value: 'unit',
    short: 'Unit',
    description: 'Add a Unit Testing solution like Jest or Mocha',
    link: 'https://cli.vuejs.org/config/#unit-testing',
    plugins: ['unit-jest', 'unit-mocha']
  })

  cli.injectPrompt({
    name: 'unit',
    when: answers => answers.features.includes('unit'),
    type: 'list',
    message: 'Pick a unit testing solution:',
    choices: [
      {
        name: 'Mocha + Chai',
        value: 'mocha',
        short: 'Mocha'
      },
      {
        name: 'Jest',
        value: 'jest',
        short: 'Jest'
      }
    ]
  })

  cli.onPromptComplete((answers, options) => {
    if (answers.unit === 'mocha') {
      options.plugins['@vue/cli-plugin-unit-mocha'] = {}
    } else if (answers.unit === 'jest') {
      options.plugins['@vue/cli-plugin-unit-jest'] = {}
    }
  })
}
injectFeature

cli.injectFeature 是注入 featurePrompt,即初始化项目时选择的 BabelTypeScriptRouter 等等,如下图:

injectFeature

injectPrompt

cli.injectPrompt 是根据选择的 featurePrompt,然后注入相应的 prompt。如当我们选择了 unit,接下来会让我们选择 Mocha + Chai 还是 Jest,如下图:

injectPrompt

onPromptComplete

cli.onPromptComplete 是一个回调,根据用户的选择添加对应的 plugin。如当我们选择了 Mocha + Chai,就会添加 @vue/cli-plugin-unit-mocha,如当我们选择了 Jest,就会添加 @vue/cli-plugin-unit-jest

new Creator()

接下来我们来看初始化 Creator 实例做的事情:

constructor (name, context, promptModules) {
  super()

  // 项目名称
  this.name = name
  // 项目的绝对路径
  this.context = process.env.VUE_CLI_CONTEXT = context
  const { presetPrompt, featurePrompt } = this.resolveIntroPrompts()
  // preset list
  this.presetPrompt = presetPrompt
  // babel、typescript、pwa 等
  this.featurePrompt = featurePrompt
  // 额外的 prompt
  // 存放项目配置文件、是否保存 preset、选择 package manager
  this.outroPrompts = this.resolveOutroPrompts()
  // feature 对应的 prompt
  this.injectedPrompts = []
  // injectedPrompts 的回调
  this.promptCompleteCbs = []
  this.afterInvokeCbs = []
  this.afterAnyInvokeCbs = []

  this.run = this.run.bind(this)
	
  // featurePrompt 中的 choices 中添加数据
  // injectedPrompts 中添加数据
  // promptCompleteCbs 中添加数据
  const promptAPI = new PromptModuleAPI(this)
  promptModules.forEach(m => m(promptAPI))
}

constructor 中,我们定义了一些实例属性(含义已注明),除了定义基本属性之外,最重要的是如下:

// featurePrompt 中的 choices 中添加数据
// injectedPrompts 中添加数据
// promptCompleteCbs 中添加数据
const promptAPI = new PromptModuleAPI(this)
promptModules.forEach(m => m(promptAPI))

PromptModuleAPI 的源码在 packages/@vue/cli/lib/PromptModuleAPI.js

module.exports = class PromptModuleAPI {
  constructor (creator) {
    // Creator 实例
    this.creator = creator
  }

  // 往 creator.featurePrompt 中的 choices 添加 feature
  injectFeature (feature) {
    this.creator.featurePrompt.choices.push(feature)
  }

  // 往 creator.injectedPrompts 中添加 prompt
  injectPrompt (prompt) {
    this.creator.injectedPrompts.push(prompt)
  }

  // 往 creator.injectedPrompts 中的某个 prompt 的 choices 添加 option
  injectOptionForPrompt (name, option) {
    this.creator.injectedPrompts.find(f => {
      return f.name === name
    }).choices.push(option)
  }

  // 往 creator.promptCompleteCbs 中添加 cb
  onPromptComplete (cb) {
    this.creator.promptCompleteCbs.push(cb)
  }
}

PromptModuleAPI 中定义的 creator 就是我们创建的 Creator 实例,同时还定义了几个方法 injectFeatureinjectPromptinjectOptionForPromptonPromptComplete 都是方便操作 creator 中的 featurePromptinjectedPromptspromptCompleteCbs

当我们创建完 PromptModuleAPI 实例后,紧接着我们遍历 getPromptModules 获取的 promptModules,传入 PromptModuleAPI 实例,初始化 Creator 实例中 featurePromptinjectedPromptspromptCompleteCbs。到此 Creator 构造函数执行完毕。

getPreset

当创建完一个 Creator 实例之后,紧接着就调用它的 create 方法。

// 执行 creator.create 函数
await creator.create(options)

create 函数代码比较多,我们分步来看,首先是获取 preset

// test or debug
const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG
const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this

// preset 为 null
if (!preset) {
  if (cliOptions.preset) {
    // 提供preset,如:vue create foo --preset bar
    preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone)
  } else if (cliOptions.default) {
    // 默认preset,如:vue create foo --default
    preset = defaults.presets.default
  } else if (cliOptions.inlinePreset) {
    // 内联preset,如:vue create foo --inlinePreset {...}
    try {
      preset = JSON.parse(cliOptions.inlinePreset)
    } catch (e) {
      error(`CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`)
      exit(1)
    }
  } else {
    // manual,如:vue create foo
    preset = await this.promptAndResolvePreset()
  }
}

// clone before mutating
preset = cloneDeep(preset)
// 注入 @vue/cli-service
preset.plugins['@vue/cli-service'] = Object.assign({
  projectName: name
}, preset)

// -b, --bare,无新手指引的脚手架项目
if (cliOptions.bare) {
  preset.plugins['@vue/cli-service'].bare = true
}

// router 的旧版本支持
if (preset.router) {
  preset.plugins['@vue/cli-plugin-router'] = {}

  if (preset.routerHistoryMode) {
    preset.plugins['@vue/cli-plugin-router'].historyMode = true
  }
}

// vuex 的 旧版本支持
if (preset.vuex) {
  preset.plugins['@vue/cli-plugin-vuex'] = {}
}

今日到此为止,2020-08-13 23:53 深夜

依赖安装

Generator

结尾分析

总结