Skip to content

vue-cli 源码探索

搭建调试环境

  1. 先去 github 把 vue-cli 的源码下载到本地。
  2. 确保环境一致:
vue-cli 版本:4.5.16
  1. 然后使用 yarn 下载依赖。在这一步我遇到个很奇怪的问题,可能是使用镜像地址 https://registry.npmmirror.com 的原因,也可能是使用 steam ++ 软件的原因,反正下载会失败,报下面的错:
RequestError: Error: unable to verify the first certificate

最后我没有修改 npm 下载地址,然后使用 vpn 下载依赖,才下载成功了。

  1. 如果全局已经安装 @vue/cli,先卸载它。
shell
npm uninstall @vue/cli -g
  1. yarn link
sh
# 
cd packages/@vue/cli
# 将 @vue/cli 包链接到全局
yarn link

# 在 /packages/test 目录下创建一个测试项目。注意还是在 vue-cli 的目录下。
cd packages/test
# 执行下面的命令行,会发现提示找不到脚本
vue create test-app

原因是 yarn link 的项目的 bin 命令地址获取不到。将其配置到环境变量中即可。配置好后需要重启 vscode,不然内置终端还是找不到 bin 命令。

shell
# 获取 yarn 命令所在目录地址
# C:\Users\lukec\AppData\Local\Yarn\bin
yarn global bin

然后就可以使用 vue 命令了。

sh
# 在 /packages/test 目录下创建一个测试项目。注意还是在 vue-cli 的目录下。
cd packages/test
# 4.5.16
vue --version
vue create test-app
cd test-app
yarn serve

或则创建一个 lanuch.json 文件:

json
{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "runtimeExecutable": "npm",
      "console": "integratedTerminal",
      // 传递给运行时可执行文件的可选参数。
      "runtimeArgs": [
        "run",
        "devv"
      ],
    }
  ]
}

根目录的 package.json:

json
{
  "devv": "cd ./packages/test && npm run devv"
}

打断点

我们在 packages\@vue\cli\bin\vue.js 文件中的第 6 行打上断点,然后在 packages\test\package.json 配置脚本 vue create test-vue-app。再然后使用 debug 模式启动脚本,程序就会停在我们打上的断点处。

检查 node 版本

在代码开始,会检查 node 版本。使用 semver.satisfies api 来判断用户的 node 版本是否满足要求。

js
// 打印 v16.18.0
console.log(process.version)
js
// Check node version before requiring/doing anything else
// The user may be on a very old node version
// 翻译:
// 在引入或做任何事之前先检查 node 版本
// 用户可能有一个非常老的 node 版本
function checkNodeVersion (wanted, id) {
  // 用户的 node 版本可以通过 process.version 获取到
  // 如果用户的 node 版本不满足 `@vue/cli` 推荐的 node 版本,
  // 那么就提示用户升级 node 版本并退出
  if (!semver.satisfies(process.version, wanted, { includePrerelease: true })) {
    console.log(chalk.red(
      'You are using Node ' + process.version + ', but this version of ' + id +
      ' requires Node ' + wanted + '.\nPlease upgrade your Node version.'
    ))
    process.exit(1)
  }
}
// 引入的文件就是 @vue/cli 这个包的 package.json
const requiredVersion = require('../package.json').engines.node
// 检查 node
checkNodeVersion(requiredVersion, '@vue/cli')

@vue/cli 的 package.json 中:

json
  "engines": {
    "node": ">=8.9"
  }

推荐用户 node 版本

如果用户使用的 node 版本是 8.x、9.x、11.x、13.x,那么就提示这些版本已经停止支持,推荐用户使用长期支持版本。

js
const EOL_NODE_MAJORS = ['8.x', '9.x', '11.x', '13.x']
for (const major of EOL_NODE_MAJORS) {
  if (semver.satisfies(process.version, major)) {
    console.log(chalk.red(
      `You are using Node ${process.version}.\n` +
      `Node.js ${major} has already reached end-of-life and will not be supported in future major releases.\n` +
      `It's strongly recommended to use an active LTS version instead.`
    ))
  }
}

process.env.VUE_CLI_DEBUG 设置为 true

如果是在 /packages/test 目录下创建的项目,进入 debug 模式

js
if (
  slash(process.cwd()).indexOf('/packages/test') > 0 && (
    fs.existsSync(path.resolve(process.cwd(), '../@vue')) ||
    fs.existsSync(path.resolve(process.cwd(), '../../@vue'))
  )
) {
  process.env.VUE_CLI_DEBUG = true
}

分离实际的选项进新对象

js
program
  .command('create <app-name>')
  // ...
  .action((name, cmd) => {
    // name: test-vue-app
    // options: {git: true}
    const options = cleanArgs(cmd)
  })

cleanArgs 函数

js
function cleanArgs (cmd) {
  const args = {}
  cmd.options.forEach(o => {
    // o.long.replace(/^--/, ''):将选项名的 -- 字符去掉
    // camelize:将由短横线组成的多个单词转换为驼峰式命名
    const key = camelize(o.long.replace(/^--/, ''))
    // if an option is not present and Command has a method with the same name
    // it should not be copied
    if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {
      args[key] = cmd[key]
    }
  })
  return args
}

有个工具函数,将由短横线组成的单词转换为驼峰式命名

js
function camelize (str) {
  return str.replace(/-(\w)/g, (_, c) => c ? c.toUpperCase() : '')
}

调用 create 方法

js
  // vue create <name> 只接收一个参数
  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.'))
  }
  // --git makes commander to default git to true
  if (process.argv.includes('-g') || process.argv.includes('--git')) {
    options.forceGit = true
  }
  require('../lib/create')(name, options)

判断项目名是否合法

