vue-cli2源码解析
node本身虽然是一门后端语言,但和前端的联系还是相当紧密的,基本上渗透到了日常使用中,玩法也是多种多样。工程化,中台服务,命令行工具等等。这篇文章就从vue-cli入手,学习一下node在命令行工具开发中的应用以及相关生态。虽然vue-cli3已经发布很久了,但考虑到其插件服务的架构是重点,相对来说vue-cli2的node命令行开发更纯粹一些,也比较全面的涵盖日常cli开发的工具和思路,所以就从版本2开始吧。
核心依赖工具概览
在进入具体代码之前,先看看package.json
里的依赖的一些核心包,这些包都是命令行工具开发常见甚至必备包,有必要先做了解。
commander
node命令行接口的完整解决方案。通过解析用户命令行输入内容,完成命令行内容展示和node相关逻辑的桥接。
chalk
修改命令行字符串样式,让输入更加美观
inquirer
给命令行提供更丰富的交互手段,比如列表选择,Y/N
选择
download-git-repo
编程式的形式从远程仓库下载代码,这里主要用来下载初始化需要的模板
ora
命令行loading效果,和chalk类似都是为了美观
execa
强化版的子进程管理,在node
内置的child_process
基础上更好的支持多平台,以及提供Promise
接口等强化功能
handlebars
一个JS语义模板库,将模板编译成html
consolidate
模板引擎的整合库,把一些流行的模板引擎包括上面的handlebars
适配成和express
兼容的接口
metalsmith
可以批量处理模板的静态网页生成器,vue-cli中最终产出的落地就靠这个
源码分析
因为vue-cli核心是通过vue-init
这条命令启动的,所以直接看bin/vue-init
文件内容。首先文件开头是#!/usr/bin/env node
,说明该脚本的解释程序为node
,并提供路径让系统动态查找到node
执行该脚本文件,之后引入了一系列三方库文件就不赘述,再到了commander
代码部分。
program
.usage('<template-name> [project-name]')
.option('-c, --clone', 'use git clone')
.option('--offline', 'use cached template')
/**
* Help.
*/
program.on('--help', () => {
console.log(' Examples:')
console.log()
console.log(chalk.gray(' # create a new project with an official template'))
console.log(' $ vue init webpack my-project')
console.log()
console.log(chalk.gray(' # create a new project straight from a github template'))
console.log(' $ vue init username/repo my-project')
console.log()
})
规定了入参,以及help
相关输出,并将用户输入解析为对象。接下来就是关键逻辑。
const rawName = program.args[1]
const inPlace = !rawName || rawName === '.'
if (inPlace || exists(to)) {
inquirer.prompt([{
type: 'confirm',
message: inPlace
? 'Generate project in current directory?'
: 'Target directory exists. Continue?',
name: 'ok'
}]).then(answers => {
if (answers.ok) {
run()
}
}).catch(logger.fatal)
} else {
run()
}
这里根据用户输入name判断是否与当前目录重名或者目录是否已存在,并进一步询问,根据用户机交互决定是否继续向下执行run
函数。
/**
* Check, download and generate the project.
*/
function run () {
// check if template is local
if (isLocalPath(template)) {
const templatePath = getTemplatePath(template)
if (exists(templatePath)) {
generate(name, templatePath, to, err => {
if (err) logger.fatal(err)
console.log()
logger.success('Generated "%s".', name)
})
} else {
logger.fatal('Local template "%s" not found.', template)
}
} else {
checkVersion(() => {
if (!hasSlash) {
// use official templates
const officialTemplate = 'vuejs-templates/' + template
if (template.indexOf('#') !== -1) {
downloadAndGenerate(officialTemplate)
} else {
if (template.indexOf('-2.0') !== -1) {
warnings.v2SuffixTemplatesDeprecated(template, inPlace ? '' : name)
return
}
// warnings.v2BranchIsNowDefault(template, inPlace ? '' : name)
downloadAndGenerate(officialTemplate)
}
} else {
downloadAndGenerate(template)
}
})
}
}
run
函数根据模板路径决定是下载官方项目模板还是下载自定义模板,如果下载官方模板会走downloadAndGenerate
函数,下载自定义模板则跳过download
步骤,直接走generate
函数
function downloadAndGenerate (template) {
const spinner = ora('downloading template')
spinner.start()
// Remove if local template exists
if (exists(tmp)) rm(tmp)
download(template, tmp, { clone }, err => {
spinner.stop()
if (err) logger.fatal('Failed to download repo ' + template + ': ' + err.message.trim())
generate(name, tmp, to, err => {
if (err) logger.fatal(err)
console.log()
logger.success('Generated "%s".', name)
})
})
donwload
函数封装了download-git-repo
从github
拉取对应模板的逻辑,在下载时会使用ora
做样式的loading优化,之后就是最核心的generate
函数填充模板的部分了。先看整体代码。
module.exports = function generate (name, src, dest, done) {
const opts = getOptions(name, src)
const metalsmith = Metalsmith(path.join(src, 'template'))
const data = Object.assign(metalsmith.metadata(), {
destDirName: name,
inPlace: dest === process.cwd(),
noEscape: true
})
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') {
opts.metalsmith.before(metalsmith, opts, helpers)
}
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 }
opts.complete(data, helpers)
} else {
logMessage(opts.completeMessage, data)
}
})
return data
}
首先通过getOptions
获取模板填充相关配置,进入到该函数看细节。
module.exports = function options (name, dir) {
const opts = getMetadata(dir)
setDefault(opts, 'name', name)
setValidateName(opts)
const author = getGitUser()
if (author) {
setDefault(opts, 'author', author)
}
return opts
}
其中getMetadata
和setDefault
的作用就是获取该模板的一些元配置信息,比如prompts
,helpers
,并将其填充到inquirer
中,以在填充时和用户交互,然后再使用setValidateName
这三方包工具验证app
的名称是否符合npm
命名规范,最后就是填充作者信息了。
下一步调用Metalsmith
准备模板输出的落地,首先通过metadata
方法定义一些在模板中可以使用的一些全局变量,再通过Handlebars.registerHelper
注册一些模板渲染时,可以使用的helper
函数,再通过 before
和after
将这些元数据合并到模板渲染前后会调用的钩子函数中。
配置准备好后,接下来的就是渲染重点了。因为metalsmith
是插件化的用法,通过use
启用需要的插件,这里一共用到了三个插件:askQuestions
,filterFiles
,renderTemplateFiles
。
先看askQuesions
实现。
module.exports = function ask (prompts, data, done) {
async.eachSeries(Object.keys(prompts), (key, next) => {
prompt(data, key, prompts[key], next)
}, done)
}
function prompt (data, key, prompt, done) {
// skip prompts whose when condition is not met
if (prompt.when && !evaluate(prompt.when, data)) {
return done()
}
let promptDefault = prompt.default
if (typeof prompt.default === 'function') {
promptDefault = function () {
return prompt.default.bind(this)(data)
}
}
inquirer.prompt([{
type: promptMapping[prompt.type] || prompt.type,
name: key,
message: prompt.message || prompt.label || key,
default: promptDefault,
choices: prompt.choices || [],
validate: prompt.validate || (() => true)
}]).then(answers => {
if (Array.isArray(answers[key])) {
data[key] = {}
answers[key].forEach(multiChoiceAnswer => {
data[key][multiChoiceAnswer] = true
})
} else if (typeof answers[key] === 'string') {
data[key] = answers[key].replace(/"/g, '\\"')
} else {
data[key] = answers[key]
}
done()
}).catch(done)
}
这里将配置中的prompts
传入,通过命令行交互生成用户需要的data
并将其通过metalsmith.metadata()
存到全局,在渲染过程中就可以直接使用这些值了。
接下来是filterFiles
插件。
module.exports = (files, filters, data, done) => {
if (!filters) {
return done()
}
const fileNames = Object.keys(files)
Object.keys(filters).forEach(glob => {
fileNames.forEach(file => {
if (match(file, glob, { dot: true })) {
const condition = filters[glob]
if (!evaluate(condition, data)) {
delete files[file]
}
}
})
})
done()
}
类似的,同样是根据配置中的filter
结合evaluate
函数从metadata
的全局数据中判断需要被过滤的模板生成文件,比如eslint
这种根据用户交互决定是否生成的文件,如果需要被过滤则用delete
操作符删除。
最后,模板填充相关的data
也有了,需要被渲染的模板也决定了,就可以调用renderTemplateFiles
生成具体的落地文件了。
function renderTemplateFiles (skipInterpolation) {
skipInterpolation = typeof skipInterpolation === 'string'
? [skipInterpolation]
: skipInterpolation
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()
}
const str = files[file].contents.toString()
// do not attempt to render files that do not have mustaches
if (!/{{([^{}]+)}}/g.test(str)) {
return next()
}
render(str, metalsmithMetadata, (err, res) => {
if (err) {
err.message = `[${file}] ${err.message}`
return next(err)
}
files[file].contents = new Buffer(res)
next()
})
}, done)
}
}
渲染手段就是利用consolidate.handlebars.render
。还剩下最后一步就是将这些落地文件输出到具体目录,然后输出一些相关信息了,通过build
实现。
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 }
opts.complete(data, helpers)
} else {
logMessage(opts.completeMessage, data)
}
})
至此就完成了vue-cli
的核心工作。
小结
整个流程其实挺清晰的,主线就是通过download-git-repo
下载模板,inquirer
命令行交互获取具体数据,利用metalsmith
的批量渲染模板和插件式处理能力, 调用handlebars
完成渲染和输出。
-- EOF --