为什么学习
之所以学习vue-cli
的源码,主要是我个人想提升脚手架搭建方面的能力,学习vue-cli
插件机制的设计模式和一些脚手架开发的技巧。
学到了什么
通过学习vue-cli
从输入 CLI 命令、生成代码,安装依赖再到最后开发环境构建的整个流程,我个人收获主要是以下几方面。
插件模式的实现
首先主要是vue-cli
约定的一套插件机制,一个插件就是一个函数,在vue-cli
中插件可以用来修改项目webpack
配置,添加项目依赖包,写入项目文件以及植入新的vue-cli-service
的命令等等。并且使用的插件还支持持久缓存在本地目录下作为preset
,这样下次调用vue-cli
的命令时可以直接基于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": {}
}
}
以vue-cli-service
为例,其生成项目代码的过程用简单的伪代码表示就是以下几步:
- 初始化
Creator
实例,保存一些package.json
的配置项,要写入的文件模板数据等;
class Creator {
constructor(projectName, options) {
// package.json
this.pkg = {
name: projectName,
private: true,
devDependencies: {}
};
// 待执行的插件
this.plugins = [];
// 待生成的文件
this.files = {};
// CLI 选项
this.options = options;
}
// 拓展 package.json
extendPackage = (fields) => {
}
// 插入待生成文件
render = (source,data) => {
}
}
- 获取指定
preset
,如果没有指定的话就让用户选择preset
,并将preset
内部的plugin
写入package.json
的dependencies
内部
resolvePreset = () => {
let preset;
if (this.options.preset) {
const preset = load(this.options.preset);
} else {
const answers = inquirer.prompt({
name: 'preset',
type: 'list',
message: `Please pick a preset:`,
choices: [
{
name: 'Default (Vue 2)',
value: 'Default (Vue 2)'
},
{
name: 'Default (Vue 3)',
value: 'Default (Vue 3)'
},
{
name: 'Manually select features',
value: '__manual__'
}
]
});
preset = answers[preset];
}
const deps = Object.keys(preset.plugins);
deps.forEach(dep => {
this.plugins.push(dep);
this.pkg.devDependencies[dep] = preset.plugins[dep];
})
}
- 生成
package.json
文件
fs.writeFileSync(
path.join(path.cwd(), 'package.json'),
JSON.stringify(pkg, null, 2),
'utf8'
);
- 安装初始
plugin
依赖
shelljs.exec('npm install');
- 初始化
GeneratorAPI
实例,GeneratorAPI
实例传入plugin
内部的generator
函数来拓展Creator
实例的待生成文件
// vue-cli-service 内部 generator/index.js
module.exports = (api, options) => {
api.render('./template', {
doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
useBabel: api.hasPlugin('babel')
})
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'] : [])
]
})
}
// GeneratorAPI 插件实例
class GeneratorAPI {
constructor(creator) {
this.creator = generator;
}
/**
* 从安装插件的路径中生成并渲染模板文件
* @param templatePath
* @param data
*/
render = (path, data) => {
this.creator.renderTemplate(path, data);
};
/**
* 拓展package.json字段
* @param pkg
*/
extendPkg = (pkg) => {
this.creator.extendPkg(pkg);
};
}
// 调用插件
resolvePlugins = () => {
this.plugins.forEach(plugin => {
const apply = require(`${plugin}/generator`);
apply(new GeneratorAPI(this))
});
}
- 再次安装依赖
shelljs.exec('npm install');
- 生成项目文件
writeFileTree(this.files)
当然了,这个只是简化的版本,实际上vue-cli
内部会再通过一个中间的Generator
实例来管理所有插件,并不会完全放在主程序Creator
内部,而GeneratorAPI
则管理所有注入到plugin
的方法。这样每个class
内部各司其责,使得后续维护更加方便。
事件机制
vue-cli
内部主程序Creator
继承了 NodeJS 的EventEmitter
,然后在程序执行的过程中暴露一些事件回调。
EventEmitter
比较简单,on
函数用于绑定事件函数,emit
属性用于触发一个事件。emit
的第一个参数指定事件名称,后续参数将传入回调函数作为参数。
this.emit('creation', { event: 'creating' });
this.emit('creation', { event: 'git-init' })
this.emit('creation', { event: 'plugins-install' })
this.emit('creation', { event: 'invoking-generators' })
this.emit('creation', { event: 'deps-install' })
this.emit('creation', { event: 'completion-hooks' })
this.emit('creation', { event: 'done' })
这样后续在其他插件内部可以通过创建Creator
实例来注入回调函数,例如
const creator = new Creator();
const onCreationEvent = ({ event }) => {
progress.set({ id: PROGRESS_ID, status: event, info: null }, context)
}
creator.on('creation', onCreationEvent)
一些nodejs的package
一般这种 NodeJS CLI 都会大量使用一些第三方package
来简化开发逻辑,vue-cli
内部也不乏一些经常使用的优质第三方开源项目,例如fs-extra
,chalk
等这些,就不全部列举了。
一些nodejs的小方法
在vue-cli
内部有一些通用方法可以直接拿过来用,后续我打算抽成一个单独的node-util
npm 包的形式,整理一些常用的 NodeJS 方法,这样以后维护比较方便。
// 创建文件夹并写入文件
const fs = require('fs-extra')
function writeFileTree (dir, files, include) {
Object.keys(files).forEach((name) => {
if (include && !include.has(name)) return
const filePath = path.join(dir, name)
fs.ensureDirSync(path.dirname(filePath))
fs.writeFileSync(filePath, files[name])
})
}