从 vue-cli 源码学习如何写模板

Miguel0563 4年前
   <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>