dwqs/blog

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

dwqs opened this issue · 13 comments

dwqs commented

vue-clivuejs 官方提供的基于 vuejs 的项目脚手架工具, 可以很快的帮助 vuejs 开发者搭建一个 startup 项目, 免去环境配置的繁琐, 开箱即用. 今天就来看下 vue-cli 的实现.

vue-cli 的版本是 2.8.2

vue-init

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<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()
}

//...

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模板。

请问大佬一个问题,我写的针对业务的模版中有vue变量({{obj.name}}这种形式)在html中,但是初始化之后的代码中,貌似被当作 handlebars 的 mustaches 表达式,因为找不到对应的变量就直接被忽略了,请问有什么办法能阻止这种问题吗?我目前的想法是给handlebars的变量替换加个范围,然后让某些文件中的mustaches写法的不受影响,但是找不到如何下手,求大佬指点一下

dwqs commented

@fundatou{{obj.name}} 改成 \{{obj.name}} 试试

@dwqs 这样是可以的,谢谢大佬!

如果模版想部署在 私有 gitlab 库 并且想用 vue cli 应该从哪些地方入手

dwqs commented

vue cli 貌似并不支持,你可以改下源码 哈哈 @choukin

我觉得好像可以诶,按照vue cli中用的download-git-repo的源码写正确的初始化命令就行了吧 @choukin ,不知道你是不是想问这个问题

@fundatou 多谢我试试

博主你好,我一直无法理解 ,我已经fork vue的webpack,然后在template中/src/components/增加了一个Hi.vue文件,但是使用命令 vue init xxxxx/my-webpack my-project 生成的项目里面,为什么还是只有HelloWord.vue?

如果模版想部署在 私有 gitlab 库 并且想用 vue cli 应该从哪些地方入手

我也有这个需求,请问下朋友解决了吗?

如果模版想部署在 私有 gitlab 库 并且想用 vue cli 应该从哪些地方入手

如果模版想部署在 私有 gitlab 库 并且想用 vue cli 应该从哪些地方入手

我也有这个需求,请问下朋友解决了吗?

download
使用 direct:url 应该可以实现

有一个问题 就是在使用vscode 对模板开发时应如何对代码进行格式化。
{13F53B34-595A-4782-B6D1-C340FC7CCA79}_20190625154834