本系列一共6篇文章
- Vue CLI 源码探索 [开篇] 整体介绍下 Vue CLI
- Vue CLI 源码探索 [一] @vue/cli 包的概览,已经第一个命令 vue create
- Vue CLI 源码探索 [二] 涉及三个命令(vue add/invoke/inspect)
- Vue CLI 源码探索 [三] 和想象中不太一样的 vue serve/build
- Vue CLI 源码探索 [四] 其他命令(vue init/outdated/upgrade/migrate/info//--help)
- Vue CLI 源码探索 [五] 分析下 Vue CLI 中测试相关的内容
- Vue CLI 源码探索 [六] 探索下 Vue CLI 的插件机制,内容较多,请慢慢看。涉及如下插件(@vue/cli-plugin-vuex/router/babel/typescript/eslint)
下面正文开始啦 ^_^
plugin 插件
组件化:
台式电脑可以分为三部分,显示器、主机、键鼠,主机,内部再次拆分为主板、电源、硬盘、内存条等等部分。每一个部分是自闭和的整体,我理解这就是一种组件化的方式。
插件化:
主板上有很多PCI,这些插槽可以查很多东西,来丰富电脑的功能,比如:网卡、声卡、电视卡、硬盘控制器等等许多东西,那么你说没有拆件电脑能够启动吗,当然只不过有些功能不能实现,比如上网、听音与。插件化就是这种道理,通过丰富的插件简化Vue开发,是你专注于业务逻辑,同时通过官方插件构建的项目也是最佳实践。当然也支持自定义插件,按照统一的插件开发方式写出的插件就能够适配所有 Vue/cli 创建的项目。
插件组成
首先我们头脑中需要有一个插件的整体概念,由哪些部分组成:
.
├── README.md
├── generator.js # generator(可选)
├── index.js # service 插件
├── package.json
├── prompts.js # prompt 文件(可选)
└── ui.js # Vue UI 集成(可选)
安装并执行插件
vue add [plugin],这个命令我们已经在前面讲过了。
插件包函几部分中,generator 和 prompts 是在 vue add 命令执行的时候执行的。
service 插件的执行时机则是在运行 vue-cli-service xxx 命令时,如 vue-cli-service serve,得出这个结论可以看下 @vue/cli-service/lib/Service.js 的 init 方法:
// apply plugins.
// this.plugins 就是当前项目中的全部 Vue CLI 插件
this.plugins.forEach(({ id, apply }) => {
if (this.pluginsToSkip.has(id)) return
// apply 方法就是 插件中 Service 默认导出的函数
apply(new PluginAPI(id, this), this.projectOptions)
})
至于为什么 vue-cli-service serve 最终会走到上面的 init 方法,我们在之前在 探索 vue inspect 时提到过的。
插件列表
官方插件
- @vue/cli-plugin-vuex
- @vue/cli-plugin-router
- @vue/cli-plugin-typescript
- @vue/cli-plugin-eslint
- @vue/cli-plugin-babel
- @vue/cli-plugin-pwa
- @vue/cli-plugin-unit-jest
- @vue/cli-plugin-unit-mocha
- @vue/cli-plugin-e2e-cypress
- @vue/cli-plugin-e2e-nightwatch
TODO
- 翻译 plugin-dev 文档
- @vue/cli-plugin-vuex
- @vue/cli-plugin-router
- @vue/cli-plugin-babel
- @vue/cli-plugin-typescript
- @vue/cli-plugin-eslint
使用Markdown语法来写 todo list 还有个小插曲:不展示 todo list的原因见此
插件开发
插件开发部分的文档可以见我翻译的内容,相信会对你有所帮助。
webpack loader
- vue-loader Vue.js 对应 webpack loader
- babel-loader 就是那个很厉害的工具对应的 webpack 插件
- thread-loader 在一个工作池中运行接下来的 loader
- cache-loader 在磁盘中缓存接下来的 loader
webpack plugin
- fork-ts-checker-webpack-plugin 在单独的进程中运行 typescirpt 类型检查器
@vue/cli-plugin-vuex
这个插件是从 @vue/cli@4.x 开始增加的,规范化 vuex 的使用,同时提供更加完美的默认配置。
源码探索
cli-plugin-vuex 插件由两个部分组成,Service 和 Generator。
Service 部分
一是必须有的 Service 部分:从 package.json 文件中可以看到,主文件是根目录的 index.js,文件内容如下
module.exports = (api, options = {}) => {}
可以看到这里返回了一个空函数,这个是根据这个文档来的。
Generator 部分
还有一部分是 Generator,就是 /generator/index.js:
// 这里的 api 指的是 GeneratorAPI 实例
module.exports = (api, options = {}) => {
// 这里 api.entryFile 指的是 main.js 文件
api.injectImports(api.entryFile, `import store from './store'`)
api.injectRootOptions(api.entryFile, `store`)
api.extendPackage({
dependencies: {
vuex: '^3.1.2'
}
})
api.render('./template', {
})
// 这里指的是 GeneratorAPI 被调用的过程中,如果是 typescript 项目的需要做转换
if (api.invoking && api.hasPlugin('typescript')) {
/* eslint-disable-next-line node/no-extraneous-require */
const convertFiles = require('@vue/cli-plugin-typescript/generator/convert')
convertFiles(api)
}
}
api.entryFile 你可能好奇他到底只的是哪个文件,我们看这里:
@vue/cli/lib/GeneratorAPI.js
/**
* 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')
}
再看下 injectImports 这个方法:
/**
* 添加导入语句到文件中
* 在这里 file 指的是主文件
* imports 就是导入语句
*/
injectImports (file, imports) {
const _imports = (
this.generator.imports[file] ||
(this.generator.imports[file] = new Set())
)
// imports 这里是支持数组的,非数组也会转为数组处理
;(Array.isArray(imports) ? imports : [imports]).forEach(imp => {
_imports.add(imp)
})
}
injectRootOptions 方法:
/**
* 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)
})
}
injectRootOptions 执行后,store 会加入到下面的代码中。
new Vue({
el: '#app',
router,
store,
render: h => h(App)
})
extendPackage 方法是用来扩展项目的 package.json 文件,
/**
* 扩展项目的 package.json 文件
* 也解决不同插件之间的依赖冲突
* 工具配置字段可能在提取之前被提取到独立文件中
* 文件将写入磁盘
*
* @param {object | () => object} fields - 合并的字段
* @param {object} [options] - 用来扩展/合并的选项
* @param {boolean} [options.prune=false] - 在合并之后从对象中移除所有 null/undefined 字段
* @param {boolean} [options.merge=true] 深度合并嵌套字段
* 无论次选项如何依赖字段始终会深度合并
* @param {boolean} [options.warnIncompatibleVersions=true] 如果依赖版本没有相交,将输出警告
*/
extendPackage (fields, options = {}) {
const extendOptions = {
prune: false,
merge: true,
warnIncompatibleVersions: true
}
// 这是为了兼容性
// 版本 4.0.0 到 4.1.2, 没有 `options` 对象, 只有 `forceNewVersion` 标志
if (typeof options === 'boolean') {
extendOptions.warnIncompatibleVersions = !options
} else {
Object.assign(extendOptions, options)
}
const pkg = this.generator.pkg
// 我们传入的是个对象,所以这里走 else 选项
const toMerge = isFunction(fields) ? fields(pkg) : fields
for (const key in toMerge) {
// value = { vuex: '^3.1.2' }
const value = toMerge[key]
// existing = { xxx } 现有依赖
const existing = pkg[key]
// key = dependencies
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
// 使用特定版本解决冲突
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)
}
}
再看 api.render('./template', {}) 这句话,我们找到 GeneratorAPI 对应的 render 方法:
因为我们第一个参数是字符串类型,所以这里仅截取了部分走的到的逻辑
/**
* Render template files into the virtual files tree object.
* 渲染模板文件到虚拟文件树对象
* @param {string | object | FileMiddleware} source -
* 参数可以是下面几种
* - 相对路径;
* - { 模板源:目标文件 } 的哈希对象映射;
* - 自定义的文件中间件函数
* @param {object} [additionalData] - 模板能够获得的额外数据
* @param {object} [ejsOptions] - ejs 的配置信息
*/
render (source, additionalData = {}, ejsOptions = {}) {
const baseDir = extractCallDir()
if (isString(source)) {
source = path.resolve(baseDir, source)
// 这里传入 _injectFileMiddleware 的函数是个参数,所以并不会马上执行
this._injectFileMiddleware(async (files) => {
const data = this._resolveData(additionalData)
const globby = require('globby')
const _files = await globby(['**/*'], { cwd: source })
for (const rawPath of _files) {
const targetPath = rawPath.split('/').map(filename => {
// 以点开头的文件当发布到 npm 上会被忽略,所以在模板中我们需要用下划线取代(例如,"_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)
// 对于二进制文件或者非空白的文件才设置,否则就过滤了
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
files[targetPath] = content
}
}
})
上面的方法中,调用了 _injectFileMiddleware 方法:
/**
* 注入一个文件处理中间件
*
* @private 私有的,通过名字的 下划线可以知道
* @param {FileMiddleware} middleware - 一个中间件函数
* 他接受虚拟文件树对象,和 ejs 渲染函数。可以是异步的
*/
_injectFileMiddleware (middleware) {
this.generator.fileMiddlewares.push(middleware)
}
上面出入 _injectFileMiddleware 的参数,的执行是在 Generator.js 中的 resolveFiles() 方法中
async resolveFiles () {
const files = this.files
for (const middleware of this.fileMiddlewares) {
// 这里将 files 传入,作为文件树的根节点
await middleware(files, ejs.render)
}
...
这里我们看下 Typescript 的转换方式:
// 我们调用的时候传入的仅仅是 GeneratorAPI
module.exports = (api, { tsLint = false, convertJsToTs = true } = {}) => {
const jsRE = /\.js$/
const excludeRE = /^tests\/e2e\/|(\.config|rc)\.js$/
const convertLintFlags = require('../lib/convertLintFlags')
// 这里使用了 GeneratorAPI 的 postProcessFiles 方法
api.postProcessFiles(files => {
// 这里默认值是 true
if (convertJsToTs) {
// 删除所有的有同名 ts 文件的 js 文件
// 简单的将其他 js 文件重命名为 ts 文件
for (const file in files) {
// 这个时候我们操作的还是虚拟文件树 files
if (jsRE.test(file) && !excludeRE.test(file)) {
const tsFile = file.replace(jsRE, '.ts')
if (!files[tsFile]) {
let content = files[file]
if (tsLint) {
content = convertLintFlags(content)
}
files[tsFile] = content
}
delete files[file]
}
}
}
这里我们看下 postProcessFiles 方法:
/**
* push 一个文件中间件,它将在所有普通中间件都执行完成后再执行
* @param {FileMiddleware} cb 参数是一个回调函数
*/
postProcessFiles (cb) {
this.generator.postProcessFilesCbs.push(cb)
}
那么 postProcessFilesCbs 将在哪里执行呢,我们再次回到了 Generator.js 文件的 resolveFiles 方法:
// 这个我们已经在前面讲到了
const files = this.files
for (const middleware of this.fileMiddlewares) {
await middleware(files, ejs.render)
}
...
for (const postProcess of this.postProcessFilesCbs) {
// 这里我们刚刚 push 进去的 中间件将会执行
await postProcess(files)
}
最终的文件写入则在 Generator.js 的 generate 方法中:
...
// 载入文件树
await this.resolveFiles()
...
// 将虚拟文件树写入磁盘
await writeFileTree(this.context, this.files, initialFiles)
@vue/cli-plugin-router
这个插件同 @vue/cli-plugin-vuex 也是从 @vue/cli@4.x 开始有的,目的也是规范化 router 的使用,同时添加更完美的默认配置。
源码探索
Service 部分
同 @vue/cli-plugin-vuex 一致,因为是必须项,所以也是导出空函数
module.exports = (api, options = {}) => {}
Generator 部分
module.exports = (api, options = {}) => {
// 增加入口
api.injectImports(api.entryFile, `import router from './router'`)
// 增加 router 选项
api.injectRootOptions(api.entryFile, `router`)
// 扩展项目的 package.json 文件中的依赖
api.extendPackage({
dependencies: {
'vue-router': '^3.1.5'
}
})
// 渲染模板
api.render('./template', {
historyMode: options.historyMode,
doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
hasTypeScript: api.hasPlugin('typescript')
})
if (api.invoking) {
if (api.hasPlugin('typescript')) {
/* eslint-disable-next-line node/no-extraneous-require */
const convertFiles = require('@vue/cli-plugin-typescript/generator/convert')
convertFiles(api)
}
}
}
前面的部分和 @vue/cli-plugin-vuex 是一致的,这里有区别的地方是,render 方法传了参数:
api.render('./template', {
historyMode: options.historyMode,
doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
hasTypeScript: api.hasPlugin('typescript')
})
// additionalData 这个参数就是上面传入的
render (source, additionalData = {}, ejsOptions = {}) {
...
this._injectFileMiddleware(async (files) => {
// 传入 _resolveData 方法中
const data = this._resolveData(additionalData)
for (const rawPath of _files) {
...
// 处理文件时,作为参数传入
const content = renderFile(sourcePath, data, ejsOptions)
...
}
...
render 方法的第二个参数传入到了 _resolveData 方法中:
/**
* 渲染模板时解析数据
*
* @private
*/
_resolveData (additionalData) {
return Object.assign({
options: this.options,
rootOptions: this.rootOptions,
plugins: this.pluginsData
}, additionalData)
}
/**
* 所以最终返回的对象结构如下
{
options: {},
rootOptions: {},
plugins: {},
historyMode: '',
doesCompile: '',
hasTypeScript: ''
}
*/
然后我们看下 renderFile 方法:
function renderFile (name, data, ejsOptions) {
...
const template = fs.readFileSync(name, 'utf-8')
// custom template inheritance via yaml front matter.
// ---
// extend: 'source-file'
// replace: !!js/regexp /some-regex/
// OR
// replace:
// - !!js/regexp /foo/
// - !!js/regexp /bar/
// ---
// https://github.com/dworthen/js-yaml-front-matter
const yaml = require('yaml-front-matter')
const parsed = yaml.loadFront(template)
// content 就是文件内容
const content = parsed.__content
let finalTemplate = content.trim() + `\n`
...
// data 最终传到 ejs 的 render 方法中
return ejs.render(finalTemplate, data, ejsOptions)
ejs.redner() 这个方法的第一个参数,是模板,第二个参数是传入模板中的变量,第三个则是ejs模板的配置项。所以我们的 data 会模板渲染的时候使用到,那么我们看下模板中是如何使用的:
@vue/cli-plugin-router/generator/template/src/App.vue,首先看这个模板文件:
---
extend: '@vue/cli-service/generator/template/src/App.vue'
replace:
- !!js/regexp /<template>[^]*?<\/template>/
- !!js/regexp /\n<script>[^]*?<\/script>\n/
- !!js/regexp / margin-top[^]*?<\/style>/
---
上面这段是 yaml 语法,首先它继承了 @vue/cli-service/generator/template/src/App.vue 文件(这个是原始的模板),然后替换了3部分内容:
- 首先是模板部分:
<%# REPLACE %>
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</div>
</template>
<%# END_REPLACE %>
然后 script 脚本,替换为空:
<%# REPLACE %>
<%# END_REPLACE %>
最后是样式部分
<%# REPLACE %>
// 这里的括号是为了承接继承的内容
}
// 这里可以看到是用到 data 中的 rootOptions 属性
<%_ if (rootOptions.cssPreprocessor !== 'stylus') { _%>
...
<%# END_REPLACE %>
@vue/cli-plugin-router/generator/template/src/router/index.js 这个文件中用到了通过插件传入过来的参数:hasTypeScript、doesCompile、historyMode:
区分 Typescript,使用不同的导入方式
<%_ if (hasTypeScript) { _%>
import VueRouter, { RouteConfig } from 'vue-router'
<%_ } else { _%>
import VueRouter from 'vue-router'
<%_ } _%>
通过 doescCompile,来区分是否需要编译
<%_ if (doesCompile) { _%>
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
<%_ } else { _%>
component: function () {
return import(/* webpackChunkName: "about" */ '../views/About.vue')
}
<%_ } _%>
通过 historyMode 控制路由模式
const router = new VueRouter({
<%_ if (historyMode) { _%>
mode: 'history',
base: process.env.BASE_URL,
<%_ } _%>
routes
})
Prompts 部分
对话这里只有一个问题,就是路由类型。这个问题的答案在 historyMode: options.historyMode, 这里就用到了。
module.exports = [
{
name: 'historyMode',
type: 'confirm',
message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`
}
]
这里我们看下上面的 prompts 是如何被执行的,我们添加插件是通过 vue add @vue/cli-plugin-router 的方式,然后会执行到 @vue/cli/lib/invoke.js 中的 invoke 方法,我们看下 invoke 方法中处理 prompts 的逻辑:
...
} else if (!Object.keys(pluginOptions).length) {
// 这里就载入了我们定义在插件中的 对话
let pluginPrompts = loadModule(`${id}/prompts`, context)
if (pluginPrompts) {
if (typeof pluginPrompts === 'function') {
pluginPrompts = pluginPrompts(pkg)
}
if (typeof pluginPrompts.getPrompts === 'function') {
pluginPrompts = pluginPrompts.getPrompts(pkg)
}
// 因为我们的插件中返回的是数组,所有就直接执行了(开始对话)
pluginOptions = await inquirer.prompt(pluginPrompts)
}
}
@vue/cli-plugin-babel
babel 用来做语法转换
Service
module.exports = (api, options) => {
// 如果是 生产环境 并且 开启了 parallel(并行打包)则为 true
const useThreads = process.env.NODE_ENV === 'production' && !!options.parallel
const cliServicePath = path.dirname(require.resolve('@vue/cli-service'))
// 载入需要额外使用 babel-loader 进行转化的目录
const transpileDepRegex = genTranspileDepRegex(options.transpileDependencies)
// try to load the project babel config;
// if the default preset is used,
// there will be a VUE_CLI_TRANSPILE_BABEL_RUNTIME env var set.
// the `filename` field is required
// in case there're filename-related options like `ignore` in the user config
babel.loadPartialConfigSync({ filename: api.resolve('src/main.js') })
api.chainWebpack(webpackConfig => {
webpackConfig.resolveLoader.modules.prepend(path.join(__dirname, 'node_modules'))
const jsRule = webpackConfig.module
.rule('js')
.test(/\.m?jsx?$/)
.exclude
.add(filepath => {
// 总是转译 vue 文件中的 js 文件
if (/\.vue\.jsx?$/.test(filepath)) {
return false
}
// 排除了 cli-service 中的动态入口
if (filepath.startsWith(cliServicePath)) {
return true
}
// 仅仅当 @vue/babel-preset-app 预设使用时,引入 @babel/runtime
if (
process.env.VUE_CLI_TRANSPILE_BABEL_RUNTIME &&
filepath.includes(path.join('@babel', 'runtime'))
) {
return false
}
// check if this is something the user explicitly wants to transpile
// 查看用户配置的需要转译的文件不能排除
if (transpileDepRegex && transpileDepRegex.test(filepath)) {
return false
}
// 不转译 node_modules 下的文件
return /node_modules/.test(filepath)
})
.end()
.use('cache-loader')
.loader(require.resolve('cache-loader'))
// api.genCacheConfig 这个方法我们看下
.options(api.genCacheConfig('babel-loader', {
'@babel/core': require('@babel/core/package.json').version,
'@vue/babel-preset-app': require('@vue/babel-preset-app/package.json').version,
'babel-loader': require('babel-loader/package.json').version,
modern: !!process.env.VUE_CLI_MODERN_BUILD,
browserslist: api.service.pkg.browserslist
}, [
'babel.config.js',
'.browserslistrc'
]))
.end()
// 如果使用并行处理,则使用 thread-loader
if (useThreads) {
const threadLoaderConfig = jsRule
.use('thread-loader')
.loader(require.resolve('thread-loader'))
if (typeof options.parallel === 'number') {
threadLoaderConfig.options({ workers: options.parallel })
}
}
// 重点,应用babel-loader
jsRule
.use('babel-loader')
.loader(require.resolve('babel-loader'))
})
}
@vue/cli-service/lib/PluginAPI.js,我们看下 genCacheConfig 方法:
/**
* 通过大量的变量生成一个缓存标志
*/
// 根据前面的调用 id = babel-laoder
// partialIdentifier = { '@babel/core': 'x.x.x', '@vue/babel-preset-app': 'x.x.x', 'babel-loader': 'x.x.x' }
// configFiles = ['babel.config.js', '.browserslistrc']
genCacheConfig (id, partialIdentifier, configFiles = []) {
const fs = require('fs')
// 这里可以看到 缓存目录是 项目的 node_modules/.cache/
const cacheDirectory = this.resolve(`node_modules/.cache/${id}`)
// 这是所有版本相关的变量集合
const variables = {
partialIdentifier,
'cli-service': require('../package.json').version,
'cache-loader': require('cache-loader/package.json').version,
env: process.env.NODE_ENV,
test: !!process.env.VUE_CLI_TEST,
config: [
fmtFunc(this.service.projectOptions.chainWebpack),
fmtFunc(this.service.projectOptions.configureWebpack)
]
}
// 所有的配置文件
if (!Array.isArray(configFiles)) {
configFiles = [configFiles]
}
configFiles = configFiles.concat([
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml'
])
// 将配置文件也添加到 variables 变量上,保证唯一
variables.configFiles = configFiles.map(file => {
const content = readConfig(file)
return content && content.replace(/\r\n?/g, '\n')
})
// 这里使用了 hash-sum 哈希生成器来生成唯一标志
const cacheIdentifier = hash(variables)
// 返回的对象,则是 cache-loader 需要的配置
return { cacheDirectory, cacheIdentifier }
Generator
module.exports = api => {
// 你很可能希望覆盖整个配置以确保他没有冲突的正常工作,例如,对于一个使用了 Jest 但是没有使用 Babel 的项目。
// 它对于使用自己的特殊 babel 配置而没有使用 Babel 插件已有的配置很少见。
delete api.generator.files['babel.config.js']
// 这里修改 package.json 文件中的 babel 配置项;增加了 core.js@3 的依赖。
api.extendPackage({
babel: {
// 我们看到 presets 来自 @vue/cli-plugin-babel/preset
presets: ['@vue/cli-plugin-babel/preset']
},
dependencies: {
'core-js': '^3.6.4'
}
})
}
这里我们就再看下 @vue/cli-plugin-babel/preset:
module.exports = require('@vue/babel-preset-app')
这里就一句引用,内容来自 @vue/babel-preset-app:
module.exports = (context, options = {}) => {
...
return {
sourceType: 'unambiguous',
overrides: [{
exclude: [/@babel[\/|\\\\]runtime/, /core-js/],
presets,
plugins
}, {
// there are some untranspiled code in @babel/runtime
// https://github.com/babel/babel/issues/9903
include: [/@babel[\/|\\\\]runtime/],
presets: [
[require('@babel/preset-env'), {
useBuiltIns,
corejs: useBuiltIns ? 3 : false
}]
]
}]
}
}
@vue/babel-preset-app 经过处理之后导出的 presets 最终赋值给了 package.json 文件中 babel.presets 选项,至于其中的细节,我们将其放在 Babel 的后续分析中。
这里有一点需要注意的地方,虽然我们这里看到 babel.presets 的配置应该在 package.json 文件中,那么为啥有的项目并不是这样呢,这里要看下这个 prompt:
{
name: 'useConfigFiles',
when: isManualMode,
type: 'list',
// Babel, ESLint 等等这些配置文件放在哪里?
message: 'Where do you prefer placing config for Babel, ESLint, etc.?',
choices: [
{
// 放在专用的配置文件中
name: 'In dedicated config files',
value: 'files'
},
{
// 放在 package.json 中
name: 'In package.json',
value: 'pkg'
}
]
}
这个 prompt 的结果决定了配置文件放的位置,如果你这里的 useConfigFiles 选择 In dedicated config files,那么 再看这里,在 Creator 的 create 方法中:
await generator.generate({
extractConfigFiles: preset.useConfigFiles
})
这里将用户的选择传入了 generator.generate 方法:
...
// extract configs from package.json into dedicated files.
this.extractConfigFiles(extractConfigFiles, checkExisting)
这里我们看下 extractConfigFiles 方法的主要逻辑:
extractConfigFiles (extractAll, checkExisting) {
...
// 这里定义了一个提取方法
const extract = key => {
if (
configTransforms[key] &&
this.pkg[key] &&
// do not extract if the field exists in original package.json
// 如果 字段 存在于原始的 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]
}
}
if (extractAll) {
// 这里会循环 package.json 下的每一个字段,看是否需要提取
for (const key in this.pkg) {
extract(key)
}
}
}
migrator
这个工具主要是为了更加方便的升级,在前面讲 vue upgrade 命令时我们已经提到了他是如何被调用的。
我们来看下 @vue/cli-plugin-babel 的 migrator 的内部逻辑:
module.exports = api => {
// 这句话是个深坑,来,我们往里跳!
api.transformScript(
'babel.config.js',
require('../codemods/usePluginPreset')
)
// 这里判断若是从 3.x 的版本升级,则增加 `core.js` 的依赖项
if (api.fromVersion('^3')) {
api.extendPackage(
{
dependencies: {
'core-js': '^3.6.4'
}
},
{ warnIncompatibleVersions: false }
)
// TODO: implement a codemod to migrate polyfills
// 这里是作者留下的待实现的内容,可以看到是计划在增加 migrator 增加自动化迁移
api.exitLog(`core-js has been upgraded from v2 to v3.
If you have any custom polyfills defined in ${chalk.yellow('babel.config.js')}, please be aware their names may have been changed.
For more complete changelog, see https://github.com/zloirock/core-js/blob/master/CHANGELOG.md#300---20190319`)
}
}
api.transformScript 这个方法是 MigratorAPI 继承自 GeneratorAPI,这里我们看下逻辑:
/**
* 针对 script 脚本 或者 .vue 文件中 script 部分执行 codemod
* @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) {
this._injectFileMiddleware(files => {
// 这里调用了 runCodemod 方法
files[file] = runCodemod(
codemod,
{ path: this.resolve(file), source: files[file] },
options
)
})
}
顺藤摸瓜我们再看下 runCodemod 方法:
// 这里因引入了两个重要插件
const adapt = require('vue-jscodeshift-adapter')
let jscodeshift = require('jscodeshift')
module.exports = function runCodemod (transformModule, fileInfo, options = {}) {
...
if (parser) {
jscodeshift = jscodeshift.withParser(parser)
}
return adapt(transform)(fileInfo, api, options)
}
这里是借助了 jscodeshift 来处理js文件内容。
我们看下 require('../codemods/usePluginPreset') 这里是如何处理js文件的:
// 这里都是在进行内容的替换
root
.find(j.Literal, { value: '@vue/app' })
.replaceWith(j.stringLiteral('@vue/cli-plugin-babel/preset'))
root
.find(j.Literal, { value: '@vue/babel-preset-app' })
.replaceWith(j.stringLiteral('@vue/cli-plugin-babel/preset'))
可以看到主要是替换某些关键词,这里的具体的语法我们暂时不深究,只需知道他是做了内容替换即可。
至此,@vue/cli-plugin-babel的解析也就结束了。
@vue/cli-plugin-typescirpt
对于这个插件的基本内容,可以看下我翻译的 README ,相信这样你应该对这个插件有个基本的了解了。
Service
Service 的内容大部分和 @vue/cli-plugin-babel 的内容重复,不赘述,几个不一样的地方:
// 这里判断如果不是多页应用,则进行以下处理
if (!projectOptions.pages) {
config.entry('app')
.clear()
.add('./src/main.ts')
}
这里的 projectOptions 是项目默认配置 + vue.config.js 两部分内容合并起来的。
执行时机,不知道你会不会有这样的疑问,那么这个 Service 插件到底是什么时候执行的呢。其实我们前面也提到过,比如我们调试项目时,会执行 yarn serve 那么其实是执行了 vue-cli-service serve,所以在这个时机,如果我们安装 @vue/cli-plugin-typescript 插件,那么它的 Service 部分则会执行。
还有个之前没有碰到的写法是,这里注册了新的命令:
// 判断如果没有安装 eslint 插件
if (!api.hasPlugin('eslint')) {
// 注册 lint 命令
api.registerCommand('lint', {
description: 'lint source files with TSLint',
usage: 'vue-cli-service lint [options] [...files]',
options: {
'--format [formatter]': 'specify formatter (default: codeFrame)',
'--no-fix': 'do not fix errors',
'--formatters-dir [dir]': 'formatter directory',
'--rules-dir [dir]': 'rules directory'
}
}, args => {
return require('./lib/tslint')(args, api)
})
}
我们看到 lint 命令的处理逻辑在 ./lib/tslint:
// 可以看到这里用到了 tslint
const tslint = require('tslint')
const ts = require('typescript')
...
// 这里其实有新的发现,vue-template-compiler原来能够解析 vue 文件的部分内容
const vueCompiler = require('vue-template-compiler')
const { script } = vueCompiler.parseComponent(content, { pad: 'line' })
vue-template-compiler 这个就是 Vue 中的代码了。
Prompts
这里我们先看下 Prompts,为啥呢,因为 Generator 中的配置信息部分就来自 Prompts:
// these prompts are used if the plugin is late-installed into an existing
// project and invoked by `vue invoke`.
// 如果这个插件使用 `vue invoke`命令,后安装到一个已经存在的项目中时,这些对话才会被使用。
const prompts = module.exports = [
{
// 是否使用 classComponent
name: `classComponent`,
type: `confirm`,
message: `Use class-style component syntax?`,
default: true
},
{
name: `useTsWithBabel`,
type: `confirm`,
message: 'Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)?'
},
{
name: `lint`,
type: `confirm`,
message: `Use TSLint?`
},
{
name: `lintOn`,
type: `checkbox`,
when: answers => answers.lint,
message: `Pick lint features:`,
choices: [
{
name: 'Lint on save',
value: 'save',
checked: true
},
{
name: 'Lint and fix on commit' + (hasGit() ? '' : chalk.red(' (requires Git)')),
value: 'commit'
}
]
},
{
// 将 js 转换为 ts
name: `convertJsToTs`,
type: `confirm`,
message: `Convert all .js files to .ts?`,
default: true
},
{
name: `allowJs`,
type: `confirm`,
message: `Allow .js files to be compiled?`,
default: false
}
]
默认的 typescript 对话在这里
Generator
了解了 Prompts 后,再看 Generator 就会清晰一些,基本的内容和前面一致,不赘述,看下新的东西:
// 这里将插件自身的 `package.json` 文件引入了
const pluginDevDeps = require('../package.json').devDependencies
module.exports = (api, {
classComponent,
tsLint,
lintOn = [],
convertJsToTs,
allowJs
}, _, invoking) => {
...
api.extendPackage({
devDependencies: {
// 这里使用插件的 typescript 版本来作为安装依赖的版本
// 达到了维护一致性的目的
// 不用同时分别维护插件 `package.json` 的版本和此处的版本
typescript: pluginDevDeps.typescript
}
})
...
// 在创建完成后 执行 lint 修复文件
api.onCreateComplete(() => {
return require('../lib/tslint')({}, api, true)
})
...
// 这里执行转换
require('./convert')(api, { tsLint, convertJsToTs })
}
这里看下 onCreateComplete 方法:
/**
* push 一个当文件被写入磁盘之后才会调用的回调函数。
*
* @param {function} cb
*/
onCreateComplete (cb) {
this.afterInvoke(cb)
}
afterInvoke (cb) {
this.generator.afterInvokeCbs.push(cb)
}
所以 onCreateComplete 其实是 push 了一个回调函数,待之后执行。
然后再看下 require('./convert') 这里的 convert 函数:
api.postProcessFiles(files => {
if (convertJsToTs) {
// 删除所有有同名 ts 文件的js文件,然后简单的将其他 js 文件重命名为 ts 文件
...
} else {
// rename only main file to main.ts
// 仅仅重命名 main.js 为 main.ts
const tsFile = api.entryFile.replace(jsRE, '.ts')
let content = files[api.entryFile]
if (tsLint) {
// 这个函数的逻辑在下面解析
content = convertLintFlags(content)
}
files[tsFile] = content
delete files[api.entryFile]
}
})
然后了解下 api.postProcessFiles 方法:
/**
* push 一个文件中间件 -- 它将在所有普通文件中间件执行后再执行
*
* @param {FileMiddleware} cb
*/
postProcessFiles (cb) {
this.generator.postProcessFilesCbs.push(cb)
}
convertLintFlags 这个方法我们也看下:
module.exports = function convertLintFlags (file) {
return file
// 主要是将 eslint 的规则,转成 tslint 的规则
.replace(/\/\*\s?eslint-(enable|disable)([^*]+)?\*\//g, (_, $1, $2) => {
if ($2) $2 = $2.trim()
return `/* tslint:${$1}${$2 ? `:${$2}` : ``} */`
})
.replace(/\/\/\s?eslint-disable-(next-)?line(.+)?/g, (_, $1, $2) => {
if ($2) $2 = $2.trim()
return `// tslint:disable-${$1 || ''}line${$2 ? `:${$2}` : ``}`
})
}
同时 Generator 中也有模板,模板的写法大量使用了 yaml-front-matter 从字符串或者文件中解析 YAML 来进行继承和替换。
Migrator
Migrator 中的逻辑很简单:
module.exports = api => {
// 这个升级的迁移方式,暂时来看升级一下 typescirpt 依赖的版本就可以了。
api.extendPackage(
{
devDependencies: {
typescript: require('../package.json').devDependencies.typescript
}
},
{ warnIncompatibleVersions: false }
)
}
@vue/cli-plugin-eslint
对于这个插件的基本内容,可以看下我翻译的 README ,相信这样你应该对这个插件有个基本的了解了。
Service
Service 服务中主要是增加 webpack 的配置和 注册了 lint 命令:
module.exports = (api, options) => {
if (options.lintOnSave) {
const extensions = require('./eslintOptions').extensions(api)
// 这里使用 loadModule 方法,允许用户自定义 ESLint 依赖版本。
const { resolveModule, loadModule } = require('@vue/cli-shared-utils')
const cwd = api.getCwd()
const eslintPkg =
// 这里在下文分析下
loadModule('eslint/package.json', cwd, true) ||
loadModule('eslint/package.json', __dirname, true)
// eslint-loader 在 eslint 配置改变时不会破会缓存,所以我们需要手动地生成一个缓存标志将配置考虑在内。
const { cacheIdentifier } = api.genCacheConfig(
'eslint-loader',
{
'eslint-loader': require('eslint-loader/package.json').version,
eslint: eslintPkg.version
},
[
'.eslintrc.js',
'.eslintrc.yaml',
'.eslintrc.yml',
'.eslintrc.json',
'.eslintrc',
'package.json'
]
)
...
// 接下来是 webpack 的配置部分,暂时省略。
}
// 这里注册了新的命令 `vue-cli-service lint`
api.registerCommand(
'lint',
{
description: 'lint and fix source files',
usage: 'vue-cli-service lint [options] [...files]',
options: {
'--format [formatter]': 'specify formatter (default: codeframe)',
'--no-fix': 'do not fix errors or warnings',
'--no-fix-warnings': 'fix errors, but do not fix warnings',
'--max-errors [limit]':
'specify number of errors to make build failed (default: 0)',
'--max-warnings [limit]':
'specify number of warnings to make build failed (default: Infinity)'
},
details:
'For more options, see https://eslint.org/docs/user-guide/command-line-interface#options'
},
args => {
require('./lint')(args, api)
}
)
这里看下如果实现了 eslint 自定义和预置同时存在的:
const cwd = api.getCwd()
const eslintPkg =
loadModule('eslint/package.json', cwd, true) ||
loadModule('eslint/package.json', __dirname, true)
再看下 api.getCwd() 这个方法:
/**
* 当前工作目录(其实也就是项目目录,因为 Service 脚本执行是在项目根目录)
*/
getCwd () {
return this.service.context
}
loadModule 的区别主要是第二个参数不同,这里有个关于 __dirname 和 process.cwd() 区别的文档可以看下,所以这里会先在 项目目录下找用户定义的 ESLint 如果没有找到的情况下,再在 "源代码所在目录" -- 也就是 @vue/cli-plugin-eslint 这个插件中的 eslint。
const { cacheIdentifier } = api.genCacheConfig(
...
缓存标志的生成和我们在 @vue/cli-plugin-babel 的 Service 部分生成标志用的同一个方法。
关于命令注册,它是引用了 lint.js,
...
require('./lint')(args, api)
再看先 lint.js 中部分内容:
// 这里可以看到,还是兼容前面提到用户自定义 eslint 的原则
const { CLIEngine } = loadModule('eslint', cwd, true) || require('eslint')
...
// 这里进行了 eslint 的初始化
const engine = new CLIEngine(config)
...
// 这里应该是进行了 lint 操作
const report = engine.executeOnFiles(files)
...
// 控制自动修复的逻辑
if (config.fix) {
CLIEngine.outputFixes(report)
}
到此,主要的 Service 逻辑我们就过了一遍。
Pormpts
module.exports = [
{
name: 'config',
type: 'list',
message: `Pick an ESLint config:`,
choices: [
{
name: 'Error prevention only',
value: 'base',
short: 'Basic'
},
...
},
{
name: 'lintOn',
type: 'checkbox',
message: 'Pick additional lint features:',
choices: [
{
name: 'Lint on save',
value: 'save',
checked: true
},
{
name: 'Lint and fix on commit' + (hasGit() ? '' : chalk.red(' (requires Git)')),
value: 'commit'
}
]
}
对话一共两个,第一个是选择一种 ESLint 配置,第二个是选择 lint 的执行时机,默认是在文件保存的时候执行 lint。
Generator
直接看代码:
// 这里的 config、lintOn 参数,其实就是 对话中的两个问题
module.exports = (api, { config, lintOn = [] }, _, invoking) => {
// 这里载入了默认的 eslint 配置
const eslintConfig = require('../eslintOptions').config(api, config)
// 这里的依赖是根据 config 选项决定的,不同的 eslint 规则对应不同的依赖
const devDependencies = require('../eslintDeps').getDeps(api, config)
const pkg = {
scripts: {
lint: 'vue-cli-service lint'
},
eslintConfig,
devDependencies
}
...
// lint & fix after create to ensure files adhere to chosen config
// for older versions that do not support the `hooks` feature
// lint 和 修复了 vue-cli 老版本不支持 `hooks` 功能
try {
// 这里的这个写法,对于我们自己做版本处理相关,也很有用
api.assertCliVersion('^4.0.0-beta.0')
} catch (e) {
if (config && config !== 'base') {
api.onCreateComplete(() => {
require('../lint')({ silent: true }, api)
})
}
}
Migrator
这里来看下 eslint 插件升级的逻辑:
module.exports = async (api) => {
// 首先获取项目的 package.json 文件
const pkg = require(api.resolve('package.json'))
// 取得本地 eslint 版本
let localESLintRange = pkg.devDependencies.eslint
// 如果项目是通过 Vue CLI 3.0 或者更早版本构建的,ESLint 依赖(ESLint v4)将在 @vue/cli-plugin-eslint 插件内部;
// 在 Vue CLI v4 中他应该被提取到项目依赖中
// 这里判断如果项目当前 Vue CLI 是3.x版本,并且项目没有单独安装 ESLint 时
if (api.fromVersion('^3') && !localESLintRange) {
localESLintRange = '^4.19.1'
// 这里 针对你从 增加了相应的依赖
api.extendPackage({
devDependencies: {
eslint: localESLintRange,
'babel-eslint': '^8.2.5',
'eslint-plugin-vue': '^4.5.0'
}
})
}
// 这里获得 eslint 的主版本号
const localESLintMajor = semver.major(
semver.maxSatisfying(
['4.99.0', '5.99.0', '6.99.0'],
localESLintRange
)
)
// 如果 主版本 已经是 6,说明是最新的,则直接返回
if (localESLintMajor === 6) {
return
}
// 如果 主版本 不是 6,则进行对话
const { confirmUpgrade } = await inquirer.prompt([{
name: 'confirmUpgrade',
type: 'confirm',
message:
`Your current ESLint version is v${localESLintMajor}.\n` +
`The lastest major version is v6.\n` +
`Do you want to upgrade? (May contain breaking changes)\n`
}])
// 如果用户的答案是 true
if (confirmUpgrade) {
const { getDeps } = require('../eslintDeps')
const newDeps = getDeps(api)
// 这里根据用户已经选择 eslint 规则来设置
if (pkg.devDependencies['@vue/eslint-config-airbnb']) {
Object.assign(newDeps, getDeps(api, 'airbnb'))
}
if (pkg.devDependencies['@vue/eslint-config-standard']) {
Object.assign(newDeps, getDeps(api, 'standard'))
}
if (pkg.devDependencies['@vue/eslint-config-prettier']) {
Object.assign(newDeps, getDeps(api, 'prettier'))
}
api.extendPackage({ devDependencies: newDeps }, { warnIncompatibleVersions: false })
...
}
}
感谢阅读
感谢你阅读到这里,翻译的不好的地方,还请指点。希望我的内容能让你受用,再次感谢。by llccing 千里