orangeyyy
1/1/2018 - 3:39 AM

Yeoman

概述

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:对于普通问题提供诊断和解决方案;

generator

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"
  }
}
  • files:为一个数组,用于指定自定义generator需要用到的文件或者文件夹;
  • dependencies:自定义generator必须继承于yeoman-generator,因此需要依赖yeoman-generator;

我们使用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');
  }
}

执行generator

我们通过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');
      };
    }
  }
  • 扩展一个自定义generator:
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();
  }
};

运行环(run loop)

在只有一个generator的时候按照先后顺序来运行任务是合理的,但是当我们需要同时运行多个generator的时候就需要用到run loop概念了。

run loop是一个支持优先级的运行队列。我们通过任务名称来定义任务优先级,如果任务名称属于优先级名称就会放到对应的优先级运行队列,如果不在集合中就会放到默认队列中。我们有如下两种方式来设置优先级任务:

//直接用优先级名称为任务方法命名
class extends Generator {
  priorityName() {}
}
//定义一个优先级组放置多个任务,但是这种方式不能用JS class形式定义
Generator.extend({
  priorityName: {
    method() {},
    method2() {}
  }
});

Yeoman提供以下可用的优先级命名(优先级从高到低):

  • initializing - 初始化任务,通常用于检查当前项目环境或读取配置信息;
  • prompting - 交互任务,用于通过交互方式获取用户选择;
  • configuring - 保存配置信息并配置项目;
  • default - 如果一个任务名称没有匹配任何优先级命令就将其放到这个队列;
  • writing - 写generator专用文件,例如:routes、controller等;
  • conflicts - 处理冲突;
  • install - 安装依赖,例如:npm、bower等;
  • end - 最后被调用,往往用于做清理工作或是说拜拜;

异步任务

Yeoman提供好两种方法来执行异步任务,在执行异步任务时run loop会暂停,直到异步任务执行完成后再恢复。

  • 返回一个Promise实例,run loop会在Promise结束后继续执行;
  • 使用this.async()接口,在不支持promise的情况可以使用这种方式;
asyncTask() {
  var done = this.async();
  getUserEmail(function (err, name) {
    done(err);
  });
}

用户交互

Yeoman虽然是一个命令行工具,但是还是提供了用户交互的能力,可以让用户在使用的时候进行一些选择。Yeoman提供了一些用户交互组件,并且希望generator作者能够只使用这些组件,否则,可能为导致generator在运行过程中出现问题。

选项(prompt)

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提供以下几种参数配置:

  • desc - 参数描述;
  • required - 是否必须;
  • type - 参数类型 String,Number,Array;
  • default - 参数默认值;

如果type设置为Array,它会包含所有剩下的参数;

选项(option)

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提供以下参数配置:

  • desc:选项描述;
  • alias: 选项别名;
  • type: 选项类型:Boolean、String、Number;
  • default: 默认值;
  • hide:是否在帮助文档中隐藏;

日志(log)

在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方法接受两个参数:

  • generatorPath - 需要使用的generator的完整路径,通常使用require.resolve来获取。
  • options - 传递给被使用generator的选项(options)。

如果自己的generator使用到其他已有generator,Yeoman建议使用peerDependencies的方式来描述依赖。

安装依赖

在脚手架中我们经常会有安装依赖的操作(npm、yarn或者bower),对于这种高频次的操作Yeoman为我们做好了封装。Yeoman会把安装依赖的需求统一搜集并放到install队列中执行。

npm

我们通过this.npmInstall()来安装npm包,Yeoman会保证npm install只执行一次,即便他在多个generator中调用了多次:

class extends Generator {
  installingLodash() {
    this.npmInstall(['lodash'], { 'save-dev': true });
  }
}

yarn

generators.Base.extend({
  installingLodash: function() {
    this.yarnInstall(['lodash'], { 'dev': true });
  }
});

bower

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解析器有:

  • Cheerio - 解析HTML;
  • Esprima - 解析JS,AST-Query提供一些简单的API来编辑Esprima生成的AST;
  • JSON - JS内置的JSON模块,用于解析JSON;
  • Gruntfile Editor - 用于改变gruntfile;

通过正则形式来解析已有文件是一件非常危险的事情,如果要使用正则来改变已有文件,一定要做好单元测试。

配置管理

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

参考