在学习脚手架之前,首先得先了解脚手架是的什么
对于的学习脚手架,先从一个脑图开始

什么是脚手架

是为了保证各施工过程顺利进行而搭设的工作平台。这便是脚手架。
而前端脚手架是指通过选择几个选项快速搭建项目基础代码的工具,可以有效避免重复的代码框架和基础配置。

脚手架分类

一、代码块脚手架

一般指我们在编译器中使用的代码片段,前端使用 vscode 编译器时,部分插件已经提供了代码片段的能力,根据关键字以及输入的代码行,匹配响应的代码片段,常见的开发插件有,ES6 react/redux/react-native 、vue language feature 之类的插件,当然开发者也可自己配置符合自己习惯的代码片段。代码片段相关配置可以参考vscode 代码片段配置
当然我们这里可以简单介绍一下配置流程

设置用户代码片段

打开设置点开用户代码片段
Alt text
选择编辑或者新建代码片段
Alt text
编辑代码片段

1
2
3
4
5
6
7
8
9
10
11
12
{
"Print to console": {
"prefix": "vue", // 代码片段前缀
"body": [ // 代码片段输出
"<template>",
" <div class=\"${TM_FILENAME_BASE}_container\">\n",
" </div>",
"</template>\n",
"<script>",
],
}
}

保存完即可启用

相关变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
TM_LINE_INDEX:行号(从零开始);

TM_LINE_NUMBER:行号(从一开始);

TM_FILENAME:当前文档的文件名;

TM_FILENAME_BASE:当前文档的文件名(不含后缀名);

TM_DIRECTORY:当前文档所在目录;

TM_FILEPATH:当前文档的完整文件路径;

CLIPBOARD:当前剪贴板中内容。

CURRENT_YEAR: 当前年份;

CURRENT_YEAR_SHORT: 当前年份的后两位;

CURRENT_MONTH: 格式化为两位数字的当前月份,如 02;

CURRENT_MONTH_NAME: 当前月份的全称,如 July;

CURRENT_MONTH_NAME_SHORT: 当前月份的简称,如 Jul;

CURRENT_DATE: 当天月份第几天;

CURRENT_DAY_NAME: 当天周几,如 Monday;

CURRENT_DAY_NAME_SHORT: 当天周几的简称,如 Mon;

CURRENT_HOUR: 当前小时(24 小时制);

CURRENT_MINUTE: 当前分钟;

CURRENT_SECOND: 当前秒数。

二、单文件脚手架

为了更贴近于实际开发,这里我们模仿vue-cli等脚手架工具搭建脚手架项目时的做法,做一个简单的单文件类型脚手架的搭建demo(多文件和项目类型的脚手架原理也一样)。
在使用vue-cli等脚手架工具搭建脚手架项目时,它会先询问一些问题,而后这些脚手架会根据这些问题的答案并结合它自带的项目模板创建出一个脚手架项目。

模板文件

template.txt,这里简化一下模板内容

1
<%= param1 %>

其中模板中的动态部分就是param1参数
而我们的需求就是实现的一个脚本,这个脚本在运行之后能够根据模板内容创建得到一个单文件脚手架,并且其中模板中的param1参数需要替换成我们的输入参数。

编写脚本文件

思路

  • 输入:脚手架模板、用户输入的模板参数
  • 输出:目标单文件类型脚手架
  • 映射关系:模板解析、IO读写等操作

由上得出结果 coding result cli.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const inquirer = require('inquirer'); // 用于与命令行交互
const fs = require('fs');
const path = require('path');
const ejs = require('ejs'); // 用于解析ejs模板
const { Transform } = require('stream'); // 用于流式传输

