基于Yeoman实现自定义脚手架


  • 什么是脚手架?
  • Yeoman是什么?
  • 实现自定义脚手架
  • 基于Yeoman实现Vue-cli

 一、什么是脚手架?

手脚架从功能上来讲就是创建项目初始文件,这其中包括生成功能模块配置、自动安装依赖、自动生成标准化(结构的)文件,提高项目创建速度降低手动操作可能发生的低级错误。

关于手脚架在周俊鹏的《前端工程化》中有这样描述,从项目方案定型之后的繁琐配置映射为方案各个模块的功能定制,这整个流程可以归纳为:选定方案——配置方案细节——配置完成——根据定制方案创建项目文件——结束流程。从项目选型到落地这个角度看,手脚架的作用就是封装整个项目方案。

比如搭建一个简单的前端项目开发环境如下:

my_project       //工作区间
--html           //结构文件夹
--css            //样式文件夹
----common_style   //公共样式
--js               //逻辑文件夹
----js_tool      //逻辑模块工具(js功能封装等)
--library        //库或框架(例如jQuery、vue、Bootstrap等)
--src            //本地开发测试服务文件

如果使用手动搭建很容易出错一些低级错误,比如某个文件或者文件夹的名称写错、协同工作时不同的程序员可能出现路径不一致等,还有就是如果是一个非常复杂的项目,每个参与开发的程序员都手动搭建一遍这就是非常低效的工作方式。特别时在现代前端工程中需要安装很多依赖模块,如果每个开发者手动下载可能会导致开发时依赖不同的版本,这必然来带不可预测的BUG,在手脚架中的配置package.json相关依赖包,然后就可以通过包管理工具一键安装依赖(比如npm init -y)。

综上所述可以得出手脚架具备高效、统一、定制化、标准的创建项目的能力。

在现代的前端工程中常见的手脚架有:vue-cli、angular-cli、create-react-app。这些手脚架都是基于框架自身高度封装的,基本上能适应大部分的工程需要,特别是单页面模块化开发项目基本上就是最合适的选择。但对于一些项目需求复杂,特别是需要基于原项目上继续开发时就不是很合适了,这时候我们可能需要针对不同的项目选型定制更适合需求的手脚架。

 二、Yeoman是什么?

手脚架是用于创建项目的工具,yeoman是实现手脚架的底层工具。

手脚架工具实际上就是创建文件夹、文件的工具,其拓展功能还包括下载文件、安装文件、定制文件模板等。这一系列工作yeoman都提供了底层功能模块,我们在基于yeoman构建自己的手脚架工具就是基于这些底层功能,创建具体的工作区间、创建或下载安装具体的文件、定制具体的文件模板。

Yeoman官网:https://yeoman.io/

前面介绍中解释了yeoman就是实现手脚架的底层工具,那么实现自定义手脚就是基于yeoman编写一个手脚架生成器,也就是yeoman的generator(生成器)。在yeoman官方网站上就有一些generator示例,首先我们基于官方提供的generator来实现一个自定义手脚架工具。

全局安装Yeoman:

npm install -g yo

全局安装vue-net-core(一款vue当页面手脚工具)

npm install -g generator-vue-net-core

然后到你需要创建项目的路径下执行以下指令:

yo vue-net-core

"yo"指令就是启动yeoman工具执行相应的生成器,生成你需要的项目结构及文件。

正确执行完以上指令以后就可以在项目路径下得到初识化的项目文件,这里我们暂时不去分析这些初始化文件,只需要明白基于yeoman自定义手脚架就是在全局上安装一个生成器,这个生成器就是实现具体项目生成的手脚架实现。那么接下来就尝试编写generator(生成器)、上传(发布)生成器、安装生成器。

 三、实现自定义脚手架

3.1自定义创建一个最简单的手脚架工具
3.1.1创建yeoman生成器项目

npm init -y  //创建npm模块(需要注意的是生成的默认package.json默认名称是当前项目的文件夹名称,yeoman生成器项目名称必须以generator作为前缀所以需要修改成generator-模式)

npm add yeoman-generator  //添加(安装)Yeoman生成器基类

3.1.2搭建yeoman生成器项目结构

关于这部分详细参考官方文档:https://yeoman.io/authoring/

//基于yeoman构建手脚架生成器的文件结构

|---- node_modules/    //当前项目的依赖包(比如yeoman-generator)

|---- generators/       //生成器的实例路径

   |---- app/        //默认生成器实例路径

   |   |__ index.js      //默认生成器实例入口文件

   |___ router/         //子生成器实例路径

       |__ index.js      //子生成器实例入口文件

|---- package-lock.json     //yeoman-generator模块配置文件(安装时自动生成)

|___package.json       //当前手脚工具生成器的配置文件(npm init -y生成,注意可能需要手动配置生成器的名称)

3.1.3实现生成器的具体功能

默认生成器的具体代码,即:项目根目录/generators/app/index.js

 1 //手脚架默认生成器入口文件
 2 
 3 const Generator = require("yeoman-generator");  //导入yeoman Generator基类
 4 module.exports = class extends Generator {
 5     writing(){
 6         this.fs.write(  //该方法是继承yeoman-generator封装的文件写入方法,接收两个参数(文件路径,文件内容)
 7             this.destinationPath('temp.txt'),   //在全局路径下拼接文件名作为文件路径
 8             Math.random().toString()            //生成一串随机数作为文件内容
 9         );
10     }
11 }
默认生成器实例入口文件
 1 //手脚架子生成器router入口文件
 2 //导入yeoman Generator基类
 3 const Generator = require("yeoman-generator");  //导入yeoman Generator基类
 4 module.exports = class extends Generator {
 5     writing(){
 6         this.fs.write(  //该方法是继承yeoman-generator封装的文件写入方法,接收两个参数(文件路径,文件内容)
 7             this.destinationPath('routerTemp.txt'),   //在全局路径下拼接文件名作为文件路径
 8             Math.random().toString()            //生成一串随机数作为文件内容
 9         );
10     }
11 }
子生成器router实例入口文件