js
  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 || '.')
  // 判断项目名是否合法
  const result = validateProjectName(name)

在当前目录下创建项目或者已有同名目录

js
  // 如果目录已经存在且没有合并选项
  if (fs.existsSync(targetDir) && !options.merge) {
    // 如果传递 force 参数
    if (options.force) {
      // 删除源文件
      await fs.remove(targetDir)
    } else {
      // 清空 console 控制台(并不是真的清空,只是将所有内容往移到看不见的地方)
      await clearConsole()
      // 如果执行的是 `vue create .`,那么进入这个 if 判断
      // 在当前目录下生成项目
      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) {
          return
        // 如果是覆盖操作,会先删除 targetDir
        } else if (action === 'overwrite') {
          console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
          await fs.remove(targetDir)
        }
      }
    }
  }

初始化提问信息

js
const creator = new Creator(name, targetDir, getPromptModules())

调用 create 创建方法

js
await creator.create(options)

在这个过程中会进行写入文件,初始化 git,下载依赖等一系列操作。

输入命令有误,猜测用户意图

假设我们输入 vue creat test-app 而不是 vue create test-app 会发生什么呢?让我们调试探索一下吧。

commander 会进入这个回调:

js
program.on('command:*', ([cmd]) => {
  program.outputHelp()
  console.log(`  ` + chalk.red(`Unknown command ${chalk.yellow(cmd)}.`))
  console.log()
  suggestCommands(cmd)
  process.exitCode = 1
})

关键在 suggestCommands 方法。leven 是一个比较两个字符串的差距的库,用来判断是否最相近。

js
function suggestCommands (unknownCommand) {
  const availableCommands = program.commands.map(cmd => cmd._name)

  let suggestion

  availableCommands.forEach(cmd => {
    // for 循环,判断是否是最匹配。
    const isBestMatch = leven(cmd, unknownCommand) < leven(suggestion || '', unknownCommand)
    // 输入的命令与 vue-cli 已有的命令字符至少差了 3 个,才会建议
    if (leven(cmd, unknownCommand) < 3 && isBestMatch) {
      suggestion = cmd
    }
  })
  // 有建议
  if (suggestion) {
    console.log(`  ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`))
  }
}

生成文件

创建 Generator 实例,调用 generator 方法生成文件。

js
    const generator = new Generator(context, {
      pkg,
      plugins,
      afterInvokeCbs,
      afterAnyInvokeCbs
    })
    await generator.generate({
      extractConfigFiles: preset.useConfigFiles
    })

generator 方法:

js
  async generate ({
    extractConfigFiles = false,
    checkExisting = false,
    sortPackageJson = true
  } = {}) {
    await this.initPlugins()

    // save the file system before applying plugin for comparison
    const initialFiles = Object.assign({}, this.files)
    // extract configs from package.json into dedicated files.
    this.extractConfigFiles(extractConfigFiles, checkExisting)
    // wait for file resolve
    await this.resolveFiles()
    // set package.json
    if (sortPackageJson) {
      this.sortPkg()
    }
    this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n'
    // write/update file tree to disk
    await writeFileTree(this.context, this.files, initialFiles, this.filesModifyRecord)
  }

initPlugins 方法中,调用每个插件的 generator 方法:

js
  // apply generators from plugins
  for (const plugin of this.plugins) {
    const { id, apply, options } = plugin
    const api = new GeneratorAPI(id, this, options, rootOptions)
    await apply(api, options, rootOptions, invoking)

    if (apply.hooks) {
      // while we execute the entire `hooks` function,
      // only the `afterInvoke` hook is respected
      // because `afterAnyHooks` is already determined by the `allPlugins` loop above
      await apply.hooks(api, options, rootOptions, pluginIds)
    }
  }

这是 @vue/cli-service 插件的 generator 方法,会去执行创建文件等一系列操作。

这里传入的参数 api 其实就是 GeneratorApi 实例的方法。

js
module.exports = (api, options) => {
  api.render('./template', {
    doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
    useBabel: api.hasPlugin('babel')
  })

  if (options.vueVersion === '3') {
    api.extendPackage({
      dependencies: {
        'vue': '^3.2.13'
      }
    })
  } else {
    api.extendPackage({
      dependencies: {
        'vue': '^2.6.14'
      },
      devDependencies: {
        'vue-template-compiler': '^2.6.14'
      }
    })
  }

  api.extendPackage({
    scripts: {
      'serve': 'vue-cli-service serve',
      'build': 'vue-cli-service build'
    },
    browserslist: [
      '> 1%',
      'last 2 versions',
      'not dead',
      ...(options.vueVersion === '3' ? ['not ie 11'] : [])
    ]
  })

  if (options.cssPreprocessor) {
    const deps = {
      sass: {
        sass: '^1.32.7',
        'sass-loader': '^12.0.0'
      },
      'dart-sass': {
        sass: '^1.32.7',
        'sass-loader': '^12.0.0'
      },
      less: {
        'less': '^4.0.0',
        'less-loader': '^8.0.0'
      },
      stylus: {
        'stylus': '^0.55.0',
        'stylus-loader': '^6.1.0'
      }
    }

    api.extendPackage({
      devDependencies: deps[options.cssPreprocessor]
    })
  }

  // for v3 compatibility
  if (options.router && !api.hasPlugin('router')) {
    require('./router')(api, options, options)
  }

  // for v3 compatibility
  if (options.vuex && !api.hasPlugin('vuex')) {
    require('./vuex')(api, options, options)
  }

  // additional tooling configurations
  if (options.configs) {
    api.extendPackage(options.configs)
  }

  // Delete jsconfig.json when typescript
  if (api.hasPlugin('typescript')) {
    api.render((files) => delete files['jsconfig.json'])
  }
}

Released under the MIT License.