inquirer.prompt([{
type: 'input',
name: 'name',
message: 'file name?'
},
{
type: 'input',
name: 'param1',
message: 'param1?'
}
])
.then(anwsers => {
// 1.根据用户输入:得到文件名和文件夹路径(用户命令路径)
const fileName = anwsers.name;
const param1 = anwsers.param1;
const dirPath = process.cwd();
// 2.得到模板文件路径
const tmplPath = path.join(__dirname, 'template.txt');
const filePath = path.join(dirPath, fileName + '.txt');
// 3.读取模板文件内容,写入到新创建的文件
const read = fs.createReadStream(tmplPath);
const write = fs.createWriteStream(filePath);
// 转换流:用于ejs模板解析
const transformStream = new Transform({
transform: (chunk, encoding, callback) => {
const input = chunk.toString();// 模板内容
const output = ejs.render(input, {param1}); // 模板解析
callback(null, output);
}
})
read.pipe(transformStream).pipe(write);
})

执行脚本

1
2
3
4
5
6
7
8
c:\workspace\automate node cli.js
? project Name? result
? param1? hello word

//结果

hello word

三、多文件(组件)脚手架

在我们碰到需要搭建一个单 / 多文件类型的脚手架需求,比如搭建一个 vue 组件脚手架(需要创建一个.vue 模板文件、一个.scss 模板文件以及一个.test.js 模板文件),Plop 就会是一个不错的助手。
相对于完全自定义实现脚手架脚本 / 工具而言,使用 Plop 虽然不能实现完全的自定义,但在它的规范下编写脚本可以调用它封装好的 Api,可以实现配置化的脚手架工具。这让我们能够更加关注任务本身,而无需关注过多任务的实现细节,进而提高开发效率。
这里我们简单介绍一下 plop 使用

插件使用第一步 install

1
yarn add plop --dev

编写模板

1
2
3
4
5
6
7
8
9
10
11
12
<template>,
<div class=\${name}_container\>
</div>
</template
<script>
export default{
name:'${name}',
data:()=>{
return
}
}
</script>

编写plop文件生成脚本

先在项目根目录下创建一个plopfile.js作为plop执行任务时的入口文件。而后在这个入口文件中编写注册我们希望它做的任务,就可以实现以配置化方式实现创建脚手架的功能。
接下来我们编写一个plop自动化创建任务,方便我们创建react组件时调用,它会根据预定义好的模板内容自动创建一个.vue 模板文件、一个.scss 模板文件以及一个.test.js 模板内容文件,实现代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
module.exports = plop => {
// generator是Plop的任务单位,component是任务名
plop.setGenerator('component', {
description: 'create a component',
// 接收用户输入
prompts: [
{
type: 'input',
name: 'name',
message: 'component name',
default: 'MyComponent'
}
],
// 任务行为
actions: [
{
type: 'add', // 代表添加文件
path: 'src/components/{{name}}/{{name}}.vue',// 文件输出位置
templateFile: 'plop-templates/component.hbs'// 模板文件位置
},
{
type: 'add',
path: 'src/components/{{name}}/{{name}}.scss',
templateFile: 'plop-templates/component.css.hbs'
},
{
type: 'add',
path: 'src/components/{{name}}/{{name}}.test.js',
templateFile: 'plop-templates/component.test.hbs'
}
]
})
}

使用Plop执行Plop任务

1
yarn plop component

我们就可以通过Plop工具而不是原生脚本去实现搭建一个vue组件脚手架的需求

四、项目脚手架

在日常开发中,我们会根据经验沉淀出一些项目模板,在不同在项目中可以进行复用。如果是每次都是通过拷贝代码到新项目的话,这样会比较麻烦,而且容易出错,此时我们就会想能不能将一些模板集成到脚手架(类似vue-cli, create-react-app)中,这样我们进行初始化创建就能使用了

在创建项目级脚手架的之前,先要了解几个必备的知识

  • commander 自定义命令行指令
  • inquirer 在命令行中实现与用户交互
  • fs-extra fs扩展,用于读写文件
  • chalk 终端美化输出
  • figlet 可以在终端输出logo
  • ora 控制台的loading动画
  • download-git-repo 下载远程模板

创建脚手架

初始化项目文件 npm init && git init
创建指令文件

1
2
3
├─ bin
│ ├─ index.js
└─ package.json