需要注意的是可能你的手脚架生成器只有默认生成器,并不包含其他子生成器,那就当前的示例下面package.json配置的files可以忽略。

{
  ...  
  "files": [
    "app",
    "router"
  ]
}

3.1.4将当前手脚架生成器连接到本地NPM全局上

npm link  //在项目根目录下之下link命令

当然这一步也可以是将生成器发布到NPM仓库,然后在使用install安装到本地,这属于NPM内容就不演示了。

3.1.5使用yeoman调用当前生成器生成项目

//在实例化项目路径下使用yo命令启动手脚架生成器初识化项目
yo cli-1     //使用默认生成器生成项目

yo cli-1:router  //使用子生成器生成项目

 当然这只是一个自定义手脚全过程最简单的演示,所以这个手脚架实例化的内容就只有一个文件。

3.2yeoman功能及API详解

3.2.1Yeoman运行上下文及生命周期:每个直接附加到生成器原型的方法都被认为是一个任务,每个任务由Yeoman环境按照声明周期顺序循环执行。

在Yeoman生命周期中包含一系列的默认接口,这些接口作用与不同的生命周期环节,Yeoman运行环境按照声明周期的顺序执行这些默认接口函数,这个执行过程就是声明周期的实际过程。以下代码结构按照现后循序展示Yeoman的生命周期,也可以理解为每个默认接口的优先级:

 1 const Generator = require("yeoman-generator");
 2 module.exports = class extends Generator {
 3    //定义默认API接口具体业务功能:代码接口中的函数顺序按照生命周期现后顺序排列(实际Yeoman运行环境就会按照下面这些函数定义的顺序执行,即便将这些函数的顺序在代码结构中乱序也会按照示例中的代码结构顺序执行,即优先级)
 4    initializing(){
 5     //    初识化方法:获取当前项目状态,获取基本配置参数
 6         console.log("initializing")
 7    }
 8    prompting(){
 9     //    向用户展示交互式问题,收集相关参数
10         console.log("prompting")
11     }
12     configuring(){
13     //    保存配置相关信息,且生成配置文件(名称一般以‘.’开头的配置)
14         console.log("configuring")
15     }
16     default(){
17     //    未匹配任何生命周期方法的非法私有方法均在此环节“自动执行”
18         console.log("default")
19     }
20     conflicts(){
21     //    处理冲突(内部调用,一般不用处理)
22         console.log("conflicts")
23     }
24     install(){
25     //使用指定的包管理工具进行依赖安装(支持npm,bower,yarn)
26         console.log("install")
27     }
28     end(){
29     //结束动作,例如清屏,输出结束信息等
30         console.log("end");
31     }
32 }

使用yo执行该生成器的打印结果:

 为了验证生命周期函数的优先级,可以使用以下逆序的代码结构测试,测试结构与上面的结果会完全一致:

 1 const Generator = require("yeoman-generator");
 2 module.exports = class extends Generator {
 3     //逆序生命周期函数代码结构,测试优先级
 4    end(){
 5     //结束动作,例如清屏,输出结束信息等
 6         console.log("end");
 7     }
 8     install(){
 9     //使用指定的包管理工具进行依赖安装(支持npm,bower,yarn)
10         console.log("install")
11     }
12     conflicts(){
13     //    处理冲突(内部调用,一般不用处理)
14         console.log("conflicts")
15     }
16     default(){
17     //    未匹配任何生命周期方法的非法私有方法均在此环节“自动执行”
18         console.log("default")
19     }
20     configuring(){
21     //    保存配置相关信息,且生成配置文件(名称一般以‘.’开头的配置)
22         console.log("configuring")
23     }
24     prompting(){
25     //    向用户展示交互式问题,收集相关参数
26         console.log("prompting")
27     }
28    initializing(){
29     //    初识化方法:获取当前项目状态,获取基本配置参数
30         console.log("initializing")
31    }
32 }

3.2.2在生成器原型上定义辅助方法和私有方法:

 1 const Generator = require("yeoman-generator");
 2 module.exports = class extends Generator {
 3    //定义默认API接口具体业务功能:代码接口中的函数顺序按照生命周期现后顺序排列(实际Yeoman运行环境就会按照下面这些函数定义的顺序执行,即便将这些函数的顺序在代码结构中乱序也会按照示例中的代码结构顺序执行,即优先级)
 4    //直接在生成器上扩展辅助方法
 5    fun1(){
 6         console.log("fun1");
 7    }
 8    fun2(){
 9        console.log("fun2")
10    }
11    //在生成器构造函数上扩展辅助方法
12    constructor(args, opts){
13         super(args,opts);
14         this.fun3 = function(){
15             console.log("fun3")
16         }
17         this.fun4 = function(){
18             console.log("fun4")
19         }
20    }
21    //直接在生成器上扩展私有方法
22    _fun5(){
23        console.log("fun5")
24    }
25    _fun6(){
26         console.log("fun6");
27    }
28    initializing(){
29     //    初识化方法:获取当前项目状态,获取基本配置参数
30         console.log("initializing")
31         this.fun1();
32         this.fun3();
33         this._fun5();
34    }
35    prompting(){
36     //    向用户展示交互式问题,收集相关参数
37         console.log("prompting")
38     }
39     configuring(){
40     //    保存配置相关信息,且生成配置文件(名称一般以‘.’开头的配置)
41         console.log("configuring")
42     }
43     default(){
44     //    未匹配任何生命周期方法的非法私有方法均在此环节“自动执行”
45         console.log("default")
46     }
47     conflicts(){
48     //    处理冲突(内部调用,一般不用处理)
49         console.log("conflicts")
50     }
51     install(){
52     //使用指定的包管理工具进行依赖安装(支持npm,bower,yarn)
53         console.log("install")
54     }
55     end(){
56     //结束动作,例如清屏,输出结束信息等
57         console.log("end");
58     }
59 }

 测试结果:

