從 vue-cli 源碼學習如何寫模板
vue-cli 是 vuejs 官方提供的基于 vuejs 的項目腳手架工具, 可以很快的幫助 vuejs 開發者搭建一個 startup 項目, 免去環境配置的繁瑣, 開箱即用. 今天就來看下 vue-cli 的實現.
vue-cli 的版本是 2.8.2
vue-init
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
中間件 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
中間件 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
根據用戶的輸入過濾掉不需要的文件之后, 就可以利用 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 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<Object> : 收集用戶自定義數據
- filters<Object> : 根據條件過濾文件
- completeMessage<String> : 模板渲染完成后給予的提示信息, 支持 handlebars 的 mustaches 表達式
- complete<Function> : 模板渲染完成后的回調函數, 優先于 completeMessage
- helpers<Object> : 自定義的 Handlebars 輔助函數
prompts
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
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
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 回調:
{
"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 , 其新加了三個功能:
- token 設置, 用于 Github Api 的 BA 認證
- init project 時可以關聯一個遠程倉庫
- 支持 prompt filter
自己針對日常使用的 vuejs 和 react 框架寫了一些 startup, 歡迎指正:
- vue-startup : webpack 3 + vuejs 2
- vue-typescript : webpack 3 + vuejs 2 + typescript 2
- react-startup : webpack 3 + react 15 + react-router 4 + reudx/mobx
- ts-tools : typescript 2 + rollup
來自:https://github.com/dwqs/blog/issues/56