6-1、工程化实战之前端脚手架

工程化定义

狭义上:基于研发流程的,包含:分支管理、开发环境、单元测试/自动化测试、部署(CI/CD)、等
广义上:开始写代码到发布、运维、bugfix、安全生产等,广义上就是很大的领域。

AST

Abstract Syntax Tree:抽象语法树,是源代码的抽象语法结构的树状表示。
常见的 JS AST,效果如下:

前端构建的基石就是它(Webpack、Babel、Eslint、Prettier),从 Webpack 来说,它会去加载对应的模块(*.js),然后解析为 AST,最后再转为处理过的 JS(压缩/Tree-shake 等)
所以我们可以在这个过程中,基于 AST 去改动某点,这样最后生成的代码也就会有我们想要的“功能”。

转换为 AST 的过程为:解析(词法、语法) -> 转换(特定转换规则) -> 生成(对应语言代码字符串)
1、获取到源代码,通过词法分析,分成一个个“单词”(token),一个 JSON 结构,特性:无语法信息,无法体现代码执行顺序

2、基于 token 进行语法分析,将其转换为 AST,这是一个具有“语法”的树结构,每一层有相同的字段
3、基于语法分析所得的 AST,进行转换(二次加工),基于自定义的转换规则对节点进行增删改查等操作
4、最后基于转换后的 AST,生成对应语言的代码字符串

脚手架开发

定义:快速、自动化的搭建、启动项目的工具
使用:通过命令行就能创建基于模板的项目

vue-cli 的脚手架流程是:1、收集用户选项;2、去 github 拉取官方配好基础的模板;3、最后通过【选项+基础模板】生成完整的可用项目;最难的就是第 3 点

本次我们实现的脚手架是:收集用户选项,去 github 拉取我们自己的模板,然后下载即可。

所需依赖

脚手架开发常用的依赖有:

  • path:提供了处理文件和目录路径的实用工具,比如路径的解析、组合和规范化等。
  • chalk:一个流行的 Node.js 包,用于在终端输出彩色文本,有助于美化命令行输出,提升用户体验。
  • fs-extra:是对 Node.js 内置文件系统(fs)模块的扩展,提供了更方便、更强大的文件和目录操作功能,如复制、移动、删除目录及其内容等。
  • inquirer:一个命令行用户界面库,常用于创建交互式的命令行问答程序,帮助开发者在初始化项目时收集用户输入的信息。
  • commander.js:另一个命令行接口(CLI)工具库,它简化了命令行选项、子命令和参数的解析过程,便于构建复杂的命令行工具。
  • axios:是一个基于 Promise 的 HTTP 客户端,用于在 Node.js 环境中执行 HTTP 请求,这对于脚手架在初始化项目时从远程获取资源
  • download-git-repo:专门用于从 GitHub 或其他 Git 仓库下载项目的库,这对于脚手架根据用户选择的模板快速拉取项目源码非常便捷。
  • ora: 用于在命令行中显示动画状态图标(spinner),在执行耗时较长的任务时,可以给用户提供正在运行中的反馈,提高用户体验。
  1. 创建项目文件夹,命名自取
1
mkdir xxxxxx
  1. 初始化,使用 npm/yarn/pnpm 初始化
1
cd hzq-cli && pnpm init
  1. 安装对应依赖,带特定版本号的是因为高版本不支持cjs
1
pnpm add path chalk@4 fs-extra inquirer@^8 commander axios download-git-repo ora@^5

处理工程入口

  1. 新建入口文件bin/hzqCli.js,命名自取
1
mkdir bin && touch bin/hzqCli.js
  1. 更改package.jsonmainbin/hzqCli.js

  1. 更改package.jsonbinbin/hzqCli.jsbin属性用于指定项目中包含的可执行脚本,并将其暴露为全局命令,全局安装此包后,就可在命令行直接运行hzqCli命令)

  1. bin/hzqCli.js初始化编码
1
2
3
4
5
#! /usr/bin/env node

// 上述为 Node.js 脚本文件的行首注释,告知使用 node 来解析和执行后续的脚本内容

console.log("hello hzqCli");
  1. 本地开发时,可以通过运行npm link,可以实现全局安装的效果,这样可以本地调试与测试(只需要一次即可,后面该代码后不需要重复执行哦)
1
npm link
  1. 命令行运行hzqCli,可以发现不会报错,并打印hello hzqCli

功能开发

进入bin/hzqCli.js,开始正式编码了,为了更容易理解编码过程,代码将采用分段形式来展示

bin/hzqCli.js编码:基础命令 create 的基本逻辑

  1. bin/hzqCli.js编码(一)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#! /usr/bin/env node

// 上述为 Node.js 脚本文件的行首注释,告知使用 node 来解析和执行后续的脚本内容

console.log("hello hzqCli");

// 引入 commander 模块,官方使用文档:https://github.com/tj/commander.js/blob/HEAD/Readme_zh-CN.md
const { program } = require("commander");