从测试的结果可以看到直接添加在生成器上的普通方法除了可以被this直接调用,还会在default之前自动调用,而在构造函数添加的私有方法和生成器原型(constructor)上添加的普通方法不会在default自动调用,而需要this手动调用。这里需要提示的是为什么调用和不能被调用涉及ES6的class底层原理,可以参考我之前的一篇博客:。但这里解释以下关于default和前面自动调用生成器上添加的普通方法,其实default本身就是用来处理这类普通方法,可以理解为其内部有一个私有方法_default将手动定义的default包裹起来。_default收集定义在生成器上的普通方法并执行,然后再执行手动添加的default方法,模拟的代码逻辑可以参考下面的代码:

_default(fun1,fun2){
    this.fun1();
    this.fun2();
    this.default();
}

 除了以上的方式扩展辅助方法和私有方法,还可以通过扩展一个父生成器来实现,参考以下代码:

 1 const Generator = require("yeoman-generator");
 2 class MyBase  extends Generator {
 3     //定义默认API接口具体业务功能:代码接口中的函数顺序按照生命周期现后顺序排列(实际Yeoman运行环境就会按照下面这些函数定义的顺序执行,即便将这些函数的顺序在代码结构中乱序也会按照示例中的代码结构顺序执行,即优先级)
 4     //直接在生成器上扩展辅助方法
 5     fun1(){
 6          console.log("fun1");
 7     }
 8     fun2(){
 9         console.log("fun2")
10     }
11     //在生成器构造函数上扩展辅助方法
12     constructor(args, opts){
13          super(args,opts);
14          this.fun3 = function(){
15              console.log("fun3")
16          }
17          this.fun4 = function(){
18              console.log("fun4")
19          }
20     }
21     //直接在生成器上扩展私有方法
22     _fun5(){
23         console.log("fun5")
24     }
25     _fun6(){
26          console.log("fun6");
27     }
28     initializing(){
29      //    初识化方法:获取当前项目状态,获取基本配置参数
30          console.log("initializing")
31          this.fun1();
32          this.fun3();
33          this._fun5();
34     }
35     prompting(){
36      //    向用户展示交互式问题,收集相关参数
37          console.log("prompting")
38      }
39      configuring(){
40      //    保存配置相关信息,且生成配置文件(名称一般以‘.’开头的配置)
41          console.log("configuring")
42      }
43      default(){
44      //    未匹配任何生命周期方法的非法私有方法均在此环节“自动执行”
45          console.log("default")
46          this.fun2();
47      }
48      conflicts(){
49      //    处理冲突(内部调用,一般不用处理)
50          console.log("conflicts")
51      }
52      install(){
53      //使用指定的包管理工具进行依赖安装(支持npm,bower,yarn)
54          console.log("install")
55      }
56      end(){
57      //结束动作,例如清屏,输出结束信息等
58          console.log("end");
59      }
60  }
61 module.exports = class extends MyBase{
62     fun7(){
63         console.log("fun7")
64     }
65     initializing(){
66         console.log("__initializing:");
67         this.fun1();
68     }
69 }

打印结果:

这个测试代码除了演示扩张方法,另外还可以说明几个问题,扩展方法很简单理解,相当于在原型上定义一个方法,然后在当前对象上使用this调用,这是非常简单的继承。但这个测试代码带出了一个关键话题,即Yeoman环境是如何使用和执行生命周期函数的。

可以从代码中看到一个非常有意思的现象是,测试结果并没有打印任何父级生成器生命周期函数的执行结果,也就是说子生成器不用自动使用父级的生命周期函数。这在官方文档中的一个介绍有体现:Yeoman环境会使用Object.getPrototypeOf(Generator)获取生成器的生命周期函数,然后再按照环境定义的优先级现后执行生命周期函数,所以它并不会获取到父级上的生命周期函数。

3.2.3用户交互:在Yeoman生成器上提供了一个prompt方法来实现用户交互操作,这里现不对其做具体介绍,先看以下示例代码:

 1 const Generator = require("yeoman-generator");
 2 module.exports = class  extends Generator {
 3     //定义默认API接口具体业务功能:代码接口中的函数顺序按照生命周期现后顺序排列(实际Yeoman运行环境就会按照下面这些函数定义的顺序执行,即便将这些函数的顺序在代码结构中乱序也会按照示例中的代码结构顺序执行,即优先级)
 4     //直接在生成器上扩展辅助方法
 5     fun1(){
 6          console.log("fun1");
 7     }
 8     fun2(){
 9         console.log("fun2")
10     }
11     //在生成器构造函数上扩展辅助方法
12     constructor(args, opts){
13          super(args,opts);
14          this.fun3 = function(){
15              console.log("fun3")
16          }
17          this.fun4 = function(){
18              console.log("fun4")
19          }
20     }
21     //直接在生成器上扩展私有方法
22     _fun5(){
23         console.log("fun5")
24     }
25     _fun6(){
26          console.log("fun6");
27     }
28     initializing(){
29      //    初识化方法:获取当前项目状态,获取基本配置参数
30          console.log("initializing")
31          this.fun1();
32          this.fun3();
33          this._fun5();
34 
35 
36 //------------------------------关于用户交互的相关代码-----------------------------------------------------
37 
38          this.promptData = {};  //在构造器上定义一个收集提示数据的对象
39     }
40     prompting(){
41      //    向用户展示交互式问题,收集相关参数
42          console.log("prompting")
43          return this.prompt([
44              {
45                  type:'input',              //交互类型
46                  name:'name',               //定义用户交互数据的key(键)--提示数据键
47                  message:"输入项目名称:",   //交互的内容(消息、提问内容、提示)
48                  default:this.appname       //默认值; appname 为当前项目生成目录名称
49              }
50          ]).then( answers =>{
51             //answers 用户输入的
52             let k = JSON.stringify(answers).match(new RegExp('(?<={").+(?=":)'))[0];    //获取收集提示数据的键
53             this.promptData[k] = answers[k]; //将数据缓存到提示数据对象上
54          });
55      }
56      writing(){
57         console.log(this.promptData);  //测试提示数据
58     }
59 
60 //-------------------------------------------------------------------------------------------
61      configuring(){
62      //    保存配置相关信息,且生成配置文件(名称一般以‘.’开头的配置)
63          console.log("configuring")
64      }
65     
66      default(){
67      //    未匹配任何生命周期方法的非法私有方法均在此环节“自动执行”
68          console.log("default")
69          this.fun2();
70      }
71      conflicts(){
72      //    处理冲突(内部调用,一般不用处理)
73          console.log("conflicts")
74      }
75      install(){
76      //使用指定的包管理工具进行依赖安装(支持npm,bower,yarn)
77          console.log("install")
78      }
79      end(){
80      //结束动作,例如清屏,输出结束信息等
81          console.log("end");
82      }
83  }

