从 vue-cli 源码学习如何写模板
Miguel0563
7年前
<p><a href="/misc/goto?guid=4959751078388813518" rel="nofollow,noindex">vue-cli </a> 是 <a href="/misc/goto?guid=4958977564508786080" rel="nofollow,noindex">vuejs</a> 官方提供的基于 vuejs 的项目脚手架工具, 可以很快的帮助 vuejs 开发者搭建一个 startup 项目, 免去环境配置的繁琐, 开箱即用. 今天就来看下 vue-cli 的实现.</p> <p>vue-cli 的版本是 2.8.2</p> <h2>vue-init</h2> <p>vue init 是基于第三方模板生成项目的命令. 先看下其整体流程:</p> <p><img src="https://simg.open-open.com/show/3ce4dd29fcc5f5790eecbabc9b771331.png"></p> <p>首先, vue cli 获取到输入的参数:</p> <pre> <code class="language-javascript"># vue-cli/bin/vue-init // ... var template = program.args[0] var hasSlash = template.indexOf('/') > -1 var rawName = program.args[1] // ...</code></pre> <p>之后, 会先判断用户是否输入了 offline 选项. 如果有, 则会使用之前缓存的模板:</p> <pre> <code class="language-javascript"># 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 } // ...</code></pre> <p>如果没有, 则判断将会生成的项目目录是否存在. 若存在, 则会向用户确认是否在当前目录生成项目( <a href="/misc/goto?guid=4959751078525174422" rel="nofollow,noindex">代码在这</a> ); 若不存在, 之后就会生成一个新的目录.</p> <p>然后, 会去判断使用的模板是否是本地的, 是本地且存在则使用本地模板生成项目, 反之使用线上模板生成项目( <a href="/misc/goto?guid=4959751078604204451" rel="nofollow,noindex">代码在这</a> ).</p> <p>在判断是使用线上的模板之后, 会根据模板名是否带 / 判断是使用官方提供的模板还是使用第三方模板( <a href="/misc/goto?guid=4959751078694211212" rel="nofollow,noindex">代码在这</a> ).</p> <p>最后会调用 downloadAndGenerate 去下载官方模板或第三方模板来生成项目( <a href="/misc/goto?guid=4959751078762527553" rel="nofollow,noindex">代码在这</a> ). vue cli 对模板的下载依赖于 <a href="/misc/goto?guid=4959751078850928618" rel="nofollow,noindex">download-git-repo</a> , 所以使用第三方模板时, 对指定模板的输入要求可以见 <a href="/misc/goto?guid=4959751078930906565" rel="nofollow,noindex"> download </a> .</p> <p>模板下载成功之后, vue cli 会调用 generate 来生成模板, 这是 cli 的核心模块, 其源码在 lib/generate.js 中. 接下来就具体分析 generate 模块.</p> <p>generate 模块导出之前, 会先在 handlebars 中注册两个辅助函数: if_eq 和 unless_eq , 用于模板中的表达式判断:</p> <pre> <code class="language-javascript"># 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) })</code></pre> <p>导出的 generate 函数接收四个参数: 项目目录名、下载的模板的临时路径、项目目录路径和一个回调函数. 回调函数用于项目生成之后在终端输出一些提示信息. 在 generate 函数内, 首先会读取模板的 meta 信息, 读取的 meta 信息来自于模板目录下的 meta.{js,json} 文件 :</p> <pre> <code class="language-javascript"># vue-cli/lib/options.js // ... // dir 是模板下载成功之后的临时路径 var json = path.join(dir, 'meta.json') var js = path.join(dir, 'meta.js') var opts = {} // ...</code></pre> <p>具体实现 <a href="/misc/goto?guid=4959751079018882753" rel="nofollow,noindex">戳此</a> . 之后会读取用户的 git 昵称和邮箱用于设置 meta 信息的一些默认属性.</p> <p>得到基本的 meta 信息之后, 会利用 <a href="/misc/goto?guid=4959751079090448239" rel="nofollow,noindex">metalsmith</a> 读取 template 内容:</p> <pre> <code class="language-javascript"># vue-cli/lib/generate.js // ... // src 是模板下载成功之后的临时路径 var opts = getOptions(name, src) var metalsmith = Metalsmith(path.join(src, 'template')) // ...</code></pre> <p>需要注意的是, <strong> 读取的内容是模板的 tempalte 目录. </strong> metalsmith 会返回文件路径和文件内容相映射的对象, 这样会方便 metalsmith 的中间件对文件进行处理.</p> <p>之后, vue cli 使用了三个中间件来处理模板:</p> <pre> <code class="language-javascript">//vue-cli/lib/generate.js#L53-L55 metalsmith.use(askQuestions(opts.prompts)) .use(filterFiles(opts.filters)) .use(renderTemplateFiles(opts.skipInterpolation))</code></pre> <h3>askQuestions</h3> <p>中间件 askQuestions 用于读取用户输入:</p> <pre> <code class="language-javascript">function askQuestions (prompts) { return function (files, metalsmith, done) { ask(prompts, metalsmith.metadata(), done) } }</code></pre> <p>ask 的源码在 vue-cli/lib/ask.js 中, 其会遍历 prompts , 在终端交互式的读取用户输入, 并将数据保存在 <a href="/misc/goto?guid=4959751079181495736" rel="nofollow,noindex"> global metadata </a> 中, 便于后续依赖 global metadata 的中间件对模板进行进一步处理. prompts 是一个对象, 每个 prompt 都是一个 <a href="/misc/goto?guid=4959751079251378286" rel="nofollow,noindex">Inquirer.js question object</a> . 示例如下:</p> <pre> <code class="language-javascript">// meta.{js,json} { "prompts": { "name": { "type": "string", "required": true, "message" : "Project name" }, "version": { "type": "input", "message": "project's version", "default": "1.0.0" } } }</code></pre> <p>在 ask 中, 对 meta 信息中的 prompt 会有条件的咨询用户:</p> <pre> <code class="language-javascript">// vue-cli/lib/ask.js#prompt inquirer.prompt([{ type: prompt.type, message: prompt.message, default: prompt.default //... }], function(answers) { // 保存用户的输入 })</code></pre> <p>经过 askQuestions 中间件处理之后, global metadata 是一个以 prompt 中的 key 为 key, 用户的输入为 value 的对象:</p> <pre> <code class="language-javascript">// global metadata { name: 'test', version: '0.1.1' // ... }</code></pre> <h3>filterFiles</h3> <p>中间件 filterFiles 会根据 meta 信息中的 filters 都文件进行过滤:</p> <pre> <code class="language-javascript">function filterFiles (filters) { return function (files, metalsmith, done) { filter(files, filters, metalsmith.metadata(), done) } }</code></pre> <p>filter 的源码在 vue-cli/lib/filter.js 中:</p> <pre> <code class="language-javascript">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() }</code></pre> <p>evaluate 用于执行 js 表达式, 关键定义如下:</p> <pre> <code class="language-javascript">// vue-cli/lib/eval.js var fn = new Function('data', 'with (data) { return ' + exp + '}')</code></pre> <p>所以在 filters 中, 可以将某些 key 的 value 定义为一个 js 表达式.</p> <h3>renderTemplateFiles</h3> <p>根据用户的输入过滤掉不需要的文件之后, 就可以利用 renderTemplateFiles 中间件来渲染模板了:</p> <pre> <code class="language-javascript">// 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(/* 渲染文件 */) }) } }</code></pre> <p>渲染完成之后, metalsmith 会将最终结果 build 的 dest 目录. 若失败, 则将 err 传给回调输出; 反之, 如果 meta 信息有 complete (函数) 或者 completeMessage (字符串), 则会进行调用或输出:</p> <pre> <code class="language-javascript">// 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) } // ...</code></pre> <h2>vue-list</h2> <p>vue list 命令用于查看官方提供的模板列表, 源码在 vue-cli/bin/vue-list 中, 关键代码如下:</p> <pre> <code class="language-javascript">// ... var request = require('request') //... request({ url: 'https://api.github.com/users/vuejs-templates/repos', headers: { 'User-Agent': 'vue-cli' } }, function(err, res, body) { // 在终端输出列表 })</code></pre> <p>需要注意的是, Github Api 对未认证的请求是有请求数限制的, 超过限制则会报错, 但可以通过 BA 认证的方式来提高请求数限制, 具体可以 <a href="/misc/goto?guid=4959751079340082756" rel="nofollow,noindex">戳此</a> .</p> <p>这是个潜在的问题, 已经有 vue-cli 的用户碰到过认证失败的问题: <a href="/misc/goto?guid=4959751079423528263" rel="nofollow,noindex">#368</a> . vue-cli 的下一个版本可能会解决这个问题, 已经有社区用户提出 <a href="/misc/goto?guid=4959751079508689739" rel="nofollow,noindex">PR</a> .</p> <h2>怎么自己写模板呢</h2> <p>从上述的分析可以知道, 模板是有特定的目录结构的:</p> <ul> <li>模板仓库的根目录下必须有 template 目录, 在该目录下定义你的模板文件</li> <li>模板仓库的根目录下必须有 meta.{js,json} 文件, 该文件必须导出为一个对象, 用于定义模板的 meta 信息</li> </ul> <p>对于 meta.{js,json} 文件, 目前可定义的字段如下:</p> <ul> <li>prompts<Object> : 收集用户自定义数据</li> <li>filters<Object> : 根据条件过滤文件</li> <li>completeMessage<String> : 模板渲染完成后给予的提示信息, 支持 handlebars 的 mustaches 表达式</li> <li>complete<Function> : 模板渲染完成后的回调函数, 优先于 completeMessage</li> <li>helpers<Object> : 自定义的 <a href="/misc/goto?guid=4959751079577775379" rel="nofollow,noindex">Handlebars</a> 辅助函数</li> </ul> <h3>prompts</h3> <p>prompts 是一个对象, 每个 prompt 都是一个 <a href="/misc/goto?guid=4959751079251378286" rel="nofollow,noindex">Inquirer.js question object</a> . 示例如下:</p> <pre> <code class="language-javascript">// 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" } } }</code></pre> <p>所有的用户输入完成之后, template 目录下的所有文件将会用 <a href="/misc/goto?guid=4958341606252679125" rel="nofollow,noindex">Handlebars</a> 进行渲染. 用户输入的数据会作为模板渲染时的使用数据:</p> <pre> <code class="language-javascript">// template/package.json {{#test}} "test": "npm run test" {{/test}}</code></pre> <p>在上述示例中, 只有用户在 test 中的回答值是 yes 时, test 脚本才会在 package.json 文件中生成.</p> <p>prompt 可以添加一个 when 字段, 该字段表示此 prompt 会根据 when 的值来判断是否出现在终端提示用户进行输入. 在 vue-cli 中, 其会根据 when 进行 eval 运算:</p> <pre> <code class="language-javascript">// ... if (prompt.when && !evaluate(prompt.when, data)) { return done() } //...</code></pre> <p>带 when 的 prompt 示例:</p> <pre> <code class="language-javascript">{ "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" ] } } }</code></pre> <p>在上述示例中, 只有用户在 lint 中的回答值是 yes 时, eslint 才会被触发, 在终端显示让用户选择 eslint 的配置规范.</p> <h3>filters</h3> <p>filters 字段是一个包含文件过滤规则的对象, 键用于定义符合 <a href="/misc/goto?guid=4959629545937768713" rel="nofollow,noindex">minimatch glob pattern</a> 规则的过滤器, 键值是 prompts 中用户的输入值或者表达式. 例如:</p> <pre> <code class="language-javascript">{ "prompts": { "unit": { "type": "confirm", "message": "Setup unit tests with Mocha?" } }, "filters": { "test/*": "unit" } }</code></pre> <p>在上述示例中, template 目录下 test 目录只有用户在 unit 中的回答值是 yes 时才会生成, 反之会被删除.</p> <p>如果要匹配以 . 开头的文件, 则需要将 minimatch 的 dot 选项设置成 true .</p> <h3>helpers</h3> <p>helpers 字段是一个包含自定义的 <a href="/misc/goto?guid=4959751079577775379" rel="nofollow,noindex">Handlebars</a> 辅助函数的对象, 自定义的函数可以在 template 中使用:</p> <pre> <code class="language-javascript">{ "helpers": { "if_or": function (v1, v2, options) { if (v1 || v2) { return options.fn(this); } return options.inverse(this); } }, }</code></pre> <p>在 template 的文件使用该 if_or :</p> <pre> <code class="language-javascript">{{#if_or val1 val2}} // 当 val1 或者 val2 为 true 时, 这里才会被渲染 {{/if_or}}</code></pre> <h3>complete</h3> <p>在渲染完成后的 complete 回调:</p> <pre> <code class="language-javascript">{ "complete": function(data, helpers) {} }</code></pre> <p>data 和 helpers 由 vue cli 传入:</p> <pre> <code class="language-javascript">// 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} // ...</code></pre> <p>如果 complete 有定义, 则调用 complete , 反之会输出 completeMessage .</p> <h2>总结</h2> <p>vue-cli 的源码还是很好分析的, 参考 vue-cli , 写了一个简化的脚手架工具 <a href="/misc/goto?guid=4959751079738877265" rel="nofollow,noindex"> chare </a> , 其新加了三个功能:</p> <ul> <li>token 设置, 用于 Github Api 的 BA 认证</li> <li>init project 时可以关联一个远程仓库</li> <li>支持 prompt filter</li> </ul> <p>自己针对日常使用的 vuejs 和 react 框架写了一些 startup, 欢迎指正:</p> <ul> <li><a href="/misc/goto?guid=4959749508041860066" rel="nofollow,noindex">vue-startup</a> : webpack 3 + vuejs 2</li> <li><a href="/misc/goto?guid=4959751079852620914" rel="nofollow,noindex">vue-typescript</a> : webpack 3 + vuejs 2 + typescript 2</li> <li><a href="/misc/goto?guid=4959751079928919423" rel="nofollow,noindex">react-startup</a> : webpack 3 + react 15 + react-router 4 + reudx/mobx</li> <li><a href="/misc/goto?guid=4959751080014307753" rel="nofollow,noindex">ts-tools</a> : typescript 2 + rollup</li> </ul> <p> </p> <p>来自:https://github.com/dwqs/blog/issues/56</p> <p> </p>