// 定义命令与参数,类似 hzqCli init、hzqCli list 等等
// create 的命令
program
.command("create <projectName>")
.description("create a new project")
.option("-f --force", "overwrite existed project") // 定义选项,同时可以附加选项的简介,短名称(-后面接单个字符)和长名称(--后面接一个或多个单词,空格分隔
.action((projectName, options) => {
console.log("create project: ", projectName);
console.log("options: ", options);
});

// 解析用户输入的命令和参数,第一个参数是要解析的字符串数组,第二个参数是解析选项
program.parse(process.argv); // 指明,按 node 约定
  1. 命令行运行hzqCli create xx -f、hzqCli create 112,可以看到如下结果

lib/create.js编码:基础命令 create 调用的实际方法

  1. 新建具体执行代码文件
1
mkdir lib && touch lib/create.js
  1. bin/hzqCli.js编码(二),引入lib/create.js

  1. lib/create.js编码(一):项目路径处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建的流程:
// 1、 判断项目是否存在(可以单独写个方法 checkFileExist)

const path = require("path"); // 路径处理
const fs = require("fs-extra"); // 文件操作

function checkFileExist(path) {}
module.exports = async function (projectName, options) {
// 1、判断项目是否存在

// 1.1 获取当前项目的完整路径:当前命令行的路径 + 项目名称
const projectPath = path.join(process.cwd(), projectName);
console.log(
"%c [ projectPath ]-11-「create.js」",
"font-size:13px; background:#9ad82a; color:#deff6e;",
projectPath
);
};
  1. 编码时,随时可运行命令hzqCli create 112进行调试哦,看来我们的处理获取是正确的

  1. lib/create.js编码(二):检查路径是否存在
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 创建的流程:
// 1、 判断项目是否存在(可以单独写个方法 checkFileExist)

const path = require("path"); // 路径处理
const fs = require("fs-extra"); // 文件操作

function checkFileExist(path) {
return fs.existsSync(path);
}
module.exports = async function (projectName, options) {
// 1、判断项目是否存在

// 1.1 获取当前项目的完整路径:当前命令行的路径 + 项目名称
const projectPath = path.join(process.cwd(), projectName);
console.log(
"%c [ projectPath ]-11-「create.js」",
"font-size:13px; background:#9ad82a; color:#deff6e;",
projectPath
);
const isExits = await checkFileExist(projectPath);
console.log("[ isExits ] >", isExits);
};
  1. 运行调试

  1. lib/create.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
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
// 创建的流程:
// 1、 判断项目是否存在(可以单独写个方法 checkFileExist)

const path = require("path"); // 路径处理
const fs = require("fs-extra"); // 文件操作
const inquirer = require("inquirer"); // 命令行交互

/**
* 异步检查文件是否存在
*
* @param path 文件路径
* @returns 返回一个布尔值,表示文件是否存在
*/
async function checkFileExist(path) {
return await fs.existsSync(path);
}

/**
* 异步删除文件
*
* @param path 文件路径
* @returns 返回删除操作的结果
*/
async function removeFile(path) {
return await fs.removeSync(path);
}

module.exports = async function (projectName, options) {
// 1、判断项目是否存在 -- start

// 1.1 获取当前项目的完整路径:当前命令行的路径 + 项目名称
const projectPath = path.join(process.cwd(), projectName);

// 1.2 判断项目是否存在
const isExits = await checkFileExist(projectPath);

if (isExits) {
// 1.2.1 项目已经存在
// 再判断是否需要强制创建
if (options.force) {
// 1.2.1.1 强制创建:则删除已存在项目,继续走创建流程
await removeFile(projectPath);
} else {
// 1.2.1.2 不强制创建:则询问用户是否确认覆盖
const answer = await inquirer.prompt([
{
name: "choosedForce",
type: "list",
message: `请选择是否覆盖已存在的 ${projectName} 文件?`,
choices: [
{ name: "是(选择后将删除文件)", value: true },
{ name: "否(选择后将退出流程)", value: false },
],
},
]);

if (answer.choosedForce) {
// 1.2.1.2.1 是:则删除已存在项目,继续走创建流程
await removeFile(projectPath);
} else {
// 1.2.1.2.2 否:则退出
return;
}
}
}

// 2、创建项目流程 -- todo
};

lib/generator.js编码:创建项目流程

  1. 创建对应文件,并初始化代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
touch lib/generator.js

// 写如下代码:
module.exports = class Generator {
constructor(projectName, projectPath) {
this.projectName = projectName;
this.projectPath = projectPath;
}

async create() {
// 创建文件
console.log("[ create todo ] >", this.projectName, this.projectPath);
}
};
  1. lib/create.js 引入Generator,最末尾加上:
1
2
3
4
5
6
7
8
9
10
11
// .....

const Generator = require("./generator"); // ++++++

