跳到主要内容

学习vuecli源码的收获

· 阅读需 12 分钟
Oxygen

为什么学习

之所以学习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为例,其生成项目代码的过程用简单的伪代码表示就是以下几步:

  1. 初始化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) => {

}
}
  1. 获取指定preset,如果没有指定的话就让用户选择preset,并将preset内部的plugin写入package.jsondependencies内部
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];
})
}
  1. 生成package.json文件
fs.writeFileSync(
path.join(path.cwd(), 'package.json'),
JSON.stringify(pkg, null, 2),
'utf8'
);
  1. 安装初始plugin依赖
shelljs.exec('npm install');
  1. 初始化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))
});
}
  1. 再次安装依赖
shelljs.exec('npm install');
  1. 生成项目文件
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-extrachalk等这些,就不全部列举了。

一些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])
})
}