控制台输入、打印截图:

首先值得关注的是prompt接收的参数是一个数组,数组的每个元素都是一个对象,对象属性包括:交互类型(type)、交互数据的键(name)、交互提示信息(message)、交互数据的默认值(default)、列表选项(choices)、校验(validate)、过滤器(filter:交互完成后降交互数据值传入filter,filter执行后返回值才是当前交互数据的值)、定义用户交互内容的样式(transformer:字体|颜色等)、前置条件受理交互操作(when:可以参考交互类型confirm的测试代码)、修改渲染行数(pageSize)、默认前缀(prefix)、默认后缀(suffix)、是否隐藏帮助(hide:取值boolean),其中默认值不是必须的。关于prompt其本身是由Inquirer.js提供,详细可以参考:https://github.com/SBoudrias/Inquirer.js,或者这一篇博客:https://blog.csdn.net/qq_26733915/article/details/80461257

在代码中可以看出prompt返回的是一个Promise对象,所以在prompting中返回一个prompt立即执行结果并使用then继续处理交互数据。这与官方的示例有一些差异,官方使用async ... await ...的代码逻辑来处理,这是ES6相关内容但考虑可能在实际项目中也是用官方的示例逻辑来实现相关业务功能,这里做一些简单的解释(参考注释):

 1 module.exports = class extends Generator {
 2   async prompting() {    //将prompting作为一个异步任务
 3     this.answers = await this.prompt([    //然后在prompt交互时使用await实现等待异步任务的交互处理,最后将所有交互数据附加到生成器对象的自定义属性answers上,这相对我前面的示例代码省去了处理交互数据的键,并且多个交互可以一起收集
 4       {
 5         type: "confirm",
 6         name: "cool",
 7         message: "Would you like to enable the Cool feature?"
 8       }
 9     ]);
10   }
11 
12   writing() {
13     this.log("cool feature", this.answers.cool); // user answer `cool` used
14   }
15 };

最后关于Yeoman用户交互值得关注的是交互类型,在Yeoman官方文档中只使用input类型演示一个示例,关于这一部分内容需要参考Inquirer.js,如果对Inquirer.js有了解的可以直接跳过这一部分内容。

Yeoman用户交互类型:input、number、confirm、list、checkbox、password。

input:输入项,将输入的内容作为交互数据。

number:数,这本质上还是一个输入交互操作,只是这个输入只接收数值数据,比如如果输入“八”输入交互会在内部转换成NaN。

confirm:确认,这个交互接收两个参数(Y/n),除了输入'Y/y'以外的值为true,最终交互数据的值都是false。

 1  async prompting(){
 2         this.answers = await this.prompt([
 3             {
 4                 type:'confirm',
 5                 name:'wants_webpack',
 6                 message:'是否需要webpack',
 7                 when:function(answers){
 8                    //when相当于是一个前置条件,只有when返回值为真才会触发当前用户交互会话;接收的参数answers是所有用户交互数据
 9                     console.log(answers)
10                     return true;    
11                 }
12             }
13         ]);
14     }

list:清单,可以理解为单选框,基于交互属性choices的选项列表实现。

 1 async prompting(){
 2         this.answers = await this.prompt([
 3             {
 4                 type:'list',
 5                 name:'tests',
 6                 message:"是否使用单元测试",
 7                 default:"E2E Testing",
 8                 choices:["Unit Testing","E2E Testing"],
 9                 filter:function(val){
10                     //基于过滤器将交互数据处理成我们需要的格式
11                     let dataAnalytical = function(val){
12                         let arr = val.split(" ");
13                         let data = "";
14                         for(let i = 0; i < arr.length; i++){
15                             if(i === 0){
16                                 data += arr[i].toLowerCase();
17                                 continue;
18                             }
19                             data += arr[i];
20                         }
21                         return data;
22                     }
23                     return dataAnalytical(val);
24                 }
25             }
26         ]);
27     }

checkbox:选框,可以理解为复选框,基于交互属性choices的选项列表实现。

1 {
2                 type:'checkbox',
3                 name:'otherConfiguration',
4                 message:"选择其他配置:",
5                 choices:["Babel","TypeScript","Router","Vuex"]
6             }

password:密码,可以理解为特殊的input,通过mask属性设置字符遮蔽效果,详细参考示例:

1             {
2                 type:'password',
3                 name:'userPassword',
4                 message:'输入用户密码:',
5                 mask:"*"
6             }