module.exports = async function (projectName, options) {
// .....

// 2、创建项目流程 -- start
const generator = new Generator(projectName, projectPath); // ++++++
await generator.create(); // 创建项目 // ++++++
}
  1. 运行命令hzqCli create 123

  1. lib/generator.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
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
const util = require("util"); // 工具库
const DownloadGitRepo = require("download-git-repo"); // 下载 git 仓库
const { getRepoList, getTagList, OWNER } = require("./https");
const inquirer = require("inquirer"); // 命令行交互
const { spinner } = require("./log");

// 将 DownloadGitRepo promise 化,因为它本身不是 promise 风格的
const downloadGitRepo = util.promisify(DownloadGitRepo);

// 封装一个 loading 函数,方便使用
const createLoading = async (fn, msg, ...fnArgs) => {
spinner.start(msg);

try {
let result;

if (typeof fn === "function") result = await fn(...fnArgs);

spinner.succeed();
return result;
} catch (error) {
spinner.fail(`【${msg}】error: ` + error.message);
spinner.fail(`【${msg}】failed, please try again later.`);
}
};

module.exports = class Generator {
constructor(projectName, projectFullPath) {
this.projectName = projectName;
this.projectFullPath = projectFullPath;
}

// 核心创建流程
async create() {
// 创建的流程是:
// 1、用户已选择的模板名称
const repoName = await this.getRepo();

if (repoName) {
// 2、用户已选择的模板版本
const tag = await this.getTag(repoName);

// 3、下载模板到项目内
await this.download(repoName, tag);
}
}

async getRepo() {
// 1、从远端拉取可选择的模板数据(使用 ora 加 loading)
// 2、让用户选择模板
// 3、提供给用户选择,并得到已选的模板

// 1、
const repoList = await createLoading(getRepoList, "Loading templates...");
if (!repoList?.length) return;

// 2、
const chooseTemplateList = repoList.filter((item) => item.name);

// 3、
const promptName = "choosedTemplateName";
const answer = await inquirer.prompt([
{
name: promptName,
type: "list",
message: `请选择对应模板`,
choices: chooseTemplateList,
},
]);

return answer[promptName];
}
async getTag(repoName) {
const tagList = await createLoading(
getTagList,
"Loading versions...",
repoName
);

if (!tagList?.length) return "";

return tagList[0];
}

// 下载 github 仓库
async download(repoName, tag) {
const repoUrl = `${OWNER}/${repoName}${tag ? "#" + tag : ""}`;

await createLoading(
downloadGitRepo,
"download template...",
repoUrl,
this.projectFullPath
);
}
};
  1. 新建请求工具
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
touch lib/https.js

// 写如下代码:
const axios = require("axios");

const BASEURL = "https://api.github.com";
const OWNER = "hzq-fe-template";

axios.defaults.baseURL = BASEURL;

axios.defaults.headers.common["User-Agent"] = "myTestApp"; // 不加这个会报 403 错误

axios.interceptors.response.use((res) => res.data); // 处理 github api 返回的数据

// 通过调用 github API 来获取模板列表
async function getRepoList() {
// 从 orgs/hzq-fe-template/repos 获取模板列表
// 具体 github 地址为:https://github.com/orgs/hzq-fe-template/repositories
return axios.get(`/orgs/${OWNER}/repos`);
}

async function getTagList(repoName) {
// 从 repos/hzq-fe-template/${repo}/tags 获取模板列表
return axios.get(`/repos/${OWNER}/${repoName}/tags`);
}

module.exports = {
OWNER,
getRepoList,
getTagList,
};
  1. 新建打印工具
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
touch lib/log.js

// 写如下代码:
const ora = require("ora");
const chalk = require("chalk");

const log = {
successText: function (msg) {
return chalk.green.bold(`${msg}`);
},
success: function (msg) {
console.log(this.successText(msg));
},
errorText: function (msg) {
return chalk.red(`${msg}`);
},
error: function (msg) {
console.log(this.errorText(msg));
},
};

// 创建一个spinner实例:初始为【青色并加粗】
const spinner = ora();

module.exports = {
log,
spinner: {
start(text = "") {
spinner.start(text);
},
succeed(text = "") {
spinner.succeed(log.successText(text));
},
fail(text = "") {
spinner.fail(log.errorText(text));
},
},
};
  1. 运行命令,就可以正确下载了


异常情况

由于 github API 自身的限制:超过速率限制 后会 403,所以不是很容易的成功……

总结

  1. 讲述了前端工程化可做的事情:可从研发流程切入
  2. 讲述了 AST 的基础概念
  3. 通过手写实现了一个脚手架

脚手架代码地址:https://github.com/MrHzq/scaffold-actual-combat


6-1、工程化实战之前端脚手架
https://mrhzq.github.io/职业上一二事/前端面试/前端八股文/6-1、工程化实战之前端脚手架/
作者
黄智强
发布于
2024年1月13日
许可协议