写一个自己的 Yeoman Generator
来自: http://leozdgao.me/write-yeoman-generator/
由于自己经常会写一些 demo,或者学习新工具库的使用,然后又比较依赖 npm 的模块管理(这个是重点)和 webpack 的代码打包功能,所以每次都要创建一个目录结构,复制各种 .rc 文件,复制 webpack 的配置文件,复制一个应用了 webpack dev 中间件的 express server,每次都要这样,让我心里很烦。
我一直知道 yeoman 这个东西,不过找不到自己喜欢的 generator,简单浏览过 generator 的文档,感觉很麻烦,不易上手,就一直没学。最近在新的项目组,我又定义了一套开发的目录规范,为了给自己和团队的其他人提供开发上的便利,于是决定好好学写 Yeoman Generator。
本文将介绍一个基本的 Yeoman Generator 的写法,并分享一些开发中的注意点。
Yeoman 是干什么的?
简单介绍下 Yeoman,它是一个脚手架生成工具,比如在之前写 ASP.NET MVC 的时候,Visual Studio 会给你选模板,然后生成一个项目的基本结构(脚手架),这对提升开发体验是很有帮助的,节省了重复劳动。然而前端没有什么 IDE(WebStorm?或许吧),没有一个固定的开发模式,可能你喜欢 jshint,我想用 eslint,你觉得 angular 顺手,我觉得 vue 更合适,这时就可以使用 Yeoman 这个工具,生成一个 适合自己技术栈 的脚手架,需要的一些文件都预先生成好,给自己省点事。
而 Yeoman Generator 则定义了一个脚手架应该如何生成,所以我们可以去 这个网站 找适合自己的 Generator,如果没有的话,就自己动手吧。
然后这里是安装和使用的命令,不具体介绍它的使用了,想学的话可以去 它的官网 看看。
> npm install -g yo > npm install -g generator-angular > yo angular
自己的需求
先说下自己的需求吧,我希望它可以:
- 满足自己的技术栈:express、webpack、react、babel、eslint、travis
- 自动生成并安装依赖
- 灵活性,即可以生成一个适合写 demo 的小脚手架,也可以生成一个 WebApp 的复杂脚手架,同时,在需要的时候可以只生成一份 .babelrc
- 组合性,多个脚手架可以组合,可复用
很高兴的是,Yeoman 完全可以实现我的需求。
开始写 Yeoman Generator 了
Yeoman 给我们提供了一个用来写脚手架的脚手架 generator-generator ,我们可以从它开始。
由于生成出来的项目依赖 nsp 服务,我在 npm prepublish 阶段的时候发生了域名解析错误的问题,如果遇到了类似的问题,就把 package.json 里的 prepublish 删掉吧。
假设我要写一个 Generator 叫做 Butler(管家的意思),那么,根据 Yeoman 的规定,你需要将这个 node 模块的名字命名为 generator-* ,所以我命名为 generator-butler ,如果你是通过 generator-generator 生成的目录结构,那么可以进入到 generator-butler 目录中,运行 npm link ,就可以开始使用你的 Generator 啦。
Yeoman Generator 高度依赖目录结构,意思是它的行为由你的目录结构决定,怎么说?比如:
yo butler yo butler:babel
第一条命令会找你代码目录中的 app 目录,第二条命令会找你目录中的 babel 目录。这样的一个个目录称为 sub-generator ,默认的 sub-generator 名字是 app。
为什么要这样呢?我分享我的想法,我觉得这是出于对可组合性角度考虑的,我们可以定义多个 sub-generator ,比如我有多个 sub-generator 分别单独管理:babel、eslint、webpack,同时 app 这个默认的 sub-generator 是这几个 sub-generator 的组合,所以:
- 同时可以生成整个项目的结构,也可以(比如)只生成 babel 配置文件
- 各个模块单独管理,易于维护
非常符合自己比较认同的一句话:
perfer composition over inheritance
默认 sub-generator 是基于项目根目录找的,也可以换一个目录(比如 generators),就像例子中那样统一管理,要实现这个,需要在 package.json 中加一个属性:
{ ... "files": [ "generators" ], ... }
如何实现组合,下面会说到。
sub-generator 的加载似乎并不是直接应用 node 的模块 resolve 机制,我本来以为是一个文件夹模块加载方式,我试着直接创建文件模块,它就不认了,看来是必须使用文件夹模块的方式的。
基本结构
Yeoman 为我们提供了 Generator 的基类,于是:
var generators = require('yeoman-generator') module.exports = generators.Base.extend({ constructor: function () { generators.Base.apply(this, arguments) // your logic } })
这边用的 OOP 用的是 classical inheritance 的风格,使用了 class-extend 这个模块,有兴趣的可以看看。
我们需要做的就是定义它的方法就行了。那么要怎么定义呢?
运行周期
一个 Yeoman Generator 被创建后(构造函数必然是最先被调用的),会依次调用它原型上的方法,且每一个方法中的 this 都被绑定为 Generator 实例本身,调用的顺序如下:
- initializing - 初始化一些状态之类的,通常是和用户输入的 options 或者 arguments 打交道,这个后面说。
- prompting - 和用户交互的时候(命令行问答之类的)调用。
- configuring - 保存配置文件(如 .babelrc 等)。
- default - 其他方法都会在这里按顺序统一调用。
- writing - 在这里写一些模板文件。
- conflicts - 处理文件冲突,比如当前目录下已经有了同名文件。
- install - 开始安装依赖。
- end - 擦屁股的部分... Say Goodbye maybe...
上面只是调用顺序,后面的说明是建议,也就是说你完全可以在 install 的部分写文件,在 configuring 的时候就开始安装依赖,不过这样的话,就不保证行为的正确性了,更不要说维护上的问题了,所以,别这样,按照它的强制范式来吧。
这些运行周期方法,除了可以是函数外,还可以是对象,我以 babel 的 sub-generator 为例子:
writing: { files: function () { // 写 `.babelrc` 文件 }, pkg: function () { // 给 package.json 文件上添加依赖项 } }
对象里的每一个函数会被依次执行。是写成一个函数,还是分成多个函数写成一个对象,都可以,我个人倾向于后者。
关于依赖 Object 属性的顺序
偏一下题,注意 default 这个部分,【按顺序执行】?
首先从 ECMAScript 标准来说,并不保证对象属性的顺序,之前开发遇到过坑:
4.3.3 Object An object is a member of the type Object. It is an unordered collection of properties each of which contains a primitive value, object, or function. A function stored in a property of an object is called a method.
自己在写 Generator 的时候也没怎么自定义方法(就是 default 这步是空的),都是依赖它的运行周期函数,而 Yeoman Generator 目前是依赖于对象属性的插入顺序的(相当于运行到 default 这步的时候),这里不多评价,如果平时开发希望在遍历集合的时候,保证遍历顺序的话,应该使用数组或者是ECMAScript 2015 中新增的 Map 对象:
A Map iterates its elements in insertion order, whereas iteration order is not specified for Objects.
和用户的交互
Yeoman 提供了多个方式来灵活定制你的脚手架:
Arguments 和 Options
比如:
yo butler MyProject --react --author leozdgao
其中, MyProject 就一个第一个定义的 argument,而 react 和 author 就是 options,值分别是 true 和 leozdgao。
对于 arguments 来说,不需要输入 key,键值对的对应关系是根据定义顺序来的。对于 options 来说,可以分别出入 key 和 value。
定义 arguments 和 options 的方式是类似的:
this.option('react', { type: Boolean, desc: "need to use React or not.", defaults: false }) this.arguments('name', { type: String, desc: "your project name", required: true })
从参数名上就看的明白是什么意思了,不多说了。
定义 arguments 或者 options 写在哪里都行,不过为了保证在任何地方都能正常访问到,建议放在构造函数中。如果要访问的话:
this.options['react'] // options 通过 options 属性获取 this.name // 是的,arguments 会直接作为 generator 的一个属性
arguments 和 options 的帮助信息会在定义后自动生成(如果它们不是在构造函数中被定义的话,帮助信息就无法自动生成):
> yo butler --help
不要信你定义的 type ,其实这里并没有根据你定义的 type 进行转换,如果对数据类型有要求的话,这里要当心。
CLI 交互
使用与用户问答交互的方式是比较有趣的,同时也不用记住要传的参数,Yeoman 提供了 API 来让我们快速实现 CLI 交互:
module.exports = generators.Base.extend({ prompting: function () { var done = this.async(); this.prompt({ type : 'input', name : 'name', message : 'Your project name', default : this.appname // Default to current folder name }, function (answers) { this.log(answers.name); done(); }.bind(this)); } });
内部直接使用了 Inquirer.js ,API不变,这里就不多写了,大家可以直接看 文档 。
可以发现 Yeoman 处理异步的方式是声明回调并显示调用。
项目模板
生成脚手架就是拷贝模板文件,你可以定义你的模板文件。这里涉及到两个文件夹,一个是你希望生成脚手架的目标文件夹,一个是模板所在的文件夹。Yeoman 提供了 API 来快速获取它们,来看个例子,我希望根据 react 这个 option 来决定是否在 presets 中添加 react :
writing: function () { this.fs.copyTpl( this.templatePath('.babelrc'), this.destinationPath('.babelrc'), { needReact: this.options.react } ) }
获取目标文件夹目录可以用 generator.destinationPath() ,传入的参数和 path.join() 是一样的。获取模板文件夹目录可以用 generator.sourceRoot() ,默认是 Generator 代码目录下的 ./templates ,也可以重写: generator.sourceRoot('new/template/path') 。如果是拼模板文件路径的话,就用 generator.templatePath('app/index.js') 。
Yeoman 给我们提供了方便的处理文件的工具,可以通过 fs 属性调用,其实就是用了 mem-fs-editor 这个库,可以直接看它的 API 说明,这里不多说了,要提一下的是模板引擎用的时 EJS。
这份是我对应上面例子的模板文件:
{ "presets": [ "es2015", "stage-0" <% if (needReact) { %> , "react" <% } %> ] }
例子里调用了 copyTpl ,如果觉得不用经过模板引擎,可以直接用 copy 原样拷贝。
组合
这里的组合只是概念,并不是按照函数式的方式实现的。
要实现组合,其实很简单,在希望调用的地方调用 generator.composeWith 即可,直接上例子:
default: function () { // execute other sub-generators this.composeWith('butler:babel', { options: { react: this.options.react } }, { local: require.resolve('../babel') }) // select a License this.composeWith('license', { options: { name: this.props.authorName, email: this.props.authorEmail, website: this.props.authorUrl } }, { local: require.resolve('generator-license/app') }) }
例子里分别是组合本地的一个 sub-generator ,和一个外部的 Generator ,我选择在 default 这个运行周期调用组合。
composeWith 接受三个参数,第一个参数一个名字,写什么都行,不过最好写要被组合的 Generator 的名字。第二个参数是传入 options 和 arguments 。第三个参数 settings,只用 local 和 link 两个选项,local 是用来定位要组合的 Generator 的位置的,link 还不知道,没怎么看懂它的 说明文档 。
自动安装依赖
恩,差点忘记这个,很简单,就是函数调用:
install: function() { this.npmInstall([ 'lodash' ], { 'saveDev': true }); }
在任何地方调用都是可以的,Yeoman 会统一在进入 install 阶段的时候统一执行。如果还有在用 bower 的同学的话可以用这个: generator.bowerInstall() 。
最后
好了,基本上完了,如果什么地方写错了,还望指出。自己的 butler ,还在开发中,可以参考,另外我其实也是参考 generator-node 的,或者自己找些 Yeoman Generator 的源码学学,个人认为使用 npm 作为包管理是趋势(暂时也应该没有终极方案,还是要依赖 bundle 工具),那么 bundle 工具就是不可或缺的了,写个脚手架还是挺有帮助的,希望本文对大家有帮助。
然后是一些工具库推荐:
- generator-license - 选择 License 的 Generator
- inquirer - 提供命令行交互的工具
- inquirer-npm-name - 帮助查询模块名在 npm 上是否冲突,和 Yeoman 完美融合
- yosay - 在命令行输出信息的时候,同时输出 Yeoman 的卡通人物...
一些文档的链接:
Yeoman 团队目前在开发一个 Yeoman App ,就是一个 GUI 版的 yo 吧,总之还是期待的。
</div>