现代前端开发,由于构建工具的大规模使用,项目的配置趋于日渐复杂。很多时候你只是需要快速编写一个简单的demo,但是你首先需要做的可能是花费不少的时间搭建一整套的前端工具链。为了解决这个问题,社区创造出了不少比较成熟的脚手架或者boilerplate来让开发者快速搭建项目。但是如果你想快速的定制化属于你自己的脚手架,今天要聊的yeoman可能是一个不错的选择。

什么是yeoman generator

真正开始写一个yeoman脚手架之前,我们需要先搞清楚 generator 这个名词。

generator是yeoman提供的对于脚手架的封装,每一个generator是一个单独的npm package。不同的generator之间可以通过组合生成新的generator(e.g. generator-generator是对于generator-node的组合)

如果你想使用一个已经发布的generator,你只需要

1
2
3
yarn global add yo generator-[generator-name] # e.g generator-node

yo [generator-name] # e.g yo node

如何编写一个yeoman generator

上面也提到过generator其实是yeoman提供的一个对于脚手架抽象的封装,所以在yeoman对于generator的项目结构也做出了一些开发者必须要满足的要求。

在项目的package.json中

  • 项目必须依赖于 yeoman-generator,
  • 项目 keywords 必须有 yemoan-generator
  • package名应该以generator-[generator-name]的格式进行命名

项目结构

yeoman generator支持两种不同的项目组织模式

模式1

├───package.json
└───generators/
    ├───app/
    │   └───index.js
    └───router/
        └───index.js

模式2

├───package.json
├───app/
│   └───index.js
└───router/
    └───index.js

需要注意的是, 第二种模式下必须要在pacakge.json里面显式的做出声明

1
2
3
4
5
6
{
  "files": [
    "app",
    "router"
  ]
}

一个最简单的generator

以下是一个最简单的Generator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// generators/app/index.js
const Generator = require('yeoman-generator');

module.exports = class extends Generator {
    constructor(args, opts) {
        super(args, opts);

        this.option('babel'); //
    }

    method1() {
        this.log('method 1 just ran');
    }

    method2() {
        this.log('method 2 just ran');
    }}

运行 npm link 将你本地的generator link到全局的path中, 然后运行 yo foo

console将会打印出 method1 just ranmethod2 just ran

私有方法

yeoman在运行时会把所有的class内部的class方法都进行调用,如果你只是想做一些逻辑的规整和抽离,并不想抽离出来的这些方法被调用,你可以使用yeoman提供的两种私有方法声明方式。

1
2
3
4
5
6
7
8
class extends Generator {
    constructor(args, opts) {
        super(args, opts);
    }

    // use instance method
    this.utilMethod = () => console.log("I won't be called");
}

or

1
2
3
4
5
6
7
8
class extends Generator {
    constructor(args, opts) {
        super(args, opts);
    }

    // use _ as a private method mark
    _utilMethod = () => console.log("I won't be called");
}

yeoman还提供了通过extends generator的方法去创建私有方法,Extend a parent generator 但是笔者认为这个方式不够优雅,不太推荐大家使用。

Run Loop

yeoman内部通过Grouped-queue 实现了一套定义好的任务队列,任务队列的顺序如下。

  1. initializing - 初始化脚手架,读取yeoman配置等。
  2. prompting - 通过this.prompt()与用户进行交互。
  3. configuring - 编辑和配置项目的配置文件。
  4. default - 如果generator内部还有不符合任意一个任务队列任务名的方法,将会被放在default这个任务下进行运行。
  5. writing - 将预置好的模板进行填充
  6. conflicts - 处理冲突,比如文件名重复等(仅限内部使用)
  7. install - 进行依赖的安装(如npm, bower)
  8. end - 最后一个任务,一般在这个任务里面会进行一些clean up之类的工作

每一个任务通过实现一个对应名字的class method来进行声明

1
2
3
4
5
6
class extends Generator {
    // initializing | prompting...
    [priorityName]() {
        // do some thing
    }
}

Prompt

yeoman使用了 Inquirer 来做用户提示

Inquirer 支持以下几种类型的用户提示

  • input
  • confirm
  • list
  • rawlist
  • expand
  • checkbox
  • password
  • editor

yeoman通过prompt任务中调用this.prompt来进行用户提示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module.exports = class extends Generator {
    async prompting() {
        this.answers = await this.prompt([{
            type    : 'confirm',
            name    : 'cool',
            message : 'Would you like to enable the Cool feature?'
            // 作为下一次的default
            store: true
        }]);
    }

    writing() {
        this.log('cool feature', this.answers.cool); // user answer `cool` used
    }
};

需要注意的是,所有的console输出yeoman都推荐使用内建的 this.log() 而不是 console.log() 或者 =process.stdout.write()=进行输出。

Argument 和 options

作为一个CLI工具,如何得到用户输入的argument和options也是一个很需要考虑的问题。好消息是yeoman也帮我们做了封装。

Argument?

怎样得到 my-project?

yo webapp my-project

1
2
3
4
5
6
7
8
module.exports = class extends Generator {
    constructor(args, opts) {
        super(args, opts);
        this.argument('appname', {type: String, required: true});
        // this.options.appname 就是 my-project
        this.log(this.options.appname);
    }
}

argument 有以下options可以在定义一个argument的时候传入。

  • desc argument描述
  • required 是否必须
  • type String,Number,Array或者function
  • default 这个argument的default value

options

怎样得到 –foo? yo webapp --foo

1
2
3
4
5
6
7
8
module.exports = class extends Generator {
    constructor(args, opts) {
        super(args, opts);
        // 绑定--foo 这个option
        this.option('foo')
        this.log(this.options.foo);
    }
}

option 有以下几个options可以在定义一个optins的时候传入。

  • desc option描述
  • alias option的short name, 比如 -h 可以定义为 --help 的short name
  • type Boolean,String, Number或者function
  • default 这个option的default value
  • hide 这个option是否要在–help中隐藏

需要注意的是,不管是option还是argument,都需要把定义声明在constructor中

文件命令

Yeoman 封装了一系列路径和文件操作的语法糖来很便利的提供使用者与文件系统的交互。

this.destinationRoot() 是一个对于destination位置的语法糖,支持传入文件或者目录名进行连接。

1
2
3
4
5
this.destinationRoot()
// returns '~/projects'

this.destinationRoot('index.js')
// returns '~/projects/index.js'

对于generator中包含的模板的地址,yeoman也提供了一些语法糖来帮助我们取得其路径。

1
2
3
4
5
6
7
this.sourceRoot()
// returns './templates'

this.templatePath('index.js');
// returns './template/index.js'

以上配置都可以通过修改.yo-rc.json来进行修改

修改一个模板

yeoman提供了一个fs.copyTpl方法来进行模板的填充。fs.copyTpl方法默认使用ejs模板。

1
2
3
4
5
6
<!-- index.html -->
<html>
  <head>
    <title><%= title %></title>
  </head>
</html>
1
2
3
4
5
6
7
8
9
class extends Generator {
  writing() {
    this.fs.copyTpl(
      this.templatePath('index.html'),
      this.destinationPath('public/index.html'),
      { title: 'Templating with Yeoman' }
    );
  }
}

渲染后的结果

1
2
3
4
5
<html>
  <head>
    <title>Templating with Yeoman</title>
  </head>
</html>

参考资料

create a generator