vue-cli
是 vuejs 官方提供的基于 vuejs 的项目脚手架工具, 可以很快的帮助 vuejs 开发者搭建一个 startup 项目, 免去环境配置的繁琐, 开箱即用. 今天就来看下 vue-cli
的实现.
vue-cli 的版本是 2.8.2
vue init
是基于第三方模板生成项目的命令. 先看下其整体流程:
首先, vue cli
获取到输入的参数:
# vue-cli/bin/vue-init
// ...
var template = program.args[0]
var hasSlash = template.indexOf('/') > -1
var rawName = program.args[1]
// ...
之后, 会先判断用户是否输入了 offline
选项. 如果有, 则会使用之前缓存的模板:
# vue-cli/bin/vue-init
// ...
var tmp = path.join(home, '.vue-templates', template.replace(/\//g, '-'))
if (program.offline) {
console.log(`> Use cached template at ${chalk.yellow(tildify(tmp))}`)
template = tmp
}
// ...
如果没有, 则判断将会生成的项目目录是否存在. 若存在, 则会向用户确认是否在当前目录生成项目(代码在这); 若不存在, 之后就会生成一个新的目录.
然后, 会去判断使用的模板是否是本地的, 是本地且存在则使用本地模板生成项目, 反之使用线上模板生成项目(代码在这).
在判断是使用线上的模板之后, 会根据模板名是否带 /
判断是使用官方提供的模板还是使用第三方模板(代码在这).
最后会调用 downloadAndGenerate
去下载官方模板或第三方模板来生成项目(代码在这). vue cli
对模板的下载依赖于 download-git-repo, 所以使用第三方模板时, 对指定模板的输入要求可以见 download
.
模板下载成功之后, vue cli
会调用 generate
来生成模板, 这是 cli 的核心模块, 其源码在 lib/generate.js
中. 接下来就具体分析 generate
模块.
generate
模块导出之前, 会先在 handlebars
中注册两个辅助函数: if_eq
和 unless_eq
, 用于模板中的表达式判断:
# vue-cli/lib/generate.js
//...
// 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)
})
导出的 generate
函数接收四个参数: 项目目录名、下载的模板的临时路径、项目目录路径和一个回调函数. 回调函数用于项目生成之后在终端输出一些提示信息. 在 generate
函数内, 首先会读取模板的 meta
信息, 读取的 meta
信息来自于模板目录下的 meta.{js,json}
文件:
# vue-cli/lib/options.js
// ...
// dir 是模板下载成功之后的临时路径
var json = path.join(dir, 'meta.json')
var js = path.join(dir, 'meta.js')
var opts = {}
// ...
具体实现戳此. 之后会读取用户的 git 昵称和邮箱用于设置 meta
信息的一些默认属性.
得到基本的 meta
信息之后, 会利用 metalsmith 读取 template
内容:
# vue-cli/lib/generate.js
// ...
// src 是模板下载成功之后的临时路径
var opts = getOptions(name, src)
var metalsmith = Metalsmith(path.join(src, 'template'))
// ...
需要注意的是, 读取的内容是模板的 tempalte
目录. metalsmith 会返回文件路径和文件内容相映射的对象, 这样会方便 metalsmith 的中间件对文件进行处理.
之后, vue cli
使用了三个中间件来处理模板:
//vue-cli/lib/generate.js#L53-L55
metalsmith.use(askQuestions(opts.prompts))
.use(filterFiles(opts.filters))
.use(renderTemplateFiles(opts.skipInterpolation))
中间件 askQuestions
用于读取用户输入:
function askQuestions (prompts) {
return function (files, metalsmith, done) {
ask(prompts, metalsmith.metadata(), done)
}
}
ask
的源码在 vue-cli/lib/ask.js
中, 其会遍历 prompts
, 在终端交互式的读取用户输入, 并将数据保存在 global metadata
中, 便于后续依赖 global metadata
的中间件对模板进行进一步处理. prompts
是一个对象, 每个 prompt
都是一个 Inquirer.js question object. 示例如下:
// meta.{js,json}
{
"prompts": {
"name": {
"type": "string",
"required": true,
"message" : "Project name"
},
"version": {
"type": "input",
"message": "project's version",
"default": "1.0.0"
}
}
}
在 ask
中, 对 meta
信息中的 prompt
会有条件的咨询用户:
// vue-cli/lib/ask.js#prompt
inquirer.prompt([{
type: prompt.type,
message: prompt.message,
default: prompt.default
//...
}], function(answers) {
// 保存用户的输入
})
经过 askQuestions
中间件处理之后, global metadata
是一个以 prompt 中的 key 为 key, 用户的输入为 value 的对象:
// global metadata
{
name: 'test',
version: '0.1.1'
// ...
}
中间件 filterFiles
会根据 meta
信息中的 filters
都文件进行过滤:
function filterFiles (filters) {
return function (files, metalsmith, done) {
filter(files, filters, metalsmith.metadata(), done)
}
}
filter
的源码在 vue-cli/lib/filter.js
中:
module.exports = function (files, filters, data, done) {
// 没有 filters 直接返回
if (!filters) {
return done()
}
// 获取所有的文件名(即路径, eg: test/**)
var fileNames = Object.keys(files)
// 遍历 filters
Object.keys(filters).forEach(function (glob) {
fileNames.forEach(function (file) {
if (match(file, glob, { dot: true })) {
// 获取到匹配的值
var condition = filters[glob]
if (!evaluate(condition, data)) {
// 删除文件
delete files[file]
}
}
})
})
done()
}
evaluate
用于执行 js 表达式, 关键定义如下:
// vue-cli/lib/eval.js
var fn = new Function('data', 'with (data) { return ' + exp + '}')
所以在 filters
中, 可以将某些 key
的 value
定义为一个 js 表达式.
根据用户的输入过滤掉不需要的文件之后, 就可以利用 renderTemplateFiles
中间件来渲染模板了:
// vue-cli/lib/generate.js#renderTemplateFiles
// ...
var render = require('consolidate').handlebars.render
var async = require('async')
// ...
function renderTemplateFiles(//...){
return function (files, metalsmith, done) {
var keys = Object.keys(files)
var metalsmithMetadata = metalsmith.metadata()
// 遍历 keys
async.each(keys, function(file, next){
// 读取文件内容
var str = files[file].contents.toString()
// 不渲染不含mustaches表达式的文件
if (!/{{([^{}]+)}}/g.test(str)) {
return next()
}
// 调用 handlebars 渲染文件
render(/* 渲染文件 */)
})
}
}
渲染完成之后, metalsmith 会将最终结果 build 的 dest
目录. 若失败, 则将 err
传给回调输出; 反之, 如果 meta
信息有 complete
(函数) 或者 completeMessage
(字符串), 则会进行调用或输出:
// vue-cli/lib/generate.js
// ...
var opts = getOptions(name, src)
// ...
if (typeof opts.complete === 'function') {
var helpers = {chalk, logger, files}
opts.complete(data, helpers)
} else {
logMessage(opts.completeMessage, data)
}
// ...
vue list
命令用于查看官方提供的模板列表, 源码在 vue-cli/bin/vue-list
中, 关键代码如下:
// ...
var request = require('request')
//...
request({
url: 'https://api.github.com/users/vuejs-templates/repos',
headers: {
'User-Agent': 'vue-cli'
}
}, function(err, res, body) {
// 在终端输出列表
})
需要注意的是, Github Api 对未认证的请求是有请求数限制的, 超过限制则会报错, 但可以通过 BA 认证的方式来提高请求数限制, 具体可以戳此.
这是个潜在的问题, 已经有 vue-cli
的用户碰到过认证失败的问题: #368. vue-cli
的下一个版本可能会解决这个问题, 已经有社区用户提出 PR.
从上述的分析可以知道, 模板是有特定的目录结构的:
template
目录, 在该目录下定义你的模板文件meta.{js,json}
文件, 该文件必须导出为一个对象, 用于定义模板的 meta
信息对于 meta.{js,json}
文件, 目前可定义的字段如下:
prompts
: 收集用户自定义数据filters
: 根据条件过滤文件completeMessage
: 模板渲染完成后给予的提示信息, 支持 handlebars 的 mustaches 表达式complete
: 模板渲染完成后的回调函数, 优先于 completeMessage
helpers
: 自定义的 Handlebars 辅助函数prompts
是一个对象, 每个 prompt
都是一个 Inquirer.js question object. 示例如下:
// meta.{js,json}
{
"prompts": {
"name": {
"type": "string",
"required": true,
"message" : "Project name"
},
"test": {
"type": "confirm",
"message" : "Unit test?"
},
"version": {
"type": "input",
"message": "project's version",
"default": "1.0.0"
}
}
}
所有的用户输入完成之后, template
目录下的所有文件将会用 Handlebars 进行渲染. 用户输入的数据会作为模板渲染时的使用数据:
// template/package.json
{{#test}}
"test": "npm run test"
{{/test}}
在上述示例中, 只有用户在 test
中的回答值是 yes
时, test
脚本才会在 package.json
文件中生成.
prompt
可以添加一个 when
字段, 该字段表示此 prompt
会根据 when
的值来判断是否出现在终端提示用户进行输入. 在 vue-cli
中, 其会根据 when
进行 eval
运算:
// ...
if (prompt.when && !evaluate(prompt.when, data)) {
return done()
}
//...
带 when
的 prompt
示例:
{
"prompts": {
"lint": {
"type": "confirm",
"message": ""Use ESLint to lint your code?"
},
"eslint": {
"when": "lint",
"type": "list",
"message": "Pick a lint config",
"choices": [
"standard",
"airbnb",
"none"
]
}
}
}
在上述示例中, 只有用户在 lint
中的回答值是 yes
时, eslint
才会被触发, 在终端显示让用户选择 eslint
的配置规范.
filters
字段是一个包含文件过滤规则的对象, 键用于定义符合 minimatch glob pattern 规则的过滤器, 键值是 prompts
中用户的输入值或者表达式. 例如:
{
"prompts": {
"unit": {
"type": "confirm",
"message": "Setup unit tests with Mocha?"
}
},
"filters": {
"test/*": "unit"
}
}
在上述示例中, template
目录下 test
目录只有用户在 unit
中的回答值是 yes
时才会生成, 反之会被删除.
如果要匹配以 .
开头的文件, 则需要将 minimatch 的 dot
选项设置成 true
.
helpers
字段是一个包含自定义的 Handlebars 辅助函数的对象, 自定义的函数可以在 template
中使用:
{
"helpers": {
"if_or": function (v1, v2, options) {
if (v1 || v2) {
return options.fn(this);
}
return options.inverse(this);
}
},
}
在 template
的文件使用该 if_or
:
{{#if_or val1 val2}}
// 当 val1 或者 val2 为 true 时, 这里才会被渲染
{{/if_or}}
在渲染完成后的 complete
回调:
{
"complete": function(data, helpers) {}
}
data
和 helpers
由 vue cli
传入:
// vue-cli/lib/generate.js
// ...
var data = Object.assign(metalsmith.metadata(), {
destDirName: name,
inPlace: dest === process.cwd(),
noEscape: true
})
// ...
// files 是 metalsmith build 之后的文件对象
var helpers = {chalk, logger, files}
// ...
如果 complete
有定义, 则调用 complete
, 反之会输出 completeMessage
.
vue-cli
的源码还是很好分析的, 参考 vue-cli
, 写了一个简化的脚手架工具 chare
, 其新加了三个功能:
自己针对日常使用的 vuejs
和 react
框架写了一些 startup, 欢迎指正: