目录结构
找到入口之package.json
从上面的图片中,大致可以看出,packages是包的集合,scripts可能是脚本的集合,但是整体入口确不清楚在哪里。分析源码的第一步,找到入口文件,先打开package.json看一下,这毕竟是一个npm项目的声明文件。
{
"private": true,
"workspaces": [
"packages/@vue/*",
"packages/test/*",
"packages/vue-cli-version-marker"
],
"scripts": {
...
},
...
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@typescript-eslint/eslint-plugin": "^4.15.1",
"@typescript-eslint/parser": "^4.15.1",
"@vue/eslint-config-airbnb": "^5.3.0",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-standard": "^6.0.0",
"@vue/eslint-config-typescript": "^7.0.0",
"@vuepress/plugin-pwa": "^1.8.1",
"@vuepress/theme-vue": "^1.8.1",
"lerna": "^3.22.0",
...
},
...
}
上面是省略之后的package文件,我们知道vue-cli是一个多包聚合的项目,那么就必定会使用多包的管理依赖,从devDependencies确定vue-cli也是使用lerna进行管理多包和版本的。lerna的特点是管理的包一般会收集在packages文件夹,并且需要新增lerna.json进行packages的声明,所以找到larna.json,查看一下:
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "5.0.0-beta.2",
"packages": [
"packages/@vue/babel-preset-app",
"packages/@vue/cli*",
"packages/vue-cli-version-marker"
],
"changelog": {
...
}
}
我们关注到,整体的包分为三大类,@vue/babel-preset-app预设相关的,@vue/cli主内容,vue-cli-version-marker版本管理的。自此,从package.json的workspaces和lerna.json的packages字段中,可以发现主要的包其实是在package下的以@vue作为基本目录的资源。我们将视线转移至相应位置
可以看到@vue下的包虽然挺多的,但是命名是很清晰明了的,我们大致可以猜出来,cli是集合可能是入口,cli-plugin应该是实现cli的插件,cli-service,cli-ui等是我们用到的vue server和vue ui的实现。进入cli,我们的任务是找到入口。cli是一个npm包,其目录结构大致是lib、bin、package.json等组成的,一样的,我们从package.json开始。
{
"name": "@vue/cli",
"version": "5.0.0-beta.2",
"description": "Command line interface for rapid Vue.js development",
"bin": {
"vue": "bin/vue.js"
},
"types": "types/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/vuejs/vue-cli.git",
"directory": "packages/@vue/cli"
},
"keywords": [
"vue",
"cli"
],
"author": "Evan You",
"license": "MIT",
...
"engines": {
"node": "^12.0.0 || >= 14.0.0"
}
}
大致浏览下,我们先不关心那么多,package.json中bin属性是自定义命令的,其作用是提供在操作系统上的命令的link到指定文件的功能,也就是说,我们所能调用的命令,到最后其实调用到的是具体的文件,同时采用具体的解释器(Python)、运行时环境(Node.js)或其他程序进行文件的执行过程。可以看到,上面的package中的bin仅初测了一个vue的命令,导向的文件是bin下的vue.js,这就是vue xxx命令执行的入口文件。
分析入口文件之vue.js
打开vue.js文件,我们逐行阅读:本文分析过程采用深度优先过程,我们遇到未知的就停下脚步进行探索,之后再goto回来。
#!/usr/bin/env node
首行定义的是当前文件的默认执行环境,上述的指定自然是使用node的环境,也就是说vue注册的命令在实际执行的时候,是link到vue.js这个文件,并用node进行执行的。
const { chalk, semver } = require('@vue/cli-shared-utils')
const requiredVersion = require('../package.json').engines.node
const leven = require('leven')
require了一些包而已,但是也是要注意一些知识的。
semver:The semantic versioner for npm. 提供的是npm包的版本比较等相关的功能;
chalk:Terminal string styling done right. 终端字体相关的库;
requiredVersion:导入了package中指定的node的版本控制字段;
leven:比较字符串之间的不相同字符数量。这个库的实现其实是涉及到了莱茵斯坦算法的,该算法也是动态规划的一种具体使用的例子。我们先跳过去研究下:
插曲: 莱茵斯坦算法
莱茵斯坦算法是最短距离问题的一种,主要用在求一个文本转换成另一个文本所需要的最小操作。算法将转换操作分为插入(as->asd),删除(asd->ad),替换(asd->add)三种,在比较转换的时候采取的是动态规划的思想,假定两个字符串A(0~i)长度是I、B(0-j)长度是J,我们要得出状态转移方程,设L(i,j)是A和B的最小莱茵斯坦距离,可以得出
L(i,j) = Min(L(i-1,j)+1,L(i,j-1)+1,L(i-1,j-1)+1) 当Ai != Bj的时候
L(i,j) = Min(L(i-1,j)+1,L(i,j-1)+1,L(i-1,j-1)) 当 Ai === Bj的时候
本质思想就是在,上一步的状态会决定下一步的状态,在Ai === Bj在上一步的基础上,那么就不用改变就能保证最小转换。下面是leven库的核心源码:
'use strict';
const array = [];
const charCodeCache = [];
const leven = (left, right) => {
if (left === right) {
return 0;
}
const swap = left;
// Swapping the strings if `a` is longer than `b` so we know which one is the
// shortest & which one is the longest
// 保证最长的字符串是在一侧的 方便处理
if (left.length > right.length) {
left = right;
right = swap;
}
let leftLength = left.length;
let rightLength = right.length;
// Performing suffix trimming:
// We can linearly drop suffix common to both strings since they
// don't increase distance at all
// Note: `~-` is the bitwise way to perform a `- 1` operation
while (leftLength > 0 && (left.charCodeAt(~-leftLength) === right.charCodeAt(~-rightLength))) {
leftLength--;
rightLength--;
}
// Performing prefix trimming
// We can linearly drop prefix common to both strings since they
// don't increase distance at all
let start = 0;
while (start < leftLength && (left.charCodeAt(start) === right.charCodeAt(start))) {
start++;
}
leftLength -= start;
rightLength -= start;
if (leftLength === 0) {
return rightLength;
}
let bCharCode;
let result;
let temp;
let temp2;
let i = 0;
let j = 0;
while (i < leftLength) {
charCodeCache[i] = left.charCodeAt(start + i);
array[i] = ++i;
}
while (j < rightLength) {
bCharCode = right.charCodeAt(start + j);
temp = j++;
result = j;
for (i = 0; i < leftLength; i++) {
temp2 = bCharCode === charCodeCache[i] ? temp : temp + 1;
temp = array[i];
// eslint-disable-next-line no-multi-assign
// 一维数组存贮结果,此处进行莱茵斯坦距离的计算,这里可以写成Math.min(temp+1,result+1,temp2),为什么写成三目运算可能是因为这样比较可以将概率最大的比较放在前面使得更快返回结果,而Math.min是一个O(n)的遍历
result = array[i] = temp > result ? temp2 > result ? result + 1 : temp2 : temp2 > temp ? temp + 1 : temp2;
}
}
return result;
};
...
vue.js方法之checkNodeVersion
vue.js接着往下探索,是一个checkNodeVersion的函数声明和调用,在package.json中的engins.node指定了最低版本,这里进行检查,不足则进程退出。
function checkNodeVersion (wanted, id) {
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)
}
}
checkNodeVersion(requiredVersion, '@vue/cli')
再往下,同样是一系列的require,先忽略掉,slash:兼容不同环境的path格式化。再往下看,运行测试用例的时候,开启debug模式,debug模式的决定字段是在process.env中的,环境变量同样可类似成多个包之间的一个公用的状态管理器,同时恶可以看到vue-cli声明的环境变量都是大写+下划线命名法加以区分的,这样可以和process原本自带的环境变量隔离开,我们自己实现代码的时候也要注意这一点,自己声明的最好有着自己独特的前缀,实现namespace的隔离思想。
// enter debug mode when creating test repo
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
}
接着向下,两个require声明。我们注意到了commander库的引入,这个就是注册命令的库;loadCommand看起来是一个加载命令对应文件的工具模块。
const program = require('commander')
const loadCommand = require('../lib/util/loadCommand')
这里先大致说下commander库的语法吧
program.option 注册全局的命令option,option就是--opt或者-o这类的命令行传参
program.version / .usage 声明版本 / 声明命令在终端的提示头部信息
program.command 注册命令
program.command.option 注册命令中的option,传参
program.command.action 终端命令敲完Enter的时候的执行
好了,接着看代码:
program
.version(`@vue/cli ${require('../package').version}`)
.usage('<command> [options]')
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 <proxyUrl>', 'Use specified proxy when creating project')
.option('-b, --bare', 'Scaffold project without beginner instructions')
.option('--skipGetStarted', 'Skip displaying "Get started" instructions')
.action((name, options) => {
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)
})
上面这一部分声明了vue create <name>的命令,同时指定了命令的参数(这么多属性指定,我们直接引用vue-cli官方文档上的解释来确定create命令的部分需求):
-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 输出使用帮助信息
预设preset一个不熟悉的概念,我们可以去官方文档上看下作者想实现什么,https://cli.vuejs.org/zh/guide/plugins-and-presets.html#preset,指定的是自动安装的插件或者终端提示的插件交互。值得注意的是,-p提供的是本地和远程的preset信息的指定的。
我们具体看下上述代码中的action的内容
.action((name, options) => {
if (minimist(process.argv.slice(3))._.length > 1) {
// 命令输入的时候参数不足的提醒
}
// --git makes commander to default git to true
if (process.argv.includes('-g') || process.argv.includes('--git')) {
options.forceGit = true
}
// create命令的实际的执行文件,直接引入并传入name和options执行
require('../lib/create')(name, options)
})
require是common.js规范中的引入模块的方法,我们知道Node.js对文件require的时候原生是只支持.js、.node和.json文件的,下面可能会出现直接require文件夹的情况,处理如下:首先判断文件夹下是否有package.json包声明文件,有的话就执行加载分析main属性中的入口文件声明,找到后就加载main指定的文件,若没有main属性或者没有package.json文件,则默认文件名为index,在文件夹当前目录下按照index.js、index.node、index.json进行索引,找到的话就直接加载。
接下来的大篇幅代码都是对各种命令的初始声明了,我们先跳过,到较下方的位置
// output help information on unknown commands
// 对没有注册的命令进行监听
program.on('command:*', ([cmd]) => {
program.outputHelp()
console.log(` ` + chalk.red(`Unknown command ${chalk.yellow(cmd)}.`))
console.log()
// 输出已有的建议命令
suggestCommands(cmd)
process.exitCode = 1
})
// add some useful info on help
// 监听vue --help 命令
program.on('--help', () => {
...
})
// 遍历所有命令 加入初始--help提示
program.commands.forEach(c => c.on('--help', () => console.log()))
看一下建议命令的suggestCommands方法
function suggestCommands (unknownCommand) {
// 收集到所有已经注册的命令
const availableCommands = program.commands.map(cmd => cmd._name)
let suggestion
// 找到最接近的命令
availableCommands.forEach(cmd => {
const isBestMatch = leven(cmd, unknownCommand) < leven(suggestion || '', unknownCommand)
if (leven(cmd, unknownCommand) < 3 && isBestMatch) {
suggestion = cmd
}
})
// 输出提示信息
if (suggestion) {
console.log(` ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`))
}
}
接着代码向下
// enhance common error messages
// 优化命令错误信息:丢失参数,无法解释的属性值,丢失属性值
const enhanceErrorMessages = require('../lib/util/enhanceErrorMessages')
enhanceErrorMessages('missingArgument', argName => {
return `Missing required argument ${chalk.yellow(`<${argName}>`)}.`
})
enhanceErrorMessages('unknownOption', optionName => {
return `Unknown option ${chalk.yellow(optionName)}.`
})
enhanceErrorMessages('optionMissingArgument', (option, flag) => {
return `Missing required argument for option ${chalk.yellow(option.flags)}` + (
flag ? `, got ${chalk.yellow(flag)}` : ``
)
})
// 给commander传入args 其实commander正式运行成功
program.parse(process.argv)
接下来跳转到增强器的声明文件看一下,lib/util/enhanceErrorMessages,
const program = require('commander')
const { chalk } = require('@vue/cli-shared-utils')
module.exports = (methodName, log) => {
program.Command.prototype[methodName] = function (...args) {
if (methodName === 'unknownOption' && this._allowUnknownOption) {
return
}
this.outputHelp()
console.log(` ` + chalk.red(log(...args)))
console.log()
process.exit(1)
}
}
很简短的几行代码啊,但是为什么又require('commander')了进行设置就可以了呢?难道用的是单例模式?这个其实是require的又一个特性了,缓存机制,Node.js在实现require方法的时候,在require对象中有一个cache是专门保存require的方法的,只要第一次导入后的模块,再此后再进行加载的时候,会直接去cache中取到的,所以在这里再次取到的program就是从缓存数组中取出的,我们可以看下require的实际实现Module._load的代码确定一下这个机制:
common.js之require
Module._load = function(request,// id parent, isMain) {
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}
var filename = Module._resolveFilename(request, parent, isMain); // 加载当前文件的模块名
var cachedModule = Module._cache[filename]; // 查看缓存中是否存在该模块
if (cachedModule) {
updateChildren(parent, cachedModule, true); // 存在的话就直接进行当前调用模块的children处理
return cachedModule.exports; // 同时直接返回export对象
}
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}
// Don't call updateChildren(), Module constructor already does.
var module = new Module(filename, parent); // 创建新的模块
if (isMain) {
process.mainModule = module;
module.id = '.';
} // 根模块直接id赋值为空 同时把node的当前主模块指向
Module._cache[filename] = module;// 添加至缓存
tryModuleLoad(module, filename); // 加载模块 其实就是使用IOIE形成作用域修改了module对象的export
return module.exports;
};
Module._load = function(request,// id parent, isMain) {
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}
var filename = Module._resolveFilename(request, parent, isMain); // 加载当前文件的模块名
var cachedModule = Module._cache[filename]; // 查看缓存中是否存在该模块
if (cachedModule) {
updateChildren(parent, cachedModule, true); // 存在的话就直接进行当前调用模块的children处理
return cachedModule.exports; // 同时直接返回export对象
}
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}
// Don't call updateChildren(), Module constructor already does.
var module = new Module(filename, parent); // 创建新的模块
if (isMain) {
process.mainModule = module;
module.id = '.';
} // 根模块直接id赋值为空 同时把node的当前主模块指向
Module._cache[filename] = module;// 添加至缓存
tryModuleLoad(module, filename); // 加载模块 其实就是使用IOIE形成作用域修改了module对象的export
return module.exports;
};
其大致的执行流程就是:Module._load -> tryModuleLoad -> Module.load -> Module._compile -> V8.Module._compile,最终的compile其实就是在require的文件对象的前后拼接上"(function (exports, require, module, __filename, __dirname) {" 和末位的"})"之后由v8中的runInContext进行组装后的函数执行,模块中定义的module.exports执行后就赋值到了传入的形参module对象上了,由此就完成了对象或者值导出的传递,最终将module.exports返回完成导入,这里也是为什么我们不能在模块中直接写出来module = xxx的这种代码,这就直接会导致待导出的值和形参中传入的Module对象直接脱离关系。common.js是由模块隔离出来的作用域,而作用域其实就是最终执行中传输的Module对象。这里的理解可以类比原始导入script脚本的思想的,我们手动实现script的导入的时候,为了实现不同脚本之间的变量隔离,可以先声明一个对象A,执行脚本后将脚本中暴露的所有变量都变成A的属性,最终在实际执行的脚本中,就实现了不同script的变量隔离了。
分析create命令实现之create.js
上述中找到了实现create命令的文件,是在lib下的create.js实现的,我们对其进行分析
const fs = require('fs-extra')
const path = require('path')
const inquirer = require('inquirer')
const Creator = require('./Creator')
const { clearConsole } = require('./util/clearConsole')
const { getPromptModules } = require('./util/createTools')
const { chalk, error, stopSpinner, exit } = require('@vue/cli-shared-utils')
const validateProjectName = require('validate-npm-package-name')
大致先看下引入的模块,其中的Creator就在create.js同级目录下,可以看出,实现create的主要逻辑是多个文件的呢,我们先记录下,在用到Creator对象的时候进行分析。其他模块我们根据语义化的命令,大致可以知道inquirer(终端命令),clearConsole清理终端显示,getPromptModules获取Prompt模块,validateProjectName判断名称合法性。
既然这是个模块,那么我们先找到exports的定义:
module.exports = (...args) => {
return create(...args).catch(err => {
stopSpinner(false) // do not persist
error(err)
if (!process.env.VUE_CLI_TEST) {
process.exit(1)
}
})
}
可以看到实质上就是执行了create方法,进入create function
async function create (projectName, 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 || '.')
// 项目名称要符合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)
}
// 当前目录已经存在的话 同时不是merge代码的时候 需要查看force属性值/提示用户复写目录(删除原目录)
if (fs.existsSync(targetDir) && !options.merge) {
if (options.force) {
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) {
return
} else if (action === 'overwrite') {
console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
await fs.remove(targetDir)
}
}
}
}
// 创建一个Createor 传入项目名称,项目的目录,预设的依赖 同时执行其create方法
const creator = new Creator(name, targetDir, getPromptModules())
await creator.create(options)
}
可以看到,流程是很清晰的,proxy参数为true的时候改变环境变量 -> 确定项目名称和项目目录 -> 目录中不为空的时候,询问用户是否删除重新创建空目录 -> new Creator同时执行create方法。不过在new Creator的时候,发现传入了getPromptModules(),我们先看看这个方法做了什么
exports.getPromptModules = () => {
return [
'vueVersion',
'babel',
'typescript',
'pwa',
'router',
'vuex',
'cssPreprocessors',
'linter',
'unit',
'e2e'
].map(file => require(`../promptModules/${file}`))
}
可以看到,是对一些字符串做了遍历require的操作,字符串甚至还有点熟悉,这和我们create项目的一步一步的提示和操作很相似嘛。
既然require了promptModules中的文件,我们去查看下文件中是怎么定义的,以vueVersion为例:
module.exports = cli => {
cli.injectFeature({
name: 'Choose Vue version',
value: 'vueVersion',
description: 'Choose a version of Vue.js that you want to start the project with',
checked: true
})
cli.injectPrompt({
name: 'vueVersion',
when: answers => answers.features.includes('vueVersion'),
message: 'Choose a version of Vue.js that you want to start the project with',
type: 'list',
choices: [
{
name: '2.x',
value: '2'
},
{
name: '3.x',
value: '3'
}
],
default: '2'
})
cli.onPromptComplete((answers, options) => {
if (answers.vueVersion) {
options.vueVersion = answers.vueVersion
}
})
}
可以看到,定义的就是inquirer看使用的终端选择提示器,用户可对涉及选择的插件的加载进行自定义的选择操作。
实现create.js主要功能之Creator.js
一上来同样的是require的声明
const path = require('path')
const debug = require('debug')
const inquirer = require('inquirer')
const EventEmitter = require('events')
const Generator = require('./Generator')
const cloneDeep = require('lodash.clonedeep')
const sortObject = require('./util/sortObject')
const getVersions = require('./util/getVersions')
const PackageManager = require('./util/ProjectPackageManager')
const { clearConsole } = require('./util/clearConsole')
const PromptModuleAPI = require('./PromptModuleAPI')
const writeFileTree = require('./util/writeFileTree')
const { formatFeatures } = require('./util/features')
const loadLocalPreset = require('./util/loadLocalPreset')
const loadRemotePreset = require('./util/loadRemotePreset')
const generateReadme = require('./util/generateReadme')
const { resolvePkg, isOfficialPlugin } = require('@vue/cli-shared-utils')
仔细查看,我们也能学到一些加载模块方面的优化,像是lodash的包,是提供按需加载的,只需要clonedeep方法的时候可以通过require(lodash.clonedeep)进行单一方法的导入。
再往下,导入了同目录下的option.js模块
const {
defaults,
saveOptions,
loadOptions,
savePreset,
validatePreset,
rcPath
} = require('./options')
这个模块提供了不少的配置项,我们先看下实现:
const fs = require('fs')
const cloneDeep = require('lodash.clonedeep')
const { getRcPath } = require('./util/rcPath')
const { exit } = require('@vue/cli-shared-utils/lib/exit')
const { error } = require('@vue/cli-shared-utils/lib/logger')
const { createSchema, validate } = require('@vue/cli-shared-utils/lib/validate')
const rcPath = exports.rcPath = getRcPath('.vuerc')
const presetSchema ...
const schema ... // 定义校验规则
exports.validatePreset = preset => validate(preset, presetSchema, msg => {
error(`invalid preset options: ${msg}`)
})
// 默认预设
exports.defaultPreset = {
useConfigFiles: false,
cssPreprocessor: undefined,
plugins: {
'@vue/cli-plugin-babel': {},
'@vue/cli-plugin-eslint': {
config: 'base',
lintOn: ['save']
}
}
}
// defaults主要针对vue的版本不同 改变其presets中的vueVersion属性值,默认的插件是babel和eslint。同时提供了和vuerc文件一样的数据结构
exports.defaults = {
lastChecked: undefined,
latestVersion: undefined,
packageManager: undefined,
useTaobaoRegistry: undefined,
presets: {
'default': Object.assign({ vueVersion: '2' }, exports.defaultPreset),
'__default_vue_3__': Object.assign({ vueVersion: '3' }, exports.defaultPreset)
}
}
let cachedOptions
exports.loadOptions = () => {
if (cachedOptions) {
return cachedOptions
}
// 如同vue-cli官方文档中提到的,在 vue create 过程中保存的 preset 会被放在你的 home 目录下的一个配置文件中 (~/.vuerc)。你可以通过直接编辑这个文件来调整、添加、删除保存好的 preset
// 此处就判断是否存在,存在则直接读出文件内容
if (fs.existsSync(rcPath)) {
try {
cachedOptions = JSON.parse(fs.readFileSync(rcPath, 'utf-8'))
} catch (e) {
error(
`Error loading saved preferences: ` +
`~/.vuerc may be corrupted or have syntax errors. ` +
`Please fix/delete it and re-run vue-cli in manual mode.\n` +
`(${e.message})`
)
exit(1)
}
// 校验cachedOptions是否满足schema的限制条件,不满足则提示outdated
validate(cachedOptions, schema, () => {
error(
`~/.vuerc may be outdated. ` +
`Please delete it and re-run vue-cli in manual mode.`
)
})
return cachedOptions
} else {
return {}
}
}
exports.saveOptions = toSave => {
// 汇集默认和用户自定义的预设项
const options = Object.assign(cloneDeep(exports.loadOptions()), toSave)
// 移除无关属性
for (const key in options) {
if (!(key in exports.defaults)) {
delete options[key]
}
}
// 覆盖~/.veurc文件
cachedOptions = options
try {
fs.writeFileSync(rcPath, JSON.stringify(options, null, 2))
return true
} catch (e) {
error(
`Error saving preferences: ` +
`make sure you have write access to ${rcPath}.\n` +
`(${e.message})`
)
}
}
// 修改.vuerc中的presets属性
exports.savePreset = (name, preset) => {
const presets = cloneDeep(exports.loadOptions().presets || {})
presets[name] = preset
return exports.saveOptions({ presets })
}
由此我们得出options这个文件主要是获取默认的vuerc文件对象和覆写vuerc文件内容的方法。
Creator.js再往下,是对@vue/cli-shared-utils工具方法的引入
const {
chalk,
execa,
log,
warn,
error,
hasGit,
hasProjectGit,
hasYarn,
hasPnpm3OrLater,
hasPnpmVersionOrLater,
exit,
loadModule
} = require('@vue/cli-shared-utils')
接下来的代码比较长了,我们从class的定义入手
module.exports = class Creator extends EventEmitter
可以看到Creator是继承了EventEmitter,可以实现出来on/once的方法了都
// name项目名称,context项目所在的目录(pwd+name),promptModules默认显示的可配置项目(回想一下上面的["vueVersion",...].map的方法)
constructor (name, context, promptModules) {
super()
this.name = name
this.context = process.env.VUE_CLI_CONTEXT = context
// 返回预设方案的终端选择器 自定义方案的插件预选器 1
const { presetPrompt, featurePrompt } = this.resolveIntroPrompts()
this.presetPrompt = presetPrompt
this.featurePrompt = featurePrompt
// 返回其他的预设选择器 2
this.outroPrompts = this.resolveOutroPrompts()
this.injectedPrompts = []
this.promptCompleteCbs = []
this.afterInvokeCbs = []
this.afterAnyInvokeCbs = []
this.run = this.run.bind(this)
// DI模式 将promptModules中的injectFeature等事件的操作转换成对this.creator属性中的操作 3
// 随着遍历执行完成 this.featurePrompt.choices就将待选的插件都注入进去
// 同时this.injectedPrompts和this.promptCompleteCbs也注入进去了插件的实现
const promptAPI = new PromptModuleAPI(this)
promptModules.forEach(m => m(promptAPI))
}
这里面有三个点是调用了其他函数了,我们逐个看下,先看下resolveIntroPrompts
// 返回的是两个终端选择功能,选择预设的方案,自定义的时候选择自动安装的插件
resolveIntroPrompts () {
const presets = this.getPresets()
// 格式化预设对象 输出数组 形如
// [
// {
// name: "Default([Vue2], babel, eslint)",
// value: "default"
// }
// ]
const presetChoices = Object.entries(presets).map(([name, preset]) => {
let displayName = name
// 置换default和__default_vue_3__的name
if (name === 'default') {
displayName = 'Default'
} else if (name === '__default_vue_3__') {
displayName = 'Default (Vue 3)'
}
return {
name: `${displayName} (${formatFeatures(preset)})`,
value: name
}
})
// 提示用户选择craete的预设流程,Default ([Vue 2] babel, eslint)/Default (Vue 3) ([Vue 3] babel, eslint)/Manually select features
const presetPrompt = {
name: 'preset',
type: 'list',
message: `Please pick a preset:`,
choices: [
...presetChoices,
{
name: 'Manually select features',
value: '__manual__'
}
]
}
// 选择Manually select features后显示待选择插件 choices初始化为空数组
const featurePrompt = {
name: 'features',
when: isManualMode,
type: 'checkbox',
message: 'Check the features needed for your project:',
choices: [],
pageSize: 10
}
return {
presetPrompt,
featurePrompt
}
}
上面看起来可能比较抽象,我们直接根据结果展示来反推下上述的实现
再看下resolveOutroPrompts方法
// 返回其他的预设选择器
resolveOutroPrompts () {
const outroPrompts = [
{
name: 'useConfigFiles',
when: isManualMode,
type: 'list',
message: 'Where do you prefer placing config for Babel, ESLint, etc.?',
choices: [
{
name: 'In dedicated config files',
value: 'files'
},
{
name: 'In package.json',
value: 'pkg'
}
]
},
{
name: 'save',
when: isManualMode,
type: 'confirm',
message: 'Save this as a preset for future projects?',
default: false
},
{
name: 'saveName',
when: answers => answers.save,
type: 'input',
message: 'Save preset as:'
}
]
// ask for packageManager once
const savedOptions = loadOptions()
if (!savedOptions.packageManager && (hasYarn() || hasPnpm3OrLater())) {
const packageManagerChoices = []
if (hasYarn()) {
packageManagerChoices.push({
name: 'Use Yarn',
value: 'yarn',
short: 'Yarn'
})
}
if (hasPnpm3OrLater()) {
packageManagerChoices.push({
name: 'Use PNPM',
value: 'pnpm',
short: 'PNPM'
})
}
packageManagerChoices.push({
name: 'Use NPM',
value: 'npm',
short: 'NPM'
})
outroPrompts.push({
name: 'packageManager',
type: 'list',
message: 'Pick the package manager to use when installing dependencies:',
choices: packageManagerChoices
})
}
return outroPrompts
}
一如既往的抽象,但是可以看出,这个是在我们选择Manually select features后的可能提示,在命令行中我们也可以试验出来
接下来看一下PromptModuleAPI的巧妙实现,SOLID原则告诉我们高层的策略实现不应当依赖于底层的细节实现,但是底层的细节实现是可以依赖高层的策略实现的。我们看下API这个文件是怎么实现的,这个文件需要和我们之前看过的vueVersion.js这个一起对比
module.exports = class PromptModuleAPI {
constructor (creator) {
this.creator = creator
}
injectFeature (feature) {
this.creator.featurePrompt.choices.push(feature)
}
injectPrompt (prompt) {
this.creator.injectedPrompts.push(prompt)
}
injectOptionForPrompt (name, option) {
this.creator.injectedPrompts.find(f => {
return f.name === name
}).choices.push(option)
}
onPromptComplete (cb) {
this.creator.promptCompleteCbs.push(cb)
}
}
API类主要实现的是对Creator内部数组变量的操作,可以看作一个抽象类,而promptModule中的vueVersion.js等文件相当于是抽象类的具体实现。所以后面在执行到promptModules.forEach(m => m(promptAPI))的时候,就把实现中的各个部分都注入到了当前Creator中来了。
同时constructor定义了run函数,执行OS上的shell命令。
Creator.create
前面调用的时候就发现。Creator new出来之后就开始执行create方法了,我们接着看create方法,看来主要的业务逻辑都在这里
async create (cliOptions = {}, preset = null) {
const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG
const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this
// 按照不同的预设进行不同的加载过程 defaul/--preset/inlinePreset
if (!preset) {
if (cliOptions.preset) {
// vue create foo --preset bar
preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone)
} else if (cliOptions.default) {
// vue create foo --default
preset = defaults.presets.default
} else if (cliOptions.inlinePreset) {
// 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 {
preset = await this.promptAndResolvePreset()
}
}
// clone before mutating
preset = cloneDeep(preset)
// inject core service
// @vue/cli-service插件中保存所有的预设信息和项目名称
preset.plugins['@vue/cli-service'] = Object.assign({
projectName: name
}, preset)
// 回忆一下-b的功能: 忽略新手指导信息
if (cliOptions.bare) {
preset.plugins['@vue/cli-service'].bare = true
}
// legacy support for router
// 安装router插件的初始化 不指定的化是hash router
if (preset.router) {
preset.plugins['@vue/cli-plugin-router'] = {}
if (preset.routerHistoryMode) {
preset.plugins['@vue/cli-plugin-router'].historyMode = true
}
}
// legacy support for vuex
// 配置vuex的插件初始信息
if (preset.vuex) {
preset.plugins['@vue/cli-plugin-vuex'] = {}
}
// 识别包管理工具 可见优先级是从用户指定到.vuerc默认再到yarn和npm等的兜底
const packageManager = (
cliOptions.packageManager ||
loadOptions().packageManager ||
(hasYarn() ? 'yarn' : null) ||
(hasPnpm3OrLater() ? 'pnpm' : 'npm')
)
await clearConsole()
// 新建出来包管理对象 后续install等都是这个对象提供的方法
const pm = new PackageManager({ context, forcePackageManager: packageManager })
log(`✨ Creating project in ${chalk.yellow(context)}.`)
// creating钩子 啧啧啧 继承EventEmitter的必要性
this.emit('creation', { event: 'creating' })
// get latest CLI plugin version
const { latestMinor } = await getVersions()
// generate package.json with plugin dependencies
// 这里生成的就是默认package.json的对象
const pkg = {
name,
version: '0.1.0',
private: true,
devDependencies: {},
...resolvePkg(context)
}
// 遍历操作预设/自定义的plugins
const deps = Object.keys(preset.plugins)
deps.forEach(dep => {
if (preset.plugins[dep]._isPreset) {
return
}
let { version } = preset.plugins[dep]
// 插件版本号处理
// 没有对插件指定版本号 则使用最新的版本 latest;vue-service和babel-preset-env这是脚手架实现的,正式环境版本都由package指定
if (!version) {
// @vue开头的 都会受到package中的version声明影响
if (isOfficialPlugin(dep) || dep === '@vue/cli-service' || dep === '@vue/babel-preset-env') {
version = isTestOrDebug ? `latest` : `~${latestMinor}`
} else {
version = 'latest'
}
}
pkg.devDependencies[dep] = version
})
// write package.json
await writeFileTree(context, {
'package.json': JSON.stringify(pkg, null, 2)
})
// generate a .npmrc file for pnpm, to persist the `shamefully-flatten` flag
// 使用pnpm管理的话 则需要在.npmrc中指定shamefully-hoist/shamefully-flatten
if (packageManager === 'pnpm') {
const pnpmConfig = hasPnpmVersionOrLater('4.0.0')
? 'shamefully-hoist=true\n'
: 'shamefully-flatten=true\n'
await writeFileTree(context, {
'.npmrc': pnpmConfig
})
}
// intilaize git repository before installing deps
// so that vue-cli-service can setup git hooks.
// 这里的重点也是对git的判断上了
const shouldInitGit = this.shouldInitGit(cliOptions)
if (shouldInitGit) {
log(`🗃 Initializing git repository...`)
this.emit('creation', { event: 'git-init' })
await run('git init')
}
// install plugins
// 准备开始install plugin了,理论上我们也可以在这个钩子这里对插件的版本注入操作
log(`⚙\u{fe0f} Installing CLI plugins. This might take a while...`)
log()
this.emit('creation', { event: 'plugins-install' })
if (isTestOrDebug && !process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
// in development, avoid installation process
await require('./util/setupDevProject')(context)
} else {
// install插件了 也可以直接run执行 不过npm实现的是有对接的库的
await pm.install()
}
// run generator
// 生产器
log(`🚀 Invoking generators...`)
this.emit('creation', { event: 'invoking-generators' })
// 合并 获取到所有引入的npm包
const plugins = await this.resolvePlugins(preset.plugins, pkg)
// ※ 重要类 Generator 等下跳转过去看实现
// 传入的参数 package.json的对象,引入的npm包集合,执行完Generator的回调数组,执行每一项invoke的回调数组
const generator = new Generator(context, {
pkg,
plugins,
afterInvokeCbs,
afterAnyInvokeCbs
})
// 直接执行了generate方法 传入了预设中的useConfigFiles的设置
await generator.generate({
extractConfigFiles: preset.useConfigFiles
})
// install additional deps (injected by generators)
log(`📦 Installing additional dependencies...`)
this.emit('creation', { event: 'deps-install' })
log()
// 又一次install 那么Generator预计可能修改了待需要install的包数组呢
if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
await pm.install()
}
// run complete cbs if any (injected by generators)
log(`⚓ Running completion hooks...`)
this.emit('creation', { event: 'completion-hooks' })
// 给了competion生命周期 钩子函数的调用机会
for (const cb of afterInvokeCbs) {
await cb()
}
for (const cb of afterAnyInvokeCbs) {
await cb()
}
// 生成README文件
if (!generator.files['README.md']) {
// generate README.md
log()
log('📄 Generating README.md...')
await writeFileTree(context, {
'README.md': generateReadme(generator.pkg, packageManager)
})
}
// commit initial state
// 默认使用git配置的时候 脚手架会在生成项目之后就执行一次 add和commit
let gitCommitFailed = false
if (shouldInitGit) {
await run('git add -A')
if (isTestOrDebug) {
await run('git', ['config', 'user.name', 'test'])
await run('git', ['config', 'user.email', 'test@test.com'])
await run('git', ['config', 'commit.gpgSign', 'false'])
}
const msg = typeof cliOptions.git === 'string' ? cliOptions.git : 'init'
try {
await run('git', ['commit', '-m', msg, '--no-verify'])
} catch (e) {
gitCommitFailed = true
}
}
// log instructions
log()
log(`🎉 Successfully created project ${chalk.yellow(name)}.`)
if (!cliOptions.skipGetStarted) {
log(
`👉 Get started with the following commands:\n\n` +
(this.context === process.cwd() ? `` : chalk.cyan(` ${chalk.gray('$')} cd ${name}\n`)) +
chalk.cyan(` ${chalk.gray('$')} ${packageManager === 'yarn' ? 'yarn serve' : packageManager === 'pnpm' ? 'pnpm run serve' : 'npm run serve'}`)
)
}
log()
this.emit('creation', { event: 'done' })
// 这里可以看出,git commit失败也不能影响构建流程。康威第二定理所言: 时间再多一件事情也不可能做的完美,但总有时间做完一件事情。即使不够完美,也要先保证主要流程是成功的。
if (gitCommitFailed) {
warn(
`Skipped git commit due to missing username and email in git config, or failed to sign commit.\n` +
`You will need to perform the initial commit yourself.\n`
)
}
generator.printExitLogs()
}
resolvePreset
// 获取.vuerc中指定的预设和默认预设的集合 结果形如
// {
// 'default': Object.assign({ vueVersion: '2' }, exports.defaultPreset),
// '__default_vue_3__': Object.assign({ vueVersion: '3' }, exports.defaultPreset)
// }
getPresets () {
const savedOptions = loadOptions()
return Object.assign({}, savedOptions.presets, defaults.presets)
}
async resolvePreset (name, clone) {
let preset
const savedPresets = this.getPresets()
if (name in savedPresets) {
// 已经保存过的预设方案直接索引到就可以了
preset = savedPresets[name]
} else if (name.endsWith('.json') || /^\./.test(name) || path.isAbsolute(name)) {
// 命令行--preset声明的是json文件 、绝对/相对路径信息等 需要加载解析对应文件
preset = await loadLocalPreset(path.resolve(name))
} else if (name.includes('/')) {
// 名字中含有/的就去远端加载了
log(`Fetching remote preset ${chalk.cyan(name)}...`)
this.emit('creation', { event: 'fetch-remote-preset' })
try {
preset = await loadRemotePreset(name, clone)
} catch (e) {
error(`Failed fetching remote preset ${chalk.cyan(name)}:`)
throw e
}
}
// 错误处理 上述的分支都没命中
if (!preset) {
error(`preset "${name}" not found.`)
const presets = Object.keys(savedPresets)
if (presets.length) {
log()
log(`available presets:\n${presets.join(`\n`)}`)
} else {
log(`you don't seem to have any saved preset.`)
log(`run vue-cli in manual mode to create a preset.`)
}
exit(1)
}
return preset
}
PackageManager
我们先分析下PackageManager的核心需求:执行install。再进一步,核心需求的质量需求:1 兼容不同包管理工具 2 非阻塞 3 友好的错误信息。此时我们再看代码的实现:先只关心构造器和install方法 我们看下流程图
class PackageManager {
constructor ({ context, forcePackageManager } = {}) {
this.context = context || process.cwd()
this._registries = {}
// 确定包管理工具
if (forcePackageManager) {
this.bin = forcePackageManager
} else if (context) {
if (hasProjectYarn(context)) {
this.bin = 'yarn'
} else if (hasProjectPnpm(context)) {
this.bin = 'pnpm'
} else if (hasProjectNpm(context)) {
this.bin = 'npm'
}
}
// if no package managers specified, and no lockfile exists
if (!this.bin) {
this.bin = loadOptions().packageManager || (hasYarn() ? 'yarn' : hasPnpm3OrLater() ? 'pnpm' : 'npm')
}
// 不支持较低版本的npm
if (this.bin === 'npm') {
// npm doesn't support package aliases until v6.9
const MIN_SUPPORTED_NPM_VERSION = '6.9.0'
const npmVersion = stripAnsi(execa.sync('npm', ['--version']).stdout)
if (semver.lt(npmVersion, MIN_SUPPORTED_NPM_VERSION)) {
throw new Error(
'You are using an outdated version of NPM.\n' +
'It does not support some core functionalities of Vue CLI.\n' +
'Please upgrade your NPM version.'
)
}
if (semver.gte(npmVersion, '7.0.0')) {
this.needsPeerDepsFix = true
}
}
// 兜底方案还是npm执行
if (!SUPPORTED_PACKAGE_MANAGERS.includes(this.bin)) {
log()
warn(
`The package manager ${chalk.red(this.bin)} is ${chalk.red('not officially supported')}.\n` +
`It will be treated like ${chalk.cyan('npm')}, but compatibility issues may occur.\n` +
`See if you can use ${chalk.cyan('--registry')} instead.`
)
PACKAGE_MANAGER_CONFIG[this.bin] = PACKAGE_MANAGER_CONFIG.npm
}
// Plugin may be located in another location if `resolveFrom` presents.
const projectPkg = resolvePkg(this.context)
const resolveFrom = projectPkg && projectPkg.vuePlugins && projectPkg.vuePlugins.resolveFrom
// Logically, `resolveFrom` and `context` are distinct fields.
// But in Vue CLI we only care about plugins.
// So it is fine to let all other operations take place in the `resolveFrom` directory.
if (resolveFrom) {
this.context = path.resolve(context, resolveFrom)
}
}
async install () {
const args = []
// npm大于7.0.0的都符合了 只有6.9.0 - 7.0.0才没有
if (this.needsPeerDepsFix) {
args.push('--legacy-peer-deps')
}
if (process.env.VUE_CLI_TEST) {
args.push('--silent', '--no-progress')
}
return await this.runCommand('install', args)
}
async runCommand (command, args) {
const prevNodeEnv = process.env.NODE_ENV
// In the use case of Vue CLI, when installing dependencies,
// the `NODE_ENV` environment variable does no good;
// it only confuses users by skipping dev deps (when set to `production`).
delete process.env.NODE_ENV
// 获取包管理源 设置npm_config_registry YARN_NPM_REGISTRY_SERVER 设置镜像源
await this.setRegistryEnvs()
// 执行install
await executeCommand(
this.bin,
[
...PACKAGE_MANAGER_CONFIG[this.bin][command],
...(args || [])
],
this.context
)
if (prevNodeEnv) {
process.env.NODE_ENV = prevNodeEnv
}
}
// Any command that implemented registry-related feature should support
// `-r` / `--registry` option
// 确定包管理源
async getRegistry (scope) {
const cacheKey = scope || ''
if (this._registries[cacheKey]) {
return this._registries[cacheKey]
}
const args = minimist(process.argv, {
alias: {
r: 'registry'
}
})
let registry
if (args.registry) {
registry = args.registry
} else if (!process.env.VUE_CLI_TEST && await shouldUseTaobao(this.bin)) {
registry = registries.taobao
} else {
try {
if (scope) {
registry = (await execa(this.bin, ['config', 'get', scope + ':registry'])).stdout
}
if (!registry || registry === 'undefined') {
registry = (await execa(this.bin, ['config', 'get', 'registry'])).stdout
}
} catch (e) {
// Yarn 2 uses `npmRegistryServer` instead of `registry`
registry = (await execa(this.bin, ['config', 'get', 'npmRegistryServer'])).stdout
}
}
this._registries[cacheKey] = stripAnsi(registry).trim()
return this._registries[cacheKey]
}
async getAuthConfig (scope) {
// get npmrc (https://docs.npmjs.com/configuring-npm/npmrc.html#files)
const possibleRcPaths = [
path.resolve(this.context, '.npmrc'),
path.resolve(require('os').homedir(), '.npmrc')
]
if (process.env.PREFIX) {
possibleRcPaths.push(path.resolve(process.env.PREFIX, '/etc/npmrc'))
}
// there's also a '/path/to/npm/npmrc', skipped for simplicity of implementation
let npmConfig = {}
for (const loc of possibleRcPaths) {
if (fs.existsSync(loc)) {
try {
// the closer config file (the one with lower index) takes higher precedence
npmConfig = Object.assign({}, ini.parse(fs.readFileSync(loc, 'utf-8')), npmConfig)
} catch (e) {
// in case of file permission issues, etc.
}
}
}
const registry = await this.getRegistry(scope)
const registryWithoutProtocol = registry
.replace(/https?:/, '') // remove leading protocol
.replace(/([^/])$/, '$1/') // ensure ending with slash
const authTokenKey = `${registryWithoutProtocol}:_authToken`
const authUsernameKey = `${registryWithoutProtocol}:username`
const authPasswordKey = `${registryWithoutProtocol}:_password`
const auth = {}
if (authTokenKey in npmConfig) {
auth.token = npmConfig[authTokenKey]
}
if (authPasswordKey in npmConfig) {
auth.username = npmConfig[authUsernameKey]
auth.password = Buffer.from(npmConfig[authPasswordKey], 'base64').toString()
}
return auth
}
async setRegistryEnvs () {
// 包管理源
const registry = await this.getRegistry()
// 环境变量设置
process.env.npm_config_registry = registry
process.env.YARN_NPM_REGISTRY_SERVER = registry
this.setBinaryMirrors()
}
// set mirror urls for users in china
// 设置镜像源
async setBinaryMirrors () {
const registry = await this.getRegistry()
if (registry !== registries.taobao) {
return
}
try {
// chromedriver, etc.
const binaryMirrorConfigMetadata = await this.getMetadata('binary-mirror-config', { full: true })
const latest = binaryMirrorConfigMetadata['dist-tags'] && binaryMirrorConfigMetadata['dist-tags'].latest
const mirrors = binaryMirrorConfigMetadata.versions[latest].mirrors.china
for (const key in mirrors.ENVS) {
process.env[key] = mirrors.ENVS[key]
}
// Cypress
const cypressMirror = mirrors.cypress
const defaultPlatforms = {
darwin: 'osx64',
linux: 'linux64',
win32: 'win64'
}
const platforms = cypressMirror.newPlatforms || defaultPlatforms
const targetPlatform = platforms[require('os').platform()]
// Do not override user-defined env variable
// Because we may construct a wrong download url and an escape hatch is necessary
if (targetPlatform && !process.env.CYPRESS_INSTALL_BINARY) {
const projectPkg = resolvePkg(this.context)
if (projectPkg && projectPkg.devDependencies && projectPkg.devDependencies.cypress) {
const wantedCypressVersion = await this.getRemoteVersion('cypress', projectPkg.devDependencies.cypress)
process.env.CYPRESS_INSTALL_BINARY =
`${cypressMirror.host}/${wantedCypressVersion}/${targetPlatform}/cypress.zip`
}
}
} catch (e) {
// get binary mirror config failed
}
}
}
isOfficialPlugin
isOfficialPlugin就能判断出来是否是@vue开头内置插件
const officialRE = /^@vue\//
exports.isOfficialPlugin = id => exports.isPlugin(id) && officialRE.test(id)
Generator
最后的一项复杂的类Generator 文件生成器,主要是由useConfigFiles来控制的,主要作用是将package中定义的插件的配置项都转移到对应的包管理文件中,举个例子,package中配置babel的配置信息之后,会进行转移保存到.babelrc或者babel.cofig.js中
// 配置插件默认的配置文件信息
const defaultConfigTransforms = {
babel: new ConfigTransform({
file: {
js: ['babel.config.js']
}
}),
postcss: new ConfigTransform({
file: {
js: ['postcss.config.js'],
json: ['.postcssrc.json', '.postcssrc'],
yaml: ['.postcssrc.yaml', '.postcssrc.yml']
}
}),
eslintConfig: new ConfigTransform({
file: {
js: ['.eslintrc.js'],
json: ['.eslintrc', '.eslintrc.json'],
yaml: ['.eslintrc.yaml', '.eslintrc.yml']
}
}),
jest: new ConfigTransform({
file: {
js: ['jest.config.js']
}
}),
browserslist: new ConfigTransform({
file: {
lines: ['.browserslistrc']
}
}),
'lint-staged': new ConfigTransform({
file: {
js: ['lint-staged.config.js'],
json: ['.lintstagedrc', '.lintstagedrc.json'],
yaml: ['.lintstagedrc.yaml', '.lintstagedrc.yml']
}
})
}
const reservedConfigTransforms = {
vue: new ConfigTransform({
file: {
js: ['vue.config.js']
}
})
module.exports = class Generator {
constructor (context, {
pkg = {},
plugins = [],
afterInvokeCbs = [],
afterAnyInvokeCbs = [],
files = {},
invoking = false
} = {}) {
this.context = context
this.plugins = sortPlugins(plugins)
this.originalPkg = pkg
this.pkg = Object.assign({}, pkg)
this.pm = new PackageManager({ context })
this.imports = {}
this.rootOptions = {}
this.afterInvokeCbs = afterInvokeCbs
this.afterAnyInvokeCbs = afterAnyInvokeCbs
this.configTransforms = {}
this.defaultConfigTransforms = defaultConfigTransforms
this.reservedConfigTransforms = reservedConfigTransforms
this.invoking = invoking
// for conflict resolution
this.depSources = {}
// virtual file tree
// 待写入的文件列表
this.files = Object.keys(files).length
// when execute `vue add/invoke`, only created/modified files are written to disk
? watchFiles(files, this.filesModifyRecord = new Set())
// all files need to be written to disk
: files
this.fileMiddlewares = []
this.postProcessFilesCbs = []
// exit messages
this.exitLogs = []
// load all the other plugins
// 获取到所有的依赖包 格式是
// {
// apply: #require的值
// id
// }
this.allPlugins = this.resolveAllPlugins()
const cliService = plugins.find(p => p.id === '@vue/cli-service')
const rootOptions = cliService
? cliService.options
: inferRootOptions(pkg)
this.rootOptions = rootOptions
}
// 初始化所有待安装的依赖包
async initPlugins () {
const { rootOptions, invoking } = this
const pluginIds = this.plugins.map(p => p.id)
// avoid modifying the passed afterInvokes, because we want to ignore them from other plugins
// 这里重新赋值this.afterInvokeCbs的作用是 在引入的包中如果包需要引入其他的子依赖或者其他操作的时候 会执行hooks中的方法 注入GeneratorAPI对象改变this.afterInvokeCbs
// 同理 对下面的this.plugins的this.afterAnyInvokeCbs重新赋值也是基于这个道理的
const passedAfterInvokeCbs = this.afterInvokeCbs
this.afterInvokeCbs = []
// apply hooks from all plugins to collect 'afterAnyHooks'
for (const plugin of this.allPlugins) {
const { id, apply } = plugin
const api = new GeneratorAPI(id, this, {}, rootOptions)
if (apply.hooks) {
await apply.hooks(api, {}, rootOptions, pluginIds)
}
}
// We are doing save/load to make the hook order deterministic
// save "any" hooks
const afterAnyInvokeCbsFromPlugins = this.afterAnyInvokeCbs
// reset hooks
this.afterInvokeCbs = passedAfterInvokeCbs
this.afterAnyInvokeCbs = []
this.postProcessFilesCbs = []
// 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)
}
}
// restore "any" hooks
this.afterAnyInvokeCbs = afterAnyInvokeCbsFromPlugins
}
async generate ({
extractConfigFiles = false,
checkExisting = false
} = {}) {
// 初始化所有要依赖的包 追加依赖包中的依赖问题
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.
// 这里做的事情是对pkg中的对包的配置项提出去到对应的文件 并且移除掉pkg中的配置
// 写入文件
this.extractConfigFiles(extractConfigFiles, checkExisting)
// wait for file resolve
await this.resolveFiles()
// set package.json
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)
}
// 将pkg对象中的配置包的信息 提取到对应的文件中 并保存file对象列表
extractConfigFiles (extractAll, checkExisting) {
// 收集可能是包配置的config的name集合
const configTransforms = Object.assign({},
defaultConfigTransforms,
this.configTransforms,
reservedConfigTransforms
)
// 定义提取方法 判断可能是对包的配置信息的话 执行transfrom方法 将文件内容生成/追加 并存入files数组 同时移除pkg原有的配置项
const extract = key => {
if (
configTransforms[key] &&
this.pkg[key] &&
// do not extract if the field exists in original package.json
!this.originalPkg[key]
) {
const value = this.pkg[key]
const configTransform = configTransforms[key]
const res = configTransform.transform(
value,
checkExisting,
this.files,
this.context
)
const { content, filename } = res
this.files[filename] = ensureEOL(content)
delete this.pkg[key]
}
}
// 判断是否提取所有的包 不是的话 就只提取vue和babel的配置项 即生成/修改vue.config.js和babel.config.js文件
if (extractAll) {
for (const key in this.pkg) {
extract(key)
}
} else {
if (!process.env.VUE_CLI_TEST) {
// by default, always extract vue.config.js
extract('vue')
}
// always extract babel.config.js as this is the only way to apply
// project-wide configuration even to dependencies.
// TODO: this can be removed when Babel supports root: true in package.json
extract('babel')
}
}
...
}
GeneratorAPI
GeneratorAPI主要做的事情是修改Generator类中的回调方法的数组,在plugins中声明hooks可以通过注入GeneratorAPI对象来修改Generator中的属性值。其主要定义了一系列的修改Generator实例对象属性的方法,对应plugin包的generator的hooks中实现即可。
class GeneratorAPI {
/**
* @param {string} id - Id of the owner plugin
* @param {Generator} generator - The invoking Generator instance
* @param {object} options - generator options passed to this plugin
* @param {object} rootOptions - root options (the entire preset)
*/
constructor (id, generator, options, rootOptions) {
this.id = id
this.generator = generator
this.options = options
this.rootOptions = rootOptions
/* eslint-disable no-shadow */
this.pluginsData = generator.plugins
.filter(({ id }) => id !== `@vue/cli-service`)
.map(({ id }) => ({
name: toShortPluginId(id),
link: getPluginLink(id)
}))
/* eslint-enable no-shadow */
this._entryFile = undefined
}
/**
* Resolves the data when rendering templates.
*
* @private
*/
_resolveData (additionalData) {
return Object.assign({
options: this.options,
rootOptions: this.rootOptions,
plugins: this.pluginsData
}, additionalData)
}
/**
* Inject a file processing middleware.
*
* @private
* @param {FileMiddleware} middleware - A middleware function that receives the
* virtual files tree object, and an ejs render function. Can be async.
*/
_injectFileMiddleware (middleware) {
this.generator.fileMiddlewares.push(middleware)
}
/**
* Normalize absolute path, Windows-style path
* to the relative path used as index in this.files
* @param {string} p the path to normalize
*/
_normalizePath (p) {
if (path.isAbsolute(p)) {
p = path.relative(this.generator.context, p)
}
// The `files` tree always use `/` in its index.
// So we need to normalize the path string in case the user passes a Windows path.
return p.replace(/\\/g, '/')
}
/**
* Resolve path for a project.
*
* @param {string} _paths - A sequence of relative paths or path segments
* @return {string} The resolved absolute path, caculated based on the current project root.
*/
resolve (..._paths) {
return path.resolve(this.generator.context, ..._paths)
}
get cliVersion () {
return require('../package.json').version
}
assertCliVersion (range) {
if (typeof range === 'number') {
if (!Number.isInteger(range)) {
throw new Error('Expected string or integer value.')
}
range = `^${range}.0.0-0`
}
if (typeof range !== 'string') {
throw new Error('Expected string or integer value.')
}
if (semver.satisfies(this.cliVersion, range, { includePrerelease: true })) return
throw new Error(
`Require global @vue/cli "${range}", but was invoked by "${this.cliVersion}".`
)
}
get cliServiceVersion () {
// In generator unit tests, we don't write the actual file back to the disk.
// So there is no cli-service module to load.
// In that case, just return the cli version.
if (process.env.VUE_CLI_TEST && process.env.VUE_CLI_SKIP_WRITE) {
return this.cliVersion
}
const servicePkg = loadModule(
'@vue/cli-service/package.json',
this.generator.context
)
return servicePkg.version
}
assertCliServiceVersion (range) {
if (typeof range === 'number') {
if (!Number.isInteger(range)) {
throw new Error('Expected string or integer value.')
}
range = `^${range}.0.0-0`
}
if (typeof range !== 'string') {
throw new Error('Expected string or integer value.')
}
if (semver.satisfies(this.cliServiceVersion, range, { includePrerelease: true })) return
throw new Error(
`Require @vue/cli-service "${range}", but was loaded with "${this.cliServiceVersion}".`
)
}
/**
* Check if the project has a given plugin.
*
* @param {string} id - Plugin id, can omit the (@vue/|vue-|@scope/vue)-cli-plugin- prefix
* @param {string} version - Plugin version. Defaults to ''
* @return {boolean}
*/
hasPlugin (id, versionRange) {
return this.generator.hasPlugin(id, versionRange)
}
/**
* Configure how config files are extracted.
*
* @param {string} key - Config key in package.json
* @param {object} options - Options
* @param {object} options.file - File descriptor
* Used to search for existing file.
* Each key is a file type (possible values: ['js', 'json', 'yaml', 'lines']).
* The value is a list of filenames.
* Example:
* {
* js: ['.eslintrc.js'],
* json: ['.eslintrc.json', '.eslintrc']
* }
* By default, the first filename will be used to create the config file.
*/
addConfigTransform (key, options) {
const hasReserved = Object.keys(this.generator.reservedConfigTransforms).includes(key)
if (
hasReserved ||
!options ||
!options.file
) {
if (hasReserved) {
const { warn } = require('@vue/cli-shared-utils')
warn(`Reserved config transform '${key}'`)
}
return
}
this.generator.configTransforms[key] = new ConfigTransform(options)
}
/**
* Extend the package.json of the project.
* Also resolves dependency conflicts between plugins.
* Tool configuration fields may be extracted into standalone files before
* files are written to disk.
*
* @param {object | () => object} fields - Fields to merge.
* @param {object} [options] - Options for extending / merging fields.
* @param {boolean} [options.prune=false] - Remove null or undefined fields
* from the object after merging.
* @param {boolean} [options.merge=true] deep-merge nested fields, note
* that dependency fields are always deep merged regardless of this option.
* @param {boolean} [options.warnIncompatibleVersions=true] Output warning
* if two dependency version ranges don't intersect.
* @param {boolean} [options.forceOverwrite=false] force using the dependency
* version provided in the first argument, instead of trying to get the newer ones
*/
extendPackage (fields, options = {}) {
const extendOptions = {
prune: false,
merge: true,
warnIncompatibleVersions: true,
forceOverwrite: false
}
// this condition statement is added for compatibility reason, because
// in version 4.0.0 to 4.1.2, there's no `options` object, but a `forceNewVersion` flag
if (typeof options === 'boolean') {
extendOptions.warnIncompatibleVersions = !options
} else {
Object.assign(extendOptions, options)
}
const pkg = this.generator.pkg
const toMerge = isFunction(fields) ? fields(pkg) : fields
for (const key in toMerge) {
const value = toMerge[key]
const existing = pkg[key]
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
// use special version resolution merge
pkg[key] = mergeDeps(
this.id,
existing || {},
value,
this.generator.depSources,
extendOptions
)
} else if (!extendOptions.merge || !(key in pkg)) {
pkg[key] = value
} else if (Array.isArray(value) && Array.isArray(existing)) {
pkg[key] = mergeArrayWithDedupe(existing, value)
} else if (isObject(value) && isObject(existing)) {
pkg[key] = deepmerge(existing, value, { arrayMerge: mergeArrayWithDedupe })
} else {
pkg[key] = value
}
}
if (extendOptions.prune) {
pruneObject(pkg)
}
}
/**
* Render template files into the virtual files tree object.
*
* @param {string | object | FileMiddleware} source -
* Can be one of:
* - relative path to a directory;
* - Object hash of { sourceTemplate: targetFile } mappings;
* - a custom file middleware function.
* @param {object} [additionalData] - additional data available to templates.
* @param {object} [ejsOptions] - options for ejs.
*/
render (source, additionalData = {}, ejsOptions = {}) {
const baseDir = extractCallDir()
if (isString(source)) {
source = path.resolve(baseDir, source)
this._injectFileMiddleware(async (files) => {
const data = this._resolveData(additionalData)
const globby = require('globby')
const _files = await globby(['**/*'], { cwd: source, dot: true })
for (const rawPath of _files) {
const targetPath = rawPath.split('/').map(filename => {
// dotfiles are ignored when published to npm, therefore in templates
// we need to use underscore instead (e.g. "_gitignore")
if (filename.charAt(0) === '_' && filename.charAt(1) !== '_') {
return `.${filename.slice(1)}`
}
if (filename.charAt(0) === '_' && filename.charAt(1) === '_') {
return `${filename.slice(1)}`
}
return filename
}).join('/')
const sourcePath = path.resolve(source, rawPath)
const content = renderFile(sourcePath, data, ejsOptions)
// only set file if it's not all whitespace, or is a Buffer (binary files)
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
files[targetPath] = content
}
}
})
} else if (isObject(source)) {
this._injectFileMiddleware(files => {
const data = this._resolveData(additionalData)
for (const targetPath in source) {
const sourcePath = path.resolve(baseDir, source[targetPath])
const content = renderFile(sourcePath, data, ejsOptions)
if (Buffer.isBuffer(content) || content.trim()) {
files[targetPath] = content
}
}
})
} else if (isFunction(source)) {
this._injectFileMiddleware(source)
}
}
/**
* Push a file middleware that will be applied after all normal file
* middelwares have been applied.
*
* @param {FileMiddleware} cb
*/
postProcessFiles (cb) {
this.generator.postProcessFilesCbs.push(cb)
}
/**
* Push a callback to be called when the files have been written to disk.
*
* @param {function} cb
*/
onCreateComplete (cb) {
this.afterInvoke(cb)
}
afterInvoke (cb) {
this.generator.afterInvokeCbs.push(cb)
}
/**
* Push a callback to be called when the files have been written to disk
* from non invoked plugins
*
* @param {function} cb
*/
afterAnyInvoke (cb) {
this.generator.afterAnyInvokeCbs.push(cb)
}
/**
* Add a message to be printed when the generator exits (after any other standard messages).
*
* @param {} msg String or value to print after the generation is completed
* @param {('log'|'info'|'done'|'warn'|'error')} [type='log'] Type of message
*/
exitLog (msg, type = 'log') {
this.generator.exitLogs.push({ id: this.id, msg, type })
}
/**
* convenience method for generating a js config file from json
*/
genJSConfig (value) {
return `module.exports = ${stringifyJS(value, null, 2)}`
}
/**
* Turns a string expression into executable JS for JS configs.
* @param {*} str JS expression as a string
*/
makeJSOnlyValue (str) {
const fn = () => {}
fn.__expression = str
return fn
}
/**
* Run codemod on a script file or the script part of a .vue file
* @param {string} file the path to the file to transform
* @param {Codemod} codemod the codemod module to run
* @param {object} options additional options for the codemod
*/
transformScript (file, codemod, options) {
const normalizedPath = this._normalizePath(file)
this._injectFileMiddleware(files => {
if (typeof files[normalizedPath] === 'undefined') {
error(`Cannot find file ${normalizedPath}`)
return
}
files[normalizedPath] = runTransformation(
{
path: this.resolve(normalizedPath),
source: files[normalizedPath]
},
codemod,
options
)
})
}
/**
* Add import statements to a file.
*/
injectImports (file, imports) {
const _imports = (
this.generator.imports[file] ||
(this.generator.imports[file] = new Set())
)
;(Array.isArray(imports) ? imports : [imports]).forEach(imp => {
_imports.add(imp)
})
}
/**
* Add options to the root Vue instance (detected by `new Vue`).
*/
injectRootOptions (file, options) {
const _options = (
this.generator.rootOptions[file] ||
(this.generator.rootOptions[file] = new Set())
)
;(Array.isArray(options) ? options : [options]).forEach(opt => {
_options.add(opt)
})
}
/**
* Get the entry file taking into account typescript.
*
* @readonly
*/
get entryFile () {
if (this._entryFile) return this._entryFile
return (this._entryFile = fs.existsSync(this.resolve('src/main.ts')) ? 'src/main.ts' : 'src/main.js')
}
/**
* Is the plugin being invoked?
*
* @readonly
*/
get invoking () {
return this.generator.invoking
}
}
总结vue create
自此,对create命令的代码一览大致告一段落了,接下来我们总结下其主要流程。
1 文件调用线
create.js -> Creator.js -> Generator.js(GeneratorAPI)
2 逻辑流程
可以看到的是,还是有很多我没有提到的,像执行install这种耗时较长的操作的时候,使用单独的进程进行处理,对整体代码所使用的设计模式分析等等,针对create命令我们在之后再进行补充。
我们学习到的
- JS传参的时候,非基本类型的引用传递,方便我们实现控制反转(DI)设计原则;
- OOP设计代码中层级感会很好;
- Node.js状态管理中我们也可以善用process.envs共享状态;
- 文件命名规范的重要性;
- 复杂软件架构的控制分离和业务解耦的思想,将generator的实际实现解耦在不同包中的generator中,提取出来所有的prompt等,vue-cli采用的插件式思想,可以使用注入思想实现对主流程的控制;
- 康威定理,大的系统组织总是比小系统更倾向于分解。vue-cli在我写这篇文件的时候也进行了一次更新,重新简化了craetor.js等主逻辑文件中的代码,将次要逻辑和实现都抽离在了单独的文件中。我们从中也可以学习到在工作中对代码重构的思想,软件在大型化之后就不得不面临着拆分,合理的拆分和解耦就是重构中不可或缺的一部分,善用善思考软件设计很重要,从需求、架构、软件开发方法、设计原则、设计模式等各个方法深入思考,不管是采用DDD为主的以业务为核心分离思想,还是Clean-Architecture的简洁架构,其实都是为了实现在开发和维护中针对需求变化中的代码最小变动,这一点我们在日常开发中可以经常留意下;
- 那么多引入的npm工具包都不知道怎么办?不知道也是正常的,项目嘛,是需求和功能驱动的,只有我们需要这个轮子的时候才能更快的接触和了解这个轮子嘛;
- 冰山和设计模式中的外观模式(Facade Pattern),不管我们写多么复杂的软件或者代码,在遵循迪米特法则下,减少和整洁对外展现,冰山下我们可以实现复杂,冰山之上要美观和整洁。正如代码一样,实现的再是"屎山",只要实现功能,方便团队协作都是不错的代码呢;
- 实现命令行其实不难,拿MVC分层设计来看,命令行也是隶属于view层的。