背景
当使用@vue/cli脚手架创建一个项目的时候,直接使用npm run dev就能成功启动项目,而这行命令帮我们做了什么,脚手架在这中间做了哪些工作,下面就是从我理解的源码(@vue/cli 5.0.8版本)角度来分析一下整个构建流程。
npm run xxx这行命令发生了什么,请查看另一篇文章 详解npm run xxx ,这里主要讲vue-cli-service serve相关。
实际上针对于当前script配置npm run serve 就等同于node ./node_modules/.bin/vue-cli-service
源码解析
1、cli-service/bin/vue-cli-service.js文件
这是脚手架的可执行文件,当npm run serve的之后会通过node_modules/bin/vue-cli-service文件映射到cli-service/bin/vue-cli-service.js文件,并执行。这个文件主要做了两件事:
- 校验node版本。
- 创建Service实例子,并解析命令行参数 ,执行实例子中暴露的run方法启动项目。
1.1、校验node版本
这里在package.json中要求的node版本必须为12.x.x或者>=14, 否则就会报错。
版本号 x.y.z :
z :表示一些小的bugfix, 更改z的号,
y :表示一些大的版本更改,比如一些API的变化
x :表示一些设计的变动及模块的重构之类的,会升级x版本号
在package.json里面dependencies依赖包的版本号前面的符号有两种,一种是~,一种是^。
~的意思是匹配最近的小版本 比如~1.0.2将会匹配所有的1.0.x版本,但不匹配1.1.0
^的意思是最近的一个大版本 比如1.0.2 将会匹配 所有 1.x.x, 但不包括2.x.x
1.2、创建Service实例,并解析命令行参数然后执行
这里通过process.argv.slice(2)来获取到用户在命令行输入的参数,然后通过minimist来进行解析终端命令,最后通过args._[0]来获取我们要执行的cli命令。
举例:
a: 当我们输入npm run serve,实际执行的是node /node_modules/bin/vue-cli-service
“scripts”: {
“serve”: “vue-cli-service serve --mode=dev --skip-plugins=skipPlugins”,
},
b: process.argv: 获取终端执行命令的参数,是一个数组 0: node路径 1: 被执行的js文件路径 2~n: 终端输入的参数。
[
‘/usr/local/bin/node’,
‘/Users/qinyuhao/Desktop/项目/qyh-test/test_11/node_modules/.bin/vue-cli-service’,
‘serve’,
‘–mode=dev’,
‘–skip-plugins=skipPlugins’
]
c: 通过minimist将获取的终端命令转换为对象。
{
_: [ ‘serve’ ],
modern: false,
report: false,
‘report-json’: false,
‘inline-vue’: false,
watch: false,
open: false,
copy: false,
https: false,
verbose: false,
mode: ‘dev’,
‘skip-plugins’: ‘skipPlugins’
}
d: 通过args._[0]获取cli命令 // [ ‘serve’ ]
e: 通过传入参数到Service实例暴露的run方法,并执行,进行初始化启动项目
2、Service类
从入口可以看出,vue-cli-service 的核心是使用了 Service 类,实例化并调用run方法。下面我们看看 Service 类里面做了什么。
2.1 首先在执行run方法之前进入到构造函数做一些初始化配置。
constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
// 绑定当前Service实例在process环境变量上,后续可直接获取上面暴露的钩子
process.VUE_CLI_SERVICE = this
// 是否是第一次启动,已经初始化过了
this.initialized = false
// 当前实例的上下文,一般是当前运行的工作目录
this.context = context
// 创建Service实例时传入的配置项,最后会通过webpack-chain的方式
this.inlineOptions = inlineOptions //undefined
// 通过webpack-chain方式配置的webpack 配置
this.webpackChainFns = []
// 通过confuregure方式配置的webpack 配置
this.webpackRawConfigFns = []
// dev-server配置
this.devServerConfigFns = []
// 内置的命令,包括cli命令和配置命令: serve、build、help...
this.commands = {}
// 项目的package.json的运行目录路径
this.pkgContext = context
// package.json containing the plugins 工作目录下package.json信息
// 如果依赖的vuePlugins有其他不在package.json的配置,就要通过res调用olveFrom来指向其他配置文件地址,
// 并递归resolvePkg获取所有配置信息
this.pkg = this.resolvePkg(pkg)
// inlinePlugins是创建Service实例时传入的配置,会覆盖package.json的配置, 优先级inlinePlugins 》 package.json中配置的plugins
// useBuiltIn是vue-cli-serve内置的插件
this.plugins = this.resolvePlugins(plugins, useBuiltIn)
// 只会使用到部分api,启动项目过程中不会完全加载这个插件
this.pluginsToSkip = new Set() // 在运行期间会将跳过的插件
// 为命令指定模式
// 注册的插件可以通过 module.exports.defaultModes 指定特定的模式
/*{
serve: 'development',
build: 'production',
inspect: 'development',
'test:unit': 'test'
}*/
this.modes = this.plugins.reduce((modes, { apply: { defaultModes } }) => {
return Object.assign(modes, defaultModes)
}, {})
}
在构造函数中,通过resolvePlugins函数,注入了cli指令和配置指令,并使其能识别serve、build、inspect、help等命令。
这个函数主要做了以下一些事情:
- 将cli指令和配置指令,注入以插件等形式注入
- 加载package.json文件中需要的vue插件
- 加载package.json中通过vue属性来配置的本地插件
- 返回项目需要用到的所有插件(经过sortPlugins函数排序之后)
这里面分为4类插件,容易混淆,所以列举了出来各种插件的区别:
主要解析插件为4类:
1、内置插件: builtInPlugins(@vue/cli-serve内部提供的插件,大致分为两类)
--a、动态注入CLI命令的插件,serve、build、inspect、help 这种可以通过npm script运行的指令
--b、对webpack构建的相关配置,base、css、dev、prod @vue/cli-serve将webpack构建功能集成到内部,并暴露相关勾子来允许自定义配置
2、inlinePlugins:在通过vue serve、vue build命令构建一个Service实例的时候传入的插件
3、package.josn插件: 在package.json的devDependencies 和 dependencies 中的 vue 插件 : @vue/cli-plugin-babel (通过isPlugin进行过滤)
4、package.vuePlugins; 在package.json里面的插件,但是不需要加载完整的插件,只需要调用插件的api,放在这里面,在package.json文件中有一个vuePlugins属性可以进行配置
resolvePlugins (inlinePlugins, useBuiltIn) {
const idToPlugin = (id, absolutePath) => ({
id: id.replace(/^.\//, 'built-in:'),
apply: require(absolutePath || id)
})
let plugins
// 内置插件
const builtInPlugins = [
'./commands/serve',
'./commands/build',
'./commands/inspect',
'./commands/help',
// config plugins are order sensitive
'./config/base',
'./config/assets',
'./config/css',
'./config/prod',
'./config/app'
].map((id) => idToPlugin(id))
if (inlinePlugins) {
plugins = useBuiltIn !== false
? builtInPlugins.concat(inlinePlugins)
: inlinePlugins
} else {
const projectPlugins = Object.keys(this.pkg.devDependencies || {})
.concat(Object.keys(this.pkg.dependencies || {}))
.filter(isPlugin) // 通过正则来判断是不是插件 /^(@vue\/|vue-|@[\w-]+(\.)?[\w-]+\/vue-)cli-plugin-/
.map(id => {
if (
this.pkg.optionalDependencies &&
id in this.pkg.optionalDependencies
) {
let apply = loadModule(id, this.pkgContext) // 加载package.json中的插件
if (!apply) {
warn(`Optional dependency ${id} is not installed.`)
apply = () => {}
}
return { id, apply }
} else {
return idToPlugin(id, resolveModule(id, this.pkgContext))
}
})
plugins = builtInPlugins.concat(projectPlugins)
console.log('plugins', plugins);
}
// Local plugins
if (this.pkg.vuePlugins && this.pkg.vuePlugins.service) {
const files = this.pkg.vuePlugins.service
if (!Array.isArray(files)) {
throw new Error(`Invalid type for option 'vuePlugins.service', expected 'array' but got ${typeof files}.`)
}
plugins = plugins.concat(files.map(file => ({
id: `local:${file}`,
apply: loadModule(`./${file}`, this.pkgContext)
})))
}
debug('vue:plugins')(plugins)
const orderedPlugins = sortPlugins(plugins)
debug('vue:plugins-ordered')(orderedPlugins)
return orderedPlugins
}
2.2 run函数解析
run函数主要做了4件事情:
- 获取当前环境模式,以便执行init函数的时候获取环境配置
- 设置需要跳过的插件,在执行init函数的时候不会加载
- 执行init函数,进行一些配置解析,插件加载等功能(下文会细说)
- 校验命令合法性,并启动项目
2.2.1 获取当前环境模式
优先使用命令配置的模式,然后才根据参数(serve、build…)使用构造函数内置的模式,如果当前执行的是build命令,并且配置了watch参数,则回退为dev模式
const mode = args.mode || (name === 'build' && args.watch ? 'development' : this.modes[name])
2.2.2 设置不用完全加载插件
通过解析终端命令之后,会得到skip-plugins的值,会将其转换为数组并加入到pluginsToSkip数组中,在init加载插件时会跳过这些插件,并将skip-plugins从args、rawArgv中删除
setPluginsToSkip (args, rawArgv) {
let skipPlugins = args['skip-plugins'] // 在启动期间不需要运行的插件
const pluginsToSkip = new Set()
if (skipPlugins) {
// When only one appearence, convert to array to prevent duplicate code
if (!Array.isArray(skipPlugins)) {
skipPlugins = Array.from([skipPlugins])
}
// Iter over all --skip-plugins appearences
for (const value of skipPlugins.values()) {
for (const plugin of value.split(',').map(id => resolvePluginId(id))) {
pluginsToSkip.add(plugin)
}
}
}
this.pluginsToSkip = pluginsToSkip
delete args['skip-plugins']
// Delete all --skip-plugin appearences
let index
while ((index = rawArgv.indexOf('--skip-plugins')) > -1) {
rawArgv.splice(index, 2) // Remove the argument and its value
}
}
2.2.3 执行init函数
init函数主要做了如下几件事情:
- 根据模式加载环境配置
- 加载用户配置
- 加载插件
- 加载webpack配置
其中loadedUserOptions是将vue.config.js、vue.config.cjs、vue.config.mjs这几个文件的配置进行解析合并,获取到用户配置的所有信息。
PluginAPI类是给函数注入插件的api,因为我们知道cli指令和配置指令都是基于插件动态注入的。
init (mode = process.env.VUE_CLI_MODE) {
if (this.initialized) {
return
}
this.initialized = true
this.mode = mode
// load mode .env
// 环境变量文件加载顺序: 特定模式环境文件(.env.development.local > .env.development) -》 一般环境文件(.env.local > .env),环境文件不可被覆盖,所以特定模式的优先级高
if (mode) { // 先加载指定mode的环境配置
this.loadEnv(mode)
}
// load base .env // 后加载默认的环境配置 - 会不会覆盖指定的环境配置? - 不会,环境变量不会被覆盖
this.loadEnv()
// load user config
const userOptions = this.loadUserOptions()
const loadedCallback = (loadedUserOptions) => {
this.projectOptions = defaultsDeep(loadedUserOptions, defaults()) // 合并配置并深拷贝一份
debug('vue:project-config')(this.projectOptions)
// apply plugins. 执行插件
this.plugins.forEach(({ id, apply }) => {
if (this.pluginsToSkip.has(id)) return
// apply就是service函数导入的一个函数
// service 插件接受两个参数,一个 PluginAPI 实例,一个包含 vue.config.js 内指定的项目本地选项的对象,或者在 package.json 内的 vue 字段。
apply(new PluginAPI(id, this), this.projectOptions)
})
// apply webpack configs from project config file
// 获取webpack的配置
if (this.projectOptions.chainWebpack) {
this.webpackChainFns.push(this.projectOptions.chainWebpack)
}
if (this.projectOptions.configureWebpack) {
this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
}
}
if (isPromise(userOptions)) {
return userOptions.then(loadedCallback)
} else {
return loadedCallback(userOptions)
}
}
2.2.4 校验命令合法性,并启动项目
校验输入的指令是否合法,并在args、rawArgv删除改指令,比如我们输入npm run serve 这里就是执行的注入的serve函数 -> node_modules/@vue/cli-service/lib/commands/serve.js
args._ = args._ || []
let command = this.commands[name]
if (!command && name) {
error(`command "${name}" does not exist.`)
process.exit(1)
}
if (!command || args.help || args.h) {
command = this.commands.help
} else {
args._.shift() // remove command itself
rawArgv.shift()
}
const { fn } = command
return fn(args, rawArgv)
至此完成了vue-serve-cli部分,之后就到执行serve函数来最终执行完成vue-cli-service serve的命令。
有兴趣的朋友可以关注一下公众号,主要分享前端相关的小知识,方便随时随地一起交流学习。