关于用户交互类型还有其他三个分别是:rawlist、expand、editor。rawlist和expand本质上还是单选列表,可以参考下面的完整测试代码。关于editor时启动编辑器,但我在测试的时候出现了无法继续往下交互执行的问题,后续解决了再来补充。

  1 const Generator = require("yeoman-generator");
  2 module.exports = class  extends Generator {
  3     //定义默认API接口具体业务功能:代码接口中的函数顺序按照生命周期现后顺序排列(实际Yeoman运行环境就会按照下面这些函数定义的顺序执行,即便将这些函数的顺序在代码结构中乱序也会按照示例中的代码结构顺序执行,即优先级)
  4     //直接在生成器上扩展辅助方法
  5     fun1(){
  6          console.log("fun1");
  7     }
  8     fun2(){
  9         console.log("fun2")
 10     }
 11     //在生成器构造函数上扩展辅助方法
 12     constructor(args, opts){
 13          super(args,opts);
 14          this.fun3 = function(){
 15              console.log("fun3")
 16          }
 17          this.fun4 = function(){
 18              console.log("fun4")
 19          }
 20     }
 21     //直接在生成器上扩展私有方法
 22     _fun5(){
 23         console.log("fun5")
 24     }
 25     _fun6(){
 26          console.log("fun6");
 27     }
 28     initializing(){
 29      //    初识化方法:获取当前项目状态,获取基本配置参数
 30          console.log("initializing")
 31          this.fun1();
 32          this.fun3();
 33          this._fun5();
 34 
 35 
 36 //------------------------------关于用户交互的相关代码-----------------------------------------------------
 37 
 38         //  this.promptData = {};  //在构造器上定义一个收集提示数据的对象
 39     }
 40     // prompting(){
 41     //  //    向用户展示交互式问题,收集相关参数
 42     //      console.log("prompting")
 43     //      return this.prompt([
 44     //          {
 45     //              type:'input',              //交互类型
 46     //              name:'name',               //定义用户交互数据的key(键)--提示数据键
 47     //              message:"输入项目名称:",   //交互的内容(消息、提问内容、提示)
 48     //              default:this.appname       //默认值; appname 为当前项目生成目录名称
 49     //          }
 50     //      ]).then( answers =>{
 51     //         //answers 用户输入的
 52     //         let k = JSON.stringify(answers).match(new RegExp('(?<={").+(?=":)'))[0];    //获取收集提示数据的键
 53     //         this.promptData[k] = answers[k]; //将数据缓存到提示数据对象上
 54     //      });
 55     //  }
 56     //  writing(){
 57     //     console.log(this.promptData);  //测试提示数据
 58     // }
 59 
 60 //-------------------------------------------------------------------------------------------
 61 
 62     async prompting(){
 63         this.answers = await this.prompt([
 64             {
 65                 type:'input',              //交互类型
 66                 name:'name',               //定义用户交互数据的key(键)--提示数据键
 67                 message:"输入项目名称:",   //交互的内容(消息、提问内容、提示)
 68                 default:this.appname       //默认值; appname 为当前项目生成目录名称
 69             },
 70             {
 71                 type:'confirm',
 72                 name:'wants_webpack',
 73                 message:'是否使用webpack',
 74                 when:function(answers){
 75                    //when相当于是一个前置条件,只有when返回值为真才会触发当前用户交互会话
 76                     console.log(answers.name)
 77                     return true;    
 78                 }
 79             },
 80             {
 81                 type:'list',
 82                 name:'tests',
 83                 message:"是否使用单元测试",
 84                 default:"Unit Testing",
 85                 choices:["Unit Testing","E2E Testing","False"],
 86                 filter:function(val){
 87                     //基于过滤器将交互数据处理成我们需要的格式
 88                     let dataAnalytical = function(val){
 89                         let arr = val.split(" ");
 90                         let data = "";
 91                         if(val === "False"){
 92                             return false;
 93                         }
 94                         for(let i = 0; i < arr.length; i++){
 95                             if(i === 0){
 96                                 data += arr[i].toLowerCase();
 97                                 continue;
 98                             }
 99                             data += arr[i];
100                         }
101                         return data;
102                     }
103                     return dataAnalytical(val);
104                 }
105             },
106             {
107                 type:'checkbox',
108                 name:'otherConfiguration',
109                 message:"选择其他配置:",
110                 choices:["Babel","TypeScript","Router","Vuex"]
111             },
112             {
113                 type:'password',
114                 name:'userPassword',
115                 message:'输入用户密码:',
116                 mask:"*"
117             },
118             {
119                 type:'rawlist',
120                 message:"请选择一种水果",
121                 name:'fruit',
122                 choices:[
123                     "apple",
124                     "pear",
125                     "banana"
126                 ],
127                 default:1
128             },
129             {
130                 type:'expand',
131                 name:"fruit1",
132                 message:"请选择一种水果",
133                 choices:[{
134                     key:'a',
135                     name:'Apple',
136                     value:"apple"
137                 },
138                 {
139                     key:'o',
140                     name:'Orange',
141                     value:"orange"
142                 },
143                 {
144                     key:'p',
145                     name:'Pear',
146                     value:"pear"
147                 }],
148                 default:"o"
149             },
150             // {
151             //     type:'editor',
152             //     name:'editor',
153             //     message:"请输入备注信息:"
154             // }
155 
156         ]);
157     }
158 
159     writing(){
160         console.log(this.answers);  //测试提示数据
161     }
162      configuring(){
163      //    保存配置相关信息,且生成配置文件(名称一般以‘.’开头的配置)
164          console.log("configuring")
165      }
166     
167      default(){
168      //    未匹配任何生命周期方法的非法私有方法均在此环节“自动执行”
169          console.log("default")
170          this.fun2();
171      }
172      conflicts(){
173      //    处理冲突(内部调用,一般不用处理)
174          console.log("conflicts")
175      }
176      install(){
177      //使用指定的包管理工具进行依赖安装(支持npm,bower,yarn)
178          console.log("install")
179      }
180      end(){
181      //结束动作,例如清屏,输出结束信息等
182          console.log("end");
183      }
184  }
关于用户交互的完整测试代码

 3.2.4可组合性:将多个生成器组合到一个手脚架工具中,避免重复相同的功能。

在当前的生成器项目中添加组合的依赖生成器,可以直接通过npm install 安装到当前生成器项目下,在package.json中可以配置为dependencies或peerDependencies依赖关系。

除了发布到npm或其他模块管理库的yeoman生成器,可能你的依赖是本地的一个yeoman生成器,比如前面3.1.3中link到本地全局的generator-cli-1的测试生成器,可以在当前生成器项目根目录下在通过npm link到当前生成器项目依赖目录中:

