Yeoman是现在比较流行的脚手架工具,我们可以利用Yeoman快速初始化一个应用。Yeoman中最关键的概念是generator类似插件,Yeoman只提供基本的功能,具体项目的初始化工作都是放在generator中进行的,现在已经有很多非常优秀的generator来初始化各式各样的应用,同时用户也可以根据自己的需要开发定制化的generator。
Yeoman的安装和使用非常简单,所有这里放在一起讲。首先我们需要先安装Yeoman。
npm install -g yo
Yeoman的使用必须依赖generator,因此我们还得安装generator,这里以generator-webapp为例:
npm install -g generator-webapp
现在我们就可以正式开始使用Yoeman了:
mkdir yeoman-demo
yo webapp
执行完上面的命令,会有一些交互式选项,选择完成,我们就在本地初始化了一个webapp项目。
有些generator会提供子命令来进行一些特定文件的初始化,例如angular提供的controller初始化命令:
yo angular:controller MyController
另外Yeoman还提供以下命令:
yo --help
:查看帮助文档;yo --generators
:查看本地以及下载的generators;yo --version
:查看Yeoman版本;yo doctor
:对于普通问题提供诊断和解决方案;Yeoman的使用非常简单,我们更关注的是如何开发自己的generator。
我们首先需要先搭建自己的generator框架,其实generator就是一个node模块。Yeoman约定generator的名字必须是generator-XXX
形式,Yeoman会根据这种命名规则去查找可用的generator。
package.json的内容大致如下所示:
{
"name": "generator-demo",
"version": "0.1.0",
"description": "",
"files": [
"generators"
],
"keywords": ["yeoman-generator"],
"dependencies": {
"yeoman-generator": "^1.0.0"
}
}
我们使用yo name
时使用的是默认generator,名称为app,因此自定义generator中必须要包含一个app文件夹来存放默认generator逻辑,我们可以用其他文件夹来存放sub-generator的逻辑以便在使用yo name:subcommand
命令时调用。通常自定义generator的文件结构如下所示:
├─package.json
└─generators/
├─app/
│ └─index.js
└─subcommand/
└─index.js
有了基本的文件结构,就可以开始写generator了,我们自定义的generator需要继承yeoman提供的generator基类,yeoman在generator基类中提供了很多基础的脚手架功能,我们要做的就是在基类的基础上定制我们自己的generator。
我们可以按照下面的方式来继承generator基类:
const Generator = require('yeoman-generator');
module.exports = class app extends Generator {
constructor(args, opts) {
super(args, opts);
}
}
我们可以在我们的genrator中定义自己的方法,默认情况下,generator会在执行时按照顺序依次执行我们定义的方法,如下所示,我们定义了两个方法:
const Generator = require('yeoman-generator');
module.exports = class app extends Generator {
constructor(args, opts) {
super(args, opts);
}
method1() {
this.log('method 1 just ran');
}
method2() {
this.log('method 2 just ran');
}
}
我们通过npm link
方法将我们的generator关联到本地,这样我们就可以使用yo命令在本地执行我们的generator了:
npm link && yo demo
//执行结果为:
//method 1 just ran
//method 2 just ran
在这一节主要讲解yeoman的generator是如何运行的以及运行的上下文是怎么样的。
generator中每一个定义的方法被认为是generator的一个任务,所有通过Object.getPrototypeOf(Generator)
返回的方法都会在generator使用时按顺序自动执行。
在开发过程中,我们经常需要借助私有方法或者工具方法来处理一些逻辑,上面说到所有定义在类内部的方法都会作为任务自动执行,yeoman提供下面几种方法来定义不需要自动执行的方法(私有方法):
class extends Generator {
//任务
method1() {
console.log('hey 1');
}
//私有方法
_private_method() {
console.log('private hey');
}
}
class extends Generator {
constructor(args, opts) {
// Calling the super constructor is important so our generator is correctly set up
super(args, opts)
this.helperMethod = function () {
console.log('won\'t be called automatically');
};
}
}
class MyBase extends Generator {
helper() {
console.log('methods on the parent generator won\'t be called automatically');
}
}
module.exports = class extends MyBase {
exec() {
this.helper();
}
};
在只有一个generator的时候按照先后顺序来运行任务是合理的,但是当我们需要同时运行多个generator的时候就需要用到run loop概念了。
run loop是一个支持优先级的运行队列。我们通过任务名称来定义任务优先级,如果任务名称属于优先级名称就会放到对应的优先级运行队列,如果不在集合中就会放到默认队列中。我们有如下两种方式来设置优先级任务:
//直接用优先级名称为任务方法命名
class extends Generator {
priorityName() {}
}
//定义一个优先级组放置多个任务,但是这种方式不能用JS class形式定义
Generator.extend({
priorityName: {
method() {},
method2() {}
}
});
Yeoman提供以下可用的优先级命名(优先级从高到低):
Yeoman提供好两种方法来执行异步任务,在执行异步任务时run loop会暂停,直到异步任务执行完成后再恢复。
this.async()
接口,在不支持promise的情况可以使用这种方式;asyncTask() {
var done = this.async();
getUserEmail(function (err, name) {
done(err);
});
}
Yeoman虽然是一个命令行工具,但是还是提供了用户交互的能力,可以让用户在使用的时候进行一些选择。Yeoman提供了一些用户交互组件,并且希望generator作者能够只使用这些组件,否则,可能为导致generator在运行过程中出现问题。
prompt是Yeoman最主要的用户交互形式,Yeoman使用Inquirer.js来提供prompt功能,我们可以参照Inquirer.js的API来使用。
prompt任务是一个异步任务,因此我们需要返回这个Promise实例来保证当前任务执行完成以后再执行其他任务:
prompting() {
return this.prompt([{
type: 'input',
name: 'name',
message: 'your name',
default: 'orangeyyy'
}, {
type: 'confirm',
name: 'man',
message: 'are you a man?'
}]).then((res) => {
this.log(`name: ${res.name}`);
this.log(`man: ${res.man}`);
});
}
有些情况下,用户每次输入的答案是一样的,为了避免用户每次重复输入,Yeoman扩展了Inquirer.js,增加了一个store属性,这样Yeoman会记录用户的选择,并作为下一次的默认值。
prompting() {
return this.prompt([{
type: 'input',
name: 'name',
message: 'your name',
default: 'orangeyyy',
store: true
}]).then((res) => {
this.log(`name: ${res.name}`);
this.log(`man: ${res.man}`);
});
}
Yeoman支持通过this.argument()
指定命令的参数,然后通过this.options
读取。需要注意的是argument的调用需要放在constructor中,否则,在使用yo XXX --help
无法打印我们设置的参数描述。
constructor(args, opts) {
super(args, opts);
this.argument('appname', {
type: String,
required: true,
desc: 'app name for build',
default: 'hello'
});
this.log(this.options.appname);
}
Yeoman提供以下几种参数配置:
如果type设置为Array,它会包含所有剩下的参数;
Yeoman通过this.option()
来指定命令选项,然后通过this.options
读取。有些人可能分不清argument和option,下面的例子一看便知:
//argument
yo demo hello
//option
yo demo --hello
举个栗子:
constructor(args, opts) {
super(args, opts);
this.option('useHttp', {
type: Boolean,
desc: 'use http or not',
default: false
});
this.log(this.options.useHttp);
}
Yeoman提供以下参数配置:
在Yeoman generator中统一使用this.log()
接口来打印日志。
Yeoman提供generator的可组合机制,在一个generator中可以使用其他generator,这样就不必在自己的generator中实现其他generator的已有功能了。
通过generator.composeWith()
我们可以在一个generator中调用其他generator,并保证所有generator并行运行,需要注意的是generator并行运行过程中需要遵循run loop的优先级。
// In demo-generator/generators/app/index.js
const Generator = require('yeoman-generator');
module.exports = class app extends Generator {
constructor(args, opts) {
super(args, opts);
}
end() {
this.log('gen end');
}
initializing() {
this.log('init');
}
method1() {
this.log('method 1 just ran');
this.composeWith(require.resolve('../gen1'));
this.composeWith(require.resolve('../gen2'));
}
method2() {
this.log('method 2 just ran');
}
prompting() {
this.log('prompt ran');
}
}
// In demo-generator/generators/gen1/index.js
const Generator = require('yeoman-generator');
module.exports = class app extends Generator {
prompting() {
this.log('gen1 prompt');
}
end() {
this.log('gen1 end');
}
task() {
this.log('gen1 task');
}
}
// In demo-generator/generators/gen2/index.js
const Generator = require('yeoman-generator');
module.exports = class app extends Generator {
prompting() {
this.log('gen2 prompt');
}
end() {
this.log('gen2 end');
}
task() {
this.log('gen2 task');
}
}
运行yo demo
,我们得到如下结果:
init
prompt ran
method 1 just ran
gen1 prompt
gen2 prompt
method 2 just ran
gen1 task
gen2 task
gen end
gen1 end
gen2 end
如果需要引用已有generator,使用下面方式:
this.composeWith(require.resolve('generator-bootstrap/generators/app'), {preprocessor: 'sass'});
componseWith方法接受两个参数:
如果自己的generator使用到其他已有generator,Yeoman建议使用peerDependencies的方式来描述依赖。
在脚手架中我们经常会有安装依赖的操作(npm、yarn或者bower),对于这种高频次的操作Yeoman为我们做好了封装。Yeoman会把安装依赖的需求统一搜集并放到install队列中执行。
我们通过this.npmInstall()
来安装npm包,Yeoman会保证npm install
只执行一次,即便他在多个generator中调用了多次:
class extends Generator {
installingLodash() {
this.npmInstall(['lodash'], { 'save-dev': true });
}
}
generators.Base.extend({
installingLodash: function() {
this.yarnInstall(['lodash'], { 'dev': true });
}
});
generators.Base.extend({
installingLodash: function() {
this.bowerInstall(['lodash']);
}
});
我们可以通过generator.installDependencies()
组合执行各种依赖安装,我们也可以设置需要通过哪种方式来安装依赖:
generators.Base.extend({
install: function () {
this.installDependencies({
npm: false,
bower: true,
yarn: true
});
}
});
Yeoman提供this.spawnCommand()
接口来spawn任何命令,不过需要注意的是,必须在install队列中调用这个方法:
class extends Generator {
install() {
this.spawnCommand('composer', ['install']);
}
}
Yeoman通常包含两种文件路径上下文: 目标路径:用于通过脚手架构建的项目位置,Yeoman对这个路径通常执行写操作; 模板路径:用于生成目标项目的模板文件,Yeoman对这个路径通常执行读操作;
目标路径定义为当前目录或者包含.yo-rc.json
文件的直接文件夹。Yeoman
提供以下接口获取目标路径:
this.destinationRoot()
- 获取目标路径;this.destinationPath('sub/path')
- 获取目标路径下的子路径;this.contextRoot
- 获取yo命令的执行路径;模板路径默认为./templates/
,我们可以通过this.sourceRoot('new/template/path')
来覆盖默认路径。Yeoman提供以下接口访问模板路径:
this.sourceRoot()
- 获取模板路径;this.templatePath('index.js')
- 获取模板路径的子路径;Yeoman对于覆盖已有文件是非常小心的,每一次覆盖已有文件都需要进行冲突处理,并需要用户确定是否要覆盖。Yeoman的将文件写到磁盘中都是异步执行的,由于异步API通常都很难使用,Yeoman提供了同步的file-system API来将文件写到一个内存文件系统中,当yeoman命令执行完成以后,会一次性将内存文件写到磁盘上,另外内存文件系统可以在组合generator间公用。
generator的所有文件操作都封装在this.fs
中(其实是一个mem-fs editor实例),可以去mem-fs editor的github主页查看所有的API。Yeoman会在conflict
队列执行完成后自动调用commit方法。
<html>
<head>
<title><%= title %></title>
</head>
</html>
我们通过下面的方法将模板文件拷贝到目标路径下:
class extends Generator {
writing() {
this.fs.copyTpl(
this.templatePath('index.html'),
this.destinationPath('public/index.html'),
{ title: 'Templating with Yeoman' }
);
}
}
generator允许用户使用自定义filter来处理文件写入,例如格式化等等,和gulp的机制非常一致,generator可以注册transformStream
来改变写入文件位置及内容,如下所示:
var beautify = require('gulp-beautify');
this.registerTransformStream(beautify({indent_size: 2 }));
默认情况下所有的文件都会通过这个流处理,我们也可以通过gulp-if或者gulp-filter来筛选需要进行处理的文件,理论上所有的gulp插件都可以在Yeoman中使用。
更新已有的文件并不是一件简单的工作,最常用的方式是将文件解析为AST,然后来编辑AST,但是编辑AST也是一件非常繁琐且不好掌握的工作。
常用的AST解析器有:
通过正则形式来解析已有文件是一件非常危险的事情,如果要使用正则来改变已有文件,一定要做好单元测试。
Yeoman提供API来讲用户相关的配置保存在.yo-rc.json
文件中,generator可以通过this.config
来获取。.yo-rc.json的结构如下所示:
{
"generator-backbone": {
"requirejs": true,
"coffee": true
},
"generator-gruntfile": {
"compass": false
}
}
.yo-rc.json
中按照名字来将各个generator的配置划分成单独的sandbox,每个generator的配置只能供自己及自己的sub-generator使用,其他的generator无法访问,不过可以通过argument和options在Yeoman运行时在generator间共享数据。
.yo-rc.json
的格式非常容易理解,因此我们可以让一些高级用户直接配置.yo-rc.json文件而不需要使用prompt的方式。
Yeoman的config接口提供以下方法:
generator.config.save()
- 保存配置文件,如果没有配置文件,将会创建一个,一般在调用set方法是默认会执行save方法;generator.config.set()
- 可以是一个key及一个对应的value,或者是一个json,不过需要注意的是值必须是序列化之后的String;generator.config.get()
- 获取一个指定的配置;generator.config.getAll()
- 获取generator对应的所有配置;generator.config.delete()
- 删除一个指定的配置;generator.config.defaults()
- 设置option的默认值;单元测试是一个工具上线必须要做的事情,通常我们会用Mocha来做单元测试,Mocha的使用不在本文的讨论范围,另外Yeoman提供了一些工具来帮助我们进行单元测试,具体API。
var helpers = require('yeoman-test');
最常用的是run方法,我们可以通过run来调用generator同时可以mock类似arguments和options相关的值:
var path = require('path');
beforeEach(function () {
// The object returned acts like a promise, so return it to wait until the process is done
return helpers.run(path.join(__dirname, '../app'))
.withOptions({ foo: 'bar' }) // Mock options passed in
.withArguments(['name-x']) // Mock the arguments
.withPrompts({ coffee: false }); // Mock the prompt answers
})
另外Yeoman还提供了一个assert工具库,具体API:
var assert = require('yeoman-assert');
我们可以判断文件是否存在:
assert.file(['Gruntfile.js', 'app/router.js', 'app/views/main.js']);
//assert.noFile()起到相反的作用
我们也可以判断文件内容:
assert.fileContent('controllers/user.js', /App\.UserController = Ember\.ObjectController\.extend/);
//assert.noFileContent()起到相反作用
每当我们通过yo命令来运行generator时,我们其实是在使用一个命令行版本的environment,其实这个environment完全可以抽离出来,在任何工具中使用。
首先我们需要安装yeoman-environment,environment提供了一些API来安装generator,注册及调用generator。
npm install --save yeoman-environment
一个简单的栗子:
//创建Yeoman environment
var yeoman = require('yeoman-environment');
var env = yeoman.createEnv();
我可以通过两种方式来注册一个generator:
//通过generator的path
env.register(require.resolve('generator-npm'), 'npm:app');
//直接通过构造器创建一个generator
var GeneratorNPM = generators.Base.extend(/* put your methods in here */);
env.registerStub(GeneratorNPM, 'npm:app');
我们可以通过下面的方式来执行generator:
env.run('npm:app', done);
//执行带有arguments及options的generator
env.run('npm:app some-name', { 'skip-install': true }, done);
通过上面这个栗子我们就可以脱离yo来运行generator了。
我们可以通过environment提供的lookup接口来查询已经安装的generator,并完成注册:
env.lookup(function () {
env.run('angular');
});
我们也可以通过environment提供的getGeneratorsMeta接口来获取已注册generator的描述信息,例如:
{
"webapp:app": {
"resolved": "/usr/lib/node_modules/generator-webapp/app/index.js",
"namespace": "webapp:app"
}
}