从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_equnless_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 中, 可以将某些 keyvalue 定义为一个 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: 收集用户自定义数据
  • filters: 根据条件过滤文件
  • completeMessage: 模板渲染完成后给予的提示信息, 支持 handlebars 的 mustaches 表达式
  • complete: 模板渲染完成后的回调函数, 优先于 completeMessage
  • helpers: 自定义的 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()
    }
    
    //...

    whenprompt 示例:

    {
      "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) {}
    }

    datahelpersvue 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

    自己针对日常使用的 vuejsreact 框架写了一些 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

    你可能感兴趣的:(Web,vue,vue-js,vue-cli)