npm link generator-cli-1

然后再package.json中配置:

"dependencies": {
    "yeoman-generator": "^5.6.1",
    "generator-cli-1":"*"
  }

以上的依赖安装算是npm包管理器的知识点,接着来看在yeoman生成器中如何组合依赖的生成器。

在yeoman生成器中实现生成器依赖组合的API是composeWith,这个API的使用非常简单,具体看下面的测试代码,如果需要了解详细的API可以自行查看官方文档:

 1 const Generator = require("yeoman-generator");
 2 const cliRouter = require('generator-cli-1/generators/router');
 3 
 4 module.exports = class  extends Generator {
 5     initializing(){
 6         this.composeWith(require.resolve("generator-cli-1/generators/app"),{a:"aaa"});
 7         this.composeWith({
 8             Generator:cliRouter,
 9             path:require.resolve("generator-cli-1/generators/router")
10         })
11     }
12 }

关于依赖的generator-cli-1的生成器为了更好的测试效果,代码有一些改变:

 1 //手脚架默认生成器入口文件
 2 
 3 const Generator = require("yeoman-generator");  //导入yeoman Generator基类
 4 module.exports = class extends Generator {
 5     constructor(args, opts){
 6         console.log(opts.a);
 7         super(args,opts);
 8     }
 9     prompting(){
10         this.log('prompting-cli-1')
11     }
12     writing(){
13         this.log('writing-cli-1')
14     }
15 }
cli-1/generators/app/index.js
 1 //手脚架子生成器router入口文件
 2 //导入yeoman Generator基类
 3 const Generator = require("yeoman-generator");  //导入yeoman Generator基类
 4 module.exports = class extends Generator {
 5     prompting(){
 6         this.log('prompting-router')
 7     }
 8     writing(){
 9         this.log('writing-router')
10     }
11 }
cli-1/generators/router/index.js

测试效果:

从测试效果来看,每个生成器是按照Yeoman执行优先级逐级执行所有生成器的生命周期函数。

3.2.5管理依赖项:在使用手脚架工具创建项目后,如果手脚架工具没有集成项目依赖安装功能,我们就需要手动使用NPM等包管理工具安装依赖。这对于Yeoman来说当然是不用担心的,在Yeoman中集成了管理依赖的模块actions/install.js。

Yeoman集成的管理依赖同时支持npm、yarn、bower,使用管理依赖的API也非常简单,需要注意的是虽然在“yeoman-generator”中实现了actions/install.js,但在Yeoman5.0版本开始在生成器原型上并没有绑定install.js的API,所以这需要将install.js的方法克隆到Generator原型上。

1 const Generator = require("yeoman-generator"); 
2 const install = require('yeoman-generator/lib/actions/install');//引入管理依赖工具
3 const tools = require("../../tools.js");//注意这里工具方法模块放在根目录下,但生成器的入口文件不在根目录下,所以需要向上定位到根目录
4 tools.clone(Generator.prototype,install);//将管理依赖工具的方法克隆到生成器构造原型上
 1 //浅克隆
 2 let clone = function(target, origin){
 3     console.log("111");
 4     for(let prop in origin){
 5         if(origin.hasOwnProperty(prop) && target[prop] === undefined){
 6             target[prop] = origin[prop];
 7         }
 8     }
 9 }
10 //深克隆
11 let deepClone = function(target, origin){
12     target = target || {};
13     let toStr = Object.prototype.toString,
14         arrStr = '[object Array]',
15         objStr = '[object Object]';
16     for(let prop in origin){
17         if(origin.hasOwnProperty(prop)){
18             if(origin[prop] !== null && typeof(origin[prop]) === 'object'){
19                 if(toStr.call(origin[prop]) === arrStr){
20                     target[prop] = toStr.call(target[prop]) === arrStr ? target[prop] :[];
21                 }else{
22                     target[prop] = toStr.call(target[prop]) == objStr ? target[prop] : {};
23                 }
24                 deepClone(target[prop],target[prop]);
25             }else{
26                 target[prop] = origin[prop];
27             }
28         }
29     }
30     return target;
31 }
32 
33 
34 
35 module.exports = {
36     clone,
37     deepClone
38 }
tools.js

实现加载依赖有两种方式,一种是直接使用API加载依赖,另一种是将需要依赖的模块配置到package.json中,然后再生命周期函数install中启动依赖加载工具,个人比较建议使用第二种方式:

 1 module.exports = class  extends Generator {
 2     writing(){
 3         const pkgJson = {
 4             devDependencies:{
 5                 eslint:'^3.15.0'
 6             },
 7             dependencies:{
 8                 react:'^16.2.0'
 9             }
10         };
11         //将依赖配置添加到package.json中
12         this.fs.extendJSON(this.destinationPath('package.json'),pkgJson);
13     }
14     install(){
15         this.npmInstall();//启动npm依赖加载工具加载配置文件中依赖的模块
16     }
17 }

直接使用依赖加载API直接加载依赖可以这样写:

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

这相当于使用下面这段命令添加模块:

npm install lodash --save-dev

3.2.6与文件系统交互:文件系统主要有三个基本功能分别是资源定位、文件传输与安装、模板处理。

资源定位在生成器原型上有四个基本方法:项目根目录(destinationRoot)、连接项目根目录生成路径(destinationRoot)、模板文件根目录(sourceRoot)、链接模板文件根目录生成路径(templatePath)。需要注意的是这四个方法都是基于默认的路径配置实现,除了默认配置也可以通过配置yo-rc.json自定义配置,然后通过读取自己在yo-rc.json配置的路径来实现,如果不使用默认的Yeoman配置生成路径,这种情况下就是按照默认配置组织你的生成器项目结构,比如模板根目录放在与生成器入口文件同一目录下,且模板根目录文件夹必须命名为“templates”。

//文件系统测试代码文件结构

|---- node_modules/    //当前项目的依赖包(比如yeoman-generator)

