vue-cli 源码探索
搭建调试环境
- 先去 github 把 vue-cli 的源码下载到本地。
- 确保环境一致:
vue-cli 版本:4.5.16- 然后使用 yarn 下载依赖。在这一步我遇到个很奇怪的问题,可能是使用镜像地址
https://registry.npmmirror.com的原因,也可能是使用steam ++软件的原因,反正下载会失败,报下面的错:
RequestError: Error: unable to verify the first certificate最后我没有修改 npm 下载地址,然后使用 vpn 下载依赖,才下载成功了。
- 如果全局已经安装 @vue/cli,先卸载它。
npm uninstall @vue/cli -g- yarn link
#
cd packages/@vue/cli
# 将 @vue/cli 包链接到全局
yarn link
# 在 /packages/test 目录下创建一个测试项目。注意还是在 vue-cli 的目录下。
cd packages/test
# 执行下面的命令行,会发现提示找不到脚本
vue create test-app原因是 yarn link 的项目的 bin 命令地址获取不到。将其配置到环境变量中即可。配置好后需要重启 vscode,不然内置终端还是找不到 bin 命令。
# 获取 yarn 命令所在目录地址
# C:\Users\lukec\AppData\Local\Yarn\bin
yarn global bin然后就可以使用 vue 命令了。
# 在 /packages/test 目录下创建一个测试项目。注意还是在 vue-cli 的目录下。
cd packages/test
# 4.5.16
vue --version
vue create test-app
cd test-app
yarn serve或则创建一个 lanuch.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:
{
"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 版本是否满足要求。
// 打印 v16.18.0
console.log(process.version)// 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 中:
"engines": {
"node": ">=8.9"
}推荐用户 node 版本
如果用户使用的 node 版本是 8.x、9.x、11.x、13.x,那么就提示这些版本已经停止支持,推荐用户使用长期支持版本。
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 模式
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
}分离实际的选项进新对象
program
.command('create <app-name>')
// ...
.action((name, cmd) => {
// name: test-vue-app
// options: {git: true}
const options = cleanArgs(cmd)
})cleanArgs 函数
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
}有个工具函数,将由短横线组成的单词转换为驼峰式命名
function camelize (str) {
return str.replace(/-(\w)/g, (_, c) => c ? c.toUpperCase() : '')
}调用 create 方法
// 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)判断项目名是否合法
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)在当前目录下创建项目或者已有同名目录
// 如果目录已经存在且没有合并选项
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)
}
}
}
}初始化提问信息
const creator = new Creator(name, targetDir, getPromptModules())调用 create 创建方法
await creator.create(options)在这个过程中会进行写入文件,初始化 git,下载依赖等一系列操作。
输入命令有误,猜测用户意图
假设我们输入 vue creat test-app 而不是 vue create test-app 会发生什么呢?让我们调试探索一下吧。
commander 会进入这个回调:
program.on('command:*', ([cmd]) => {
program.outputHelp()
console.log(` ` + chalk.red(`Unknown command ${chalk.yellow(cmd)}.`))
console.log()
suggestCommands(cmd)
process.exitCode = 1
})关键在 suggestCommands 方法。leven 是一个比较两个字符串的差距的库,用来判断是否最相近。
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 方法生成文件。
const generator = new Generator(context, {
pkg,
plugins,
afterInvokeCbs,
afterAnyInvokeCbs
})
await generator.generate({
extractConfigFiles: preset.useConfigFiles
})generator 方法:
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 方法:
// 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 实例的方法。
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'])
}
}