指令文件注册

1
2
3
4
5
6
7
8
9
{
"name": "demo-cli",
"version": "1.0.0",
"main": "index.js",
"bin": {
"demo-cli": "./bin/index.js"
},
...
}

注意
修改入口文件index.js为如下内容,文件以#!开头代表这个文件被当做一个执行文件来执行,可以当做脚本运行。后面的/usr/bin/env node代表这个文件用node执行

1
2
3
#!/usr/bin/env node

console.log('hello demo-cli')

注册命令

1
npm link

执行命令

1
2
3
demo-cli

hello demo-cli // 命令创建成功

到这儿我们的脚手架已经算是创建成功了,只是还不具备生成项目模板的功能,这就需要我们自己去扩展

编写脚手架脚本

在编写脚本之前我们先简单介绍一些关于inquirer的指令

  • type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor;
  • name: 存储当前问题回答的变量;
  • message:问题的描述;
  • default:默认值;
  • choices:列表选项,在某些 type 下可用,并且包含一个分隔符(separator);
  • validate:对用户的回答进行校验;
  • filter:对用户的回答进行过滤处理,返回处理后的值;
  • when:根据前面问题的回答,判断当前问题是否需要被回答;
  • prefix:修改 message 默认前缀;
  • suffix:修改 message 默认后缀。

首先在入口文件处获取与用户之前的交互信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

const program = require('commander')
const create = require('../lib/create')

program
.version('0.1.0')
.command('create <name>')
.description('create a new project')
.action(name => {
console.log(name)
})

program.parse()

ex:
demo-cli creat newProject
newProject

此时已经完成一个用户交互的闭环,下面开始编写/lib/create完成一个下载gitlab模板的脚手架

大部分公司的gitlab都属于私有化部署,这里就不介绍gitlab 令牌生成规则以及相关api的功能了

create逻辑编写

  1. 获取用户create命令参数
  2. 判断当前目录是否有重名文件
  3. 获取组织下仓库模板列表
  4. 用户根据分支或者标签选择模板
  5. 根据选择结果获取资源列表
  6. 选定资源模板后进行下载
  7. 在当前目录下生成新项目

综上可得

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
const axios = require("axios"); // 接口请求封装
const path = require("path");
const ora = require("ora"); // 加载
const fs = require("fs");
const Inquirer = require("inquirer"); // 交互式命令行。
const { promisify } = require("util");
const MetalSmith = require("metalsmith"); // 遍历文件夹 找需不需要渲染
let { render } = require("consolidate").ejs;
render = promisify(render);

const { token, url, clonePre, downloadDirectory } = require("../const");

let downLoadGit = require("download-git-repo");
downLoadGit = promisify(downLoadGit);
let ncp = require("ncp");
ncp = promisify(ncp);