|---- generators/       //生成器的实例路径

   |---- app/        //默认生成器实例路径

         |----index.js      //默认生成器实例入口文件

      |__templates      //模板文件根目录

        |----index.html   //结构模板文件

        |----index.css    //样式模板文件

        |__index.js     //逻辑模板文件

|---- package-lock.json     //yeoman-generator模块配置文件(安装时自动生成)

|___package.json       //当前手脚工具生成器的配置文件(npm init -y生成,注意可能需要手动配置生成器的名称)

 资源定位测试代码:

 1 const Generator = require("yeoman-generator");
 2 
 3 module.exports = class  extends Generator {
 4     paths(){
 5         console.log(this.destinationRoot());    //项目根目录
 6         console.log(this.destinationPath('index.js'));//基于项目根目录连接具体文件相对路径生成文件绝对路径
 7         console.log(this.sourceRoot());         //手脚架模板文件根目录(准确的说应该是当前生成器模板文件根目录)
 8         console.log(this.templatePath("index.js")); //基于生成器模板根目录连接具体模板文件相对路径生成模板目标文件绝对路径
 9     }
10 }

关于资源定位依赖的.yo-rc.json在下一节的存储用户配置中详细介绍,这个阶段只需要直到在未配置.yo-rc.json时,Yeoman会自动调用内部默认配置。接着来关注文件传输、安装、模板处理,在手脚架中一般包含两种模板文件:静态的模板文件、动态模板文件。

所谓静态模板文件就是不需要做任何改动的源文件,只需要将它从模板中拷贝到项目中就可以直接使用。在Yeoman的API文档中提供了一系列的方法,比如读取模板文件的readTemplate和写入目标文件夹的writeDestination,你会发现这两个方法是无法实现的,这个无法实现存在两个问题,第一个问题是这两个方法并不存在(这可能是我是用的Yeoman版本更新了,这两个方法的接口被跟新了);第二个问题是写入目标文件并不是写入文件,而且这个方法的API介绍中并没有传入文件数据的接收参数。不论这一系列问题的原因到底是什么,只说明一个问题,直接使用API文档中的接口可能导致你无法实现文件读写功能。

但不代表这些问题就无法解决,第一个想到的解决方案就是基于Nodejs的原生文件系统模块Fs,如果采用原生实现那肯定就会被问到我为什么还是用Yeoman呢?就算可以说Yeoman封装了前面介绍了哪些基础功能,但如果对Nodejs有所了解的就知道前面的那些功能都有很多轮子可以搭建起来实现。那么第二个解决方案就是在Yeoman官方文档中有提到Yeoman的文件系统是基于mem-fs editor模块实现,当进入mem-fs editor项目文档下可以找到两个方法:写入write和渲染read,基于这两个方法我们就可以实现静态文件的读写操作,详细参考示例代码:

 1 const Generator = require("yeoman-generator");
 2 
 3 module.exports = class  extends Generator {
 4     writing(){
 5          this.fs.write(
 6              this.destinationPath("page/html/index.html"),    //生成写入文件的路径
 7              this.fs.read(this.templatePath("index.html"))    //读取需要写入的文件
 8          );
 9     }
10 }

通过mem-fs editor可以大概推测出我测试的Yeoman应该是使用men-fs editor其更新版本,这才导致在Yeoman的官方文档中的API失效,但不管如何文件的读写操作已经得到解决。通过阅读men-fs editor文档发现了copy这个方法,该方法可以一步实现读写操作,也可以理解为copy封装了write、read方法,下面就使用copy方法实现前面一样的功能:

 1 const Generator = require("yeoman-generator");
 2 
 3 module.exports = class  extends Generator {
 4     writing(){
 5         this.fs.copy(
 6             this.templatePath("index.html"),                    //生成读取文件的路径
 7             this.destinationPath("page/html/index.html")        //生成写入文件的路径            
 8         );
 9     }
10 }

静态模板文件的读写操作实现以后,就是动态模板文件的渲染操了,这在mem-fs editor中也提供了一系列的功能,但在Yeoman官方文档中提到了三种解决方案分别是基于mem-fs editor内部集成的ejs模板语法、基于流传输转换文件(比如集成gulp实现)、基于AST抽象语法树构建文件。

但实际项目开发中,ejs的模板语法方案基本上都可以满足业务需求,而且mem-fs editor内部已经集成这一方案,在Yeoman中不再需要额外的做集成配置,下面就是基于集成ejs模板语法的mem-fs editor实现的模板生成项目文件的示例:

 1 const Generator = require("yeoman-generator");
 2 
 3 module.exports = class  extends Generator {
 4     writing(){
 5         //复制模板生成项目文件
 6         this.fs.copyTpl(
 7             this.templatePath("index.html"),    //模板文件绝对路径
 8             this.destinationPath("page/html/index.html"),   //项目文件绝对路径(要生成的文件或者要覆盖的文件)
 9             { title:'Yeoman-APi-首页' } //替换模板的数据
10         );
11     }
12 }

模板文件就是templatePath下的index.html

 1 DOCTYPE html>
 2 <html lang="en">
 3 <head>
 4     <meta charset="UTF-8">
 5     <meta http-equiv="X-UA-Compatible" content="IE=edge">
 6     <meta name="viewport" content="width=device-width, initial-scale=1.0">
 7     <title><%= title %>title>
 8 head>
 9 <body>
10     
11 body>
12 html>

但是在使用copyTpl时需要注意在我们创建的项目中同样可能出现需要用到ejs模板标记或者类似的模板标记语法,这时候就会导致在进行模板数据替换时报找不到数据来替换模板标记,而实际上这个标记就本来就是不需要替换的,这种情况只需要在这类标记的首个‘%’处使用两个百分号'%%',最后在模板生成文件中这类需要在项目文件中保留的ejs模板标记就会被转换成单个百分号的模板标记,例如实际项目中有下面这种情况:

<link rel="icom" href="<% BASE_URL %>favicon.ico">    

这时候正确的处理就是将这个模板标记按照以下的方式处理:

