前言

vue-cli 的 2.9.6 版本看完之后,对 cli 脚手架的整个原理才有了一个比较清晰的认识,其可配置性主要体现在三个方面,即可配置性问答,可配置性文件,可配置性文件内容。文章也将主要整理这三个部分是如何实现的。

流程介绍

  • vue 文件开始,根据输入vue init/list 执行不同文件

    • 选择vue init,进入执行 vue-init

    • 根据isLocalPath()判断是 本地模板 还是 官方模板

      • 如果是当前路径的template,则调用getTemplatePath()确保其是绝对路径,并进入 generate()

      • 如果是官方模板,则需要检查版本(向远程发送请求,获取 cli 当前 latest 的版本和本地安装的版本)

      • 根据选择获取 模板路径,生成路径,项目名,进入 downloadAndGenerate(),然后download()之后进入generate()

    • 进入generate() 首先通过getOptions()获取模板下meta.js/meta.json的配置(其中包含了不同模板的提问),getOptions()里面设置项目名,以及设置包名验证,然后调用getGitUser()获取 git 配置的用户名,然后设置作者

    • 然后使用Metalsmith,先调用meta.jsmetalsmith.before的方法,其目的是合并一些参数,比如合并 isNotTest,然后在 test 测试的时候,跳过问答部分

    • metalsmith可传入插件(函数)产生结果,其实和 gulp 的 pipe 管道很像

      • 先调用 askQuestions() 获取模板中meta.js中的prompt数据,使用inquirer包来控制问答交互,并将用户输入答案存入metalsmith.metadata()全局变量中,以便后面使用

      • 然后调用 filterFiles() 获取模板中meta.js中的filters数据,然后根据上一步用户输入,来删去模板中用户不需要的文件。其实现方式是:遍历模板filters,然后内层遍历所有模板文件,如果符合某条件,则将该文件删去

      • 调用 renderTemplateFiles() 跳过一些文件生成以及根据Handlebars.registerHelper的定义,替换模板中的部分,使其内容可配置。其中实现方法是:使用handlebars,模板渲染的时候将其中的部分替换,首先定义一些条件,然后在模板相应位置写条件(和 ejs 模板渲染引擎比较像),然后根据一些选项来控制具体内容是否需要,最后生成最终文件

  • vue list的实现则和 vue 1.x 版本相同,向远程仓库发起请求,获取对应模板列表,然后输出出来

细节分析

启动

1
2
3
4
5
6
7
8
9
program
.version(require("../package").version)
.usage("<command> [options]")
.command("init", "generate a new project from a template")
.command("list", "list available official templates")
.command("build", "prototype a new project")
.command("create", "(for v3 warning only)")

program.parse(process.argv)

入口文件很简单,使用commondercommand方法,给第二个描述参数,则用户输入命令时执行不同的文件

核心 generate()

npm 包

先看一下引入的 npm 包,注释里简单介绍一下功能

1
2
3
4
5
6
7
8
9
10
11
12
13
// 命令行界面高亮
const chalk = require("chalk")
// 传入方法,生成目标文件
const Metalsmith = require("metalsmith")
// 构建语义模板,可以看到下载的模板里面有 if_eq 等字样
const Handlebars = require("handlebars")
// 异步执行
const async = require("async")
// 模板引擎合并库,直接使用了handlebars
const render = require("consolidate").handlebars.render
const path = require("path")
// 匹配
const multimatch = require("multimatch")