class Creator {
// 1. 获取用户create命令参数
constructor(projectName) {
this.projectName = projectName;
}

async create() {
// 2. 判断当前目录是否有重名文件
const currentPath = path.resolve(this.projectName);

const continued = await this.checkFileExistAndIfOverwrite(currentPath);
if (!continued) {
console.log("Nothing happened, bye");
return;
}

// 3. 获取组织下仓库模板列表
const repos = await this.wrapFetchAddLoding(
this.fetchRepoList,
"fetching repo list"
)();

// 用户选择需要下载的模板仓库
const templates = repos
.map((item) => ({
name: item.name,
value: `${item.name}_${item.id}`,
}))
.filter((item) => item.name.includes("template"));

const { template } = await Inquirer.prompt({
name: "template",
type: "list",
message: "please choose template to create project",
choices: templates, // 选择模式
});

let [templateName, templateId] = template.split("_");

// 4. 用户根据分支或者标签选择模板
const { type } = await Inquirer.prompt({
name: "type",
type: "list",
message: "Which resource do you want, branches or tags",
choices: [
{
name: "branches",
value: "branches",
},
{
name: "tags",
value: "tags",
},
], // 选择模式
});

// 5. 根据选择结果获取资源列表
let resources = await this.wrapFetchAddLoding(
this.fetchRepoResourceList,
`fetching ${type} list`
)(templateId, type);

// 可能是tag列表,或者是分支列表
resources = resources.map((item) => item.name);

const { resource } = await Inquirer.prompt({
name: "resource",
type: "list",
message: "please choose template to create project",
choices: resources, // 选择模式
});

// 6. 选定资源模板后进行下载
const result = await this.download(templateName, resource);

console.log("downloaded in\n", result);

// 7. 在当前目录下生成新项目
try {
await this.renderTemplate(result, currentPath);
console.log(`Your project has been created successfully in ${currentPath}`);
} catch (e) {
console.log("error", e);
}
}
// 完成。

// 以下是封装的函数
// 获取仓库列表
async fetchRepoList() {
const { data } = await axios.get(url, {
headers: {
"PRIVATE-TOKEN": token,
},
});
return data;
}

// type: tags | branches
async fetchRepoResourceList(id, type) {
const { data } = await axios.get(`${url}/${id}/repository/${type}/`, {
headers: {
"PRIVATE-TOKEN": token,
},
});
return data;
}

// 下载项目
async copyToMyProject(currentPath) {
await ncp(target, currentPath);
}

// 判断当前文件夹下是否有重名文件,如果有则咨询是否覆盖,覆盖则删掉原有的目录
async checkFileExistAndIfOverwrite(dest) {
if (fs.existsSync(dest)) {
const { action } = await Inquirer.prompt([
{
name: "action",
type: "list",
message: "File exists, do you want to overwrite or cancel?",
choices: [
{
name: "overwrite",
value: "overwrite",
},
{
name: "cancel",
value: false,
},
],
},
]);
if (action === "overwrite") {
console.log("Removing the file...");
fs.rmSync(dest, { recursive: true });
return true;
} else {
return false;
}
}
return true;
}

// 用克隆的方式从gitlab下载项目
async download(template, resource) {
let api = `${clonePre}/${template}${resource ? "#" + resource : ""}`;
const dest = `${downloadDirectory}/${template}#${resource}`; // 将模板下载到对应的目录中

// 判断是否已经下载过该模板
const continued = await this.checkFileExistAndIfOverwrite(dest);

// 如果没有删除,则停止后续下载操作。
if (!continued) return dest;

await this.wrapFetchAddLoding(downLoadGit, "Downloading the template")(
api,
dest,
{
clone: true,
}
);

return dest; // 返回下载目录
}

// 7.在当前目录生成新项目
async renderTemplate(result, currentPath) {
await this.wrapFetchAddLoding(copyToMyProject, currentPath);
}

// 对于promise函数,在开始时候开启loading提示,对用户更友好
wrapFetchAddLoding(fn, message) {
return async (...args) => {
const spinner = ora(message);
spinner.start(); // 开始loading
let r;
try {
r = await fn(...args);
spinner.succeed(); // 结束loading
} catch (e) {
spinner.fail(); // 结束loading
}
return r;
};
}
}

module.exports = async (projectName) => {
const creator = new Creator(projectName);
creator.create();
};

以上逻辑其实用到了一些常量,让我们一起来看
const/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { token, url, clonePre } = require("./private");

// 存放用户的所需要的常量
const { version, name } = require("../../package.json");

// 存储模板的位置, for macOS
const downloadDirectory = `${
process.env[process.platform === "darwin" ? "HOME" : "USERPROFILE"]
}/.template`;

module.exports = {
version,
downloadDirectory,
cliName: name,
// private
token,
url,
clonePre,
};

private.js

1
2
3
4
5
6
7
8
9
10
11
12
// for templates group
// 你创建的 GitLab 的访问令牌(Access Token)
const token = "";

// 你的公司gitlab地址
const url = "";

// 选择clone with ssh
// 那么clonePre就为项目名称前面的内容
const clonePre = "";
module.exports = { token, url, clonePre };

__END__