<link rel="icom" href="<%% BASE_URL %>favicon.ico">

最后关于文件系统还有一个内容就是处理JSON文件,关于处理JSON文件包含写入JSON文件、写入JSON文件数据、读取JSON文件数据,详细参考以下示例:

 1 const Generator = require("yeoman-generator");
 2 const install = require('yeoman-generator/lib/actions/install');
 3 const tools = require("../../tools.js");
 4 
 5 module.exports = class  extends Generator {
 6     writeConfig(){
 7         this.fs.writeJSON(this.templatePath("collectConfig.json"),{"a":"aaa"}); //writeJSON写入JSON时如果没有collectConfig.JSON文件会自动创建这个文件
 8         this.fs.extendJSON(this.templatePath("collectConfig.json"),{"b":"bbb"});//extendJSON会将当前JSON数据拼接在collectConfig.json中
 9         console.log(this.fs.readJSON(this.templatePath("collectConfig.json")));//使用readJSON读取JSON文件
10     }
11 }

Yeoman的文件系统到这里常用的API就介绍了完了,其他详细的内容可以参考mem-fs editor,最后还是提一下删除文件或目录的方法delete在mem-fs editor也有封装,只需要将删除的文件夹或文件绝对路径传入就是了。

 3.2.7存储用户配置:

收集用户配置在手脚架中是基本操作,比如我们在通过prompt交互收集的配置需要讲其存储下以便后续项目开发中使用,在前面的文件系统中有JSON文件读写操作,但同时yeoman也集成了一个更方便的通用配置收集的功能,这个功能就是在你当前创建项目根目录下创建一个yo-rc.json配置文件。这样有一个好处就是当一个项目依赖多个生成器时,在项目中可以通过这个yo-rc.json配置文件共用配置数据,让拓展手脚架功能多个生成器协同工作更方便。例如下面通过默认生成器收集写入配置信息,然后在其他子生成器中使用这个配置:

 1 //手脚架默认生成器
 2 const Generator = require("yeoman-generator");
 3 
 4 module.exports = class  extends Generator {
 5     async prompting(){
 6         this.answers = await this.prompt([
 7             {
 8                 type:'input',
 9                 name:'username',
10                 message:"输入用户名:"
11             }
12         ])
13     }
14     configuring(){
15         for(let key in this.answers){
16             this.config.set(key,this.answers[key]); //如果项目根目录下没有.yo-rc.json文件会自动生成
17         }
18     }
19 }
1 //手脚架子生成器
2 const Generator = require("yeoman-generator");
3 
4 module.exports = class  extends Generator {
5     initializing(){
6         this.log(this.config.get("username"));
7     }
8     
9 }

测试结果:

配置文件.yo-rc.json除了默认生成到项目根目录下,也可以自定生成路径,在Yeoman中存储配置是由生成器原型上的Storage实现,在官方文档中提供了详细的说明,这里就粘贴以下官方的文档说明示例:

 1 new Storage (name opt , fs, configPath, options opt )
 2 参数:
 3 名称    类型    属性    描述
 4 name    细绳    <可选>
 5 新存储的名称(这是一个命名空间)
 6 
 7 fs    mem-fs-编辑器        
 8 一个 mem-fs 编辑器实例
 9 
10 configPath    细绳        
11 用作存储的文件路径。
12 ---------------------------------------------------------------
13 options    目的    <可选>
14 存储选项。
15 
16 特性
17 名称    类型    属性    默认    描述
18 lodashPath    布尔值    <可选>
19 false   默认
20 设置 true 以将 name 视为 lodash 路径。
21 
22 disableCache    布尔值    <可选>
23 false   默认
24 设置为 true 以禁用 json 对象缓存。
25 
26 disableCacheByFile    布尔值    <可选>
27 false   默认
28 设置为 true 以清理每个 fs 更改的缓存。
29 
30 sorted    布尔值    <可选>
31 false   默认
32 设置为 true 以写入排序的 json。
生成自定义路径的配置文件

关于存储配置除了示例中演示的set和get方法,还有丰富的其他功能,下面也是粘贴官方的文档:

 1 方法
 2 this.config.save()
 3 此方法会将配置写入.yo-rc.json文件。如果该文件尚不存在,该save方法将创建它。
 4 
 5 该.yo-rc.json文件还确定项目的根目录。因此,即使您不使用存储来存储任何东西,始终在生成器save内部调用也被认为是最佳实践:app。
 6 
 7 另请注意,save每次set配置选项时都会自动调用该方法。所以你通常不需要显式调用它。
 8 
 9 this.config.set()
10 set要么采用键和关联值,要么采用多个键/值的对象散列。
11 
12 请注意,值必须是 JSON 可序列化的(字符串、数字或非递归对象)。
13 
14 this.config.get()
15 get将String键作为参数并返回关联的值。
16 
17 this.config.getAll()
18 返回完整可用配置的对象。
19 
20 返回的对象是按值传递的,而不是引用。这意味着您仍然需要使用该set方法来更新配置存储。
21 
22 this.config.delete()
23 删除一个键。
24 
25 this.config.defaults()
26 接受选项的哈希值以用作默认值。如果键/值对已经存在,则该值将保持不变。如果缺少一个键,它将被添加。

3.2.8重要但不必须的部分,写完以手脚架后可能你需要测试、调试、集成到其他工具中,这在官方文档中都有示例,这里就粘贴以下文档链接供大家迅速访问参考:

1 yeoman测试文档:
2 https://yeoman.io/authoring/testing.html
3 yeoman调试文档:
4 https://yeoman.io/authoring/debugging.html
5 yeoman集成到其他工具文档:
6 https://yeoman.io/authoring/debugging.html
7 
8 //完整的API文档
9 https://yeoman.github.io/generator/

 四、基于Yeoman实现Vue-cli

 //这部正在奔来的路上,因为关于实现nodejs自定义命令demo需要结合这篇内容,这两三天之内就会将这部分内容补齐。