generate()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
module.exports = function generate(name, src, dest, done) {
const opts = getOptions(name, src)
// console.log("opts:", opts)
console.log("src:", src)
const metalsmith = Metalsmith(path.join(src, "template"))
const data = Object.assign(metalsmith.metadata(), {
destDirName: name,
inPlace: dest === process.cwd(),
noEscape: true,
})
// console.log("metalsmith1:", metalsmith)
opts.helpers &&
Object.keys(opts.helpers).map((key) => {
Handlebars.registerHelper(key, opts.helpers[key])
})
const helpers = { chalk, logger }

if (opts.metalsmith && typeof opts.metalsmith.before === "function") {
// 调用meta.js的metalsmith.before的方法,并传入metalsmith,meta.js的配置,以及高亮和打印输出
// 做的工作是合并一个isnotTest,目测应该是做一个不是test的标识【待验证】
opts.metalsmith.before(metalsmith, opts, helpers)
}
// console.log("metalsmith2:", metalsmith)

// 询问问题,
// 将不需要的文件过滤掉
// 跳过一些文件生成以及根据Handlebars.registerHelper的定义,替换模板中的部分,使其内容可配置
metalsmith
.use(askQuestions(opts.prompts))
.use(filterFiles(opts.filters))
.use(renderTemplateFiles(opts.skipInterpolation))

if (typeof opts.metalsmith === "function") {
opts.metalsmith(metalsmith, opts, helpers)
} else if (opts.metalsmith && typeof opts.metalsmith.after === "function") {
opts.metalsmith.after(metalsmith, opts, helpers)
}

metalsmith
.clean(false)
.source(".") // start from template root instead of `./src` which is Metalsmith's default for `source`
.destination(dest)
.build((err, files) => {
done(err)
if (typeof opts.complete === "function") {
const helpers = { chalk, logger, files }
// 执行meta.js中的complete方法
// webpack模板中则是排序依赖包,安装依赖包,输出相应信息
opts.complete(data, helpers)
} else {
logMessage(opts.completeMessage, data)
}
})

return data
}

代码很长,我们分开来讲,细节就不再多说,主要说核心实现。

生成文件前期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const opts = getOptions(name, src)
// console.log("opts:", opts)
console.log("src:", src)
const metalsmith = Metalsmith(path.join(src, "template"))
const data = Object.assign(metalsmith.metadata(), {
destDirName: name,
inPlace: dest === process.cwd(),
noEscape: true,
})
// console.log("metalsmith1:", metalsmith)
opts.helpers &&
Object.keys(opts.helpers).map((key) => {
Handlebars.registerHelper(key, opts.helpers[key])
})
const helpers = { chalk, logger }

if (opts.metalsmith && typeof opts.metalsmith.before === "function") {
// 调用meta.js的metalsmith.before的方法,并传入metalsmith,meta.js的配置,以及高亮和打印输出
// 做的工作是合并一个isnotTest,目测应该是做一个不是test的标识【待验证】
opts.metalsmith.before(metalsmith, opts, helpers)
}

这一部分做的事情主要是读取模板下meta.js文件中的配置信息,其中包含的信息有

  • metalsmith全局变量(比如 isNotTest,应该就是在 test 时候跳过问答部分),通过opts.metalsmith.before(metalsmith, opts, helpers)合并进来

  • helpers即 handlesbar 的渲染模板,通过Handlebars.registerHelper(key, opts.helpers[key])合并进来

  • prompts即 inquire 需要使用的模板配置问题(后面讲)

  • filters即根据回答,讲不需要文件删除的部分(后面讲)

  • complete生成文件时调用(后面讲)

生成文件中期

1
2
3
4
5
6
7
// 询问问题,
// 将不需要的文件过滤掉
// 跳过一些文件生成以及根据Handlebars.registerHelper的定义,替换模板中的部分,使其内容可配置
metalsmith
.use(askQuestions(opts.prompts))
.use(filterFiles(opts.filters))
.use(renderTemplateFiles(opts.skipInterpolation))

这一部分做了三件事,询问问题,过滤文件,模板渲染文件,其实现方式是

  • askQuestions就不详细说了,比较简单

  • filterFiles,通过遍历 filters 规则,然后内层遍历模板下所有文件,如果和 filters 的 value 相匹配,则 delete 掉文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const fileNames = Object.keys(files)
// console.log("fileNames:", fileNames)
Object.keys(filters).forEach((glob) => {
// 遍历filters规则
fileNames.forEach((file) => {
// 遍历生成的所有文件,dot允许匹配.开头的文件
if (match(file, glob, { dot: true })) {
// condition是filters的value
const condition = filters[glob]
if (!evaluate(condition, data)) {
delete files[file]
}
}
})
})
  • renderTemplateFiles,遍历每个文件,异步处理其中的内容,将所有可配置部分根据注册的模板以及回答,来选择文件内容如何生成。可以看下面的 handlebars 介绍
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
return (files, metalsmith, done) => {
const keys = Object.keys(files)
const metalsmithMetadata = metalsmith.metadata()
async.each(
keys,
(file, next) => {
// skipping files with skipInterpolation option
if (
skipInterpolation &&
multimatch([file], skipInterpolation, { dot: true }).length
) {
return next()
}
// str是文件内容
const str = files[file].contents.toString()
console.log("str:", str)
// do not attempt to render files that do not have mustaches
if (!/{{([^{}]+)}}/g.test(str)) {
return next()
}
// 使用handlebars,模板渲染的时候将其中的部分替换
// 比如:如果选择使用router,那么,模板中的main.js文件中就会渲染出import router部分
// {{#router}}
// import router from './router'
// {{/router}}
render(str, metalsmithMetadata, (err, res) => {
if (err) {
err.message = `[${file}] ${err.message}`
return next(err)
}
files[file].contents = new Buffer(res)
next()
})
},
done
)
}

handlebars

1
2
3
4
5
6
7
8
// register handlebars helper
Handlebars.registerHelper("if_eq", function (a, b, opts) {
return a === b ? opts.fn(this) : opts.inverse(this)
})

Handlebars.registerHelper("unless_eq", function (a, b, opts) {
return a === b ? opts.inverse(this) : opts.fn(this)
})

注册模板渲染,作用是实现模板文件内容的可配置。举个例子,如果选择时,选择引入vue-router,那么这个时候,main.js肯定要引入,这个时候就可以根据handlebars注册的模板进行有选择性渲染。

当然上面的注册并没有注册 router 的,因为不同模板不一样,并非每一个模板都需要 router,那么是如何实现可配置性呢?就是根据用户选择,来选择是否需要渲染。

这里贴一下 webpack 的模板就明白了

1
2
3
4
5
6
7
8
9
{{#if_eq build "standalone"}}
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
{{/if_eq}}
import Vue from 'vue'
import App from './App'
{{#router}}
import router from './router'
{{/router}}

生成文件后期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
metalsmith
.clean(false)
.source(".") // start from template root instead of `./src` which is Metalsmith's default for `source`
.destination(dest)
.build((err, files) => {
done(err)
if (typeof opts.complete === "function") {
const helpers = { chalk, logger, files }
// 执行meta.js中的complete方法
// webpack模板中则是排序依赖包,安装依赖包,输出相应信息
opts.complete(data, helpers)
} else {
logMessage(opts.completeMessage, data)
}
})

这一部分就是生成文件的部分了,在经过了前面三个函数的处理之后,此时的文件已经基本成型,内容也已经是配置后的了,这一部分还会调用一下meta.js中的complete部分,比如这里的complete就是先给依赖排序,然后执行npm install

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
complete: function(data, { chalk }) {
const green = chalk.green

sortDependencies(data, green)

const cwd = path.join(process.cwd(), data.inPlace ? '' : data.destDirName)

if (data.autoInstall) {
installDependencies(cwd, data.autoInstall, green)
.then(() => {
return runLintFix(cwd, data, green)
})
.then(() => {
printMessage(data, green)
})
.catch(e => {
console.log(chalk.red('Error:'), e)
})
} else {
printMessage(data, chalk)
}
}

总结

vue cli 2.9.6 是 2.x 的最后一个版本,其核心内容就是 generater()的部分,其包含了三大核心内容,可配置性问答,可配置性文件,可配置性文件内容。正是由于此,才另 vue cli 2.x 和 1.x 相比,更加的灵活。

但是虽然如此,2.x 和 1.x 还是都没有逃出其核心原理是直接下载远程模板,其配置性也都是在模板的基础上进行的。还是不够灵活,那 vue 3.x 则使用插件式,令模板更加灵活(当然这里 vue cli 3.x 还没有看完,说的不一定对,看完之后会回来修改的)