前端质量平台如何利用 AST 实现代码扫码

本篇文章主要通过一个简单物料检测例子来介绍一下我们团队前端代码质量平台,如何做代码扫码和分析,希望对大家有一定的参考价值

一、AST(抽象语法树)

In computer science, an abstract syntax tree (AST), or just syntax tree, is a tree representation of the abstract syntactic structure of source code written in a programming language. - 维基百科
Javascript 的语法是为了给开发者更好的编程而设计的,但是不适合程序的理解。浏览器编译器一般会把源码转化为 AST 来进行进一步的分析等其他操作,一个程序在执行之前会经历三个步骤统称为编译:

  • 分词/词法分析: 将由字符组成的字符串分解成有意义的代码块
  • 解析/语法分析: 词法单元流转换成一个由元素嵌套所组成的代表了程序语法结构的抽象语法树(abstract syntax code,AST)
  • 代码生成: 将 AST 转换成可执行代码的过程被称为代码生成
    抽象语法树(abstract syntax code,AST)是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构.

1.1 AST 用途

babel、eslint、prettier 等工具无一例外的应用了 AST 树,而树的遍历就深度优先和广度优先两种,在这只能是深度优先。

二、JavaScript 解析器

js 解析器是代码分析扫描工具的基础,没有它就很难工作,开篇我们先来介绍下目前有哪些常用的解析器。

2.1 ESTree

ESTree 的初衷通过社区的力量,保证和 es 规范的一致性,通过自定义的语法结构来表述 JavaScript 的 AST,后来随着知名度越来越高,多位知名工程师的参与,使得变成了事实意义上的规范,目前这个库是 Mozilla 和社区一起维护的。因为 EsTree 定义的规范,所以现在所有的 js 解析器或者编译器,基本上都绕不开它。

2.2 Esprima

这是第一个用 JavaScript 编写的符合 EsTree 规范的 JavaScript 的解析器,后续多个编译器都是受它的影响。

2.3 acorn

acorn 和 Esprima 很类似,输出的 ast 都是符合 EsTree 规范的,目前 webpack 的 AST 解析器用的就是 acorn,和 Esprima 一样,也是也不直接支持 JSX

2.4 @babel/parser

babel 官方的解析器,最初 fork 于 acorn,后来完全走向了自己的道路,其构建的插件体系非常强大,提供了一套完善的 visitor 插件机制用于扩展,通过编写 babel 插件来操作 ast 非常的方便。

2.5 espree

eslint、prettier 的默认解析器,最初 fork 于 Esprima 的一个分支,后来因为 ES6 的快速发展,但 Esprima 短时间内又不支持,后面就基于 acorn 开发。

2.6 swc

用的 rust 编写的 js 编译器,单核比 babel 快 4 倍,4 核比 babel 快 70 倍,也可以用来打包 js、ts 代码,并且也拥有 tree shaking 功能,目标就是替换 babel,比如 Next.js 11.1 就用 SWC 替代 Babel 和 Terser。

2.7 esbuild

esbuild 是用 go 编写的下一代 web 打包工具,它拥有目前最快的打包记录和压缩记录,snowpack 和 vite 的也是使用它来做打包工具,为了追求卓越的性能,目前没有将 AST 进行暴露,也无法修改 AST,无法用作解析对应的 JavaScript。

三、代码扫码平台的技术实现

3.1 @babel/parser

前面提到 @babel/parser 提供了一套完善的插件机制用于扩展,通过编写 babel 插件来操作 ast 非常的方便。

1
2
3
4
5
6
7
8
9
10
constparser = require("@babel/parser");
consttraverse = require("@babel/traverse").default;
// 编写自定义规则插件
constvisitor = {};
// 源代码
constcode = `const str = "hello world";`;
// code -> ast
constast = parser.parse(code);
// 分析代码
traverse(ast, visitor);

我们可以通过编写 visitor ,当然直接使用这些 API 的场景倒不多,项目中经常用到的,是各种 Babel 插件,接下俩我展示是如何快速开发一个插件

3.1.1 编写一个简单的插件

我们把 foo === bar; >> sebmck === bar; 分析 AST 结构:

1
2
3
4
5
6
7
8
9
10
11
12
{
"type": "BinaryExpression",
"operator": "===",
"left": {
"type": "Identifier",
"name": "foo"
},
"right": {
"type": "Identifier",
"name": "bar"
}
}

我们从添加 BinaryExpression 访问者方法开始,只关注哪些使用了 === 的 BinaryExpression 用新的标识符来替换 left/right 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
exportdefaultfunction({ types: t }) {
return {
visitor: {
// Visitor 中的每个函数接收2个参数:path 和 state,
BinaryExpression(path) {
if (path.node.operator !== "===") {
return;
}
path.node.left = t.identifier("sebmck");
path.node.right = t.identifier("dork");
}
}
};
}

这样一个简单转换插件就完成了,具体详见 Babel 插件手册, babel 插件的编写主要涉及 vistors、paths、scope 和 binding 几个能力。

3.2 eslint rule

前面我介绍了基于 babel 操作 ast 的示例,Babel 不但完成了 AST 的解析工作,它还提供了一套完善的 visitor 插件机制用于我们操作 ast,业内评价 Babel is the new jQuery。但是我们平台希望能够扫描脚本更加方便使用也就是希望能在开发结算就能发现问题,我们开发阶段和 eslint 形影不离,完全可以采用开发自定义 eslint rules 的形式对代码分析,我们通过自定义的开发 eslint rules 来实现代码分析需求,同时也满足可以在项目里配置当前检测规则。

3.2.1 如何编写 rule

一条 rule 就是一个 node 模块,其主要由 meta 和 create 两部分组成,其中:

  • meta 代表了这条规则的元数据,如其类别,文档,可接收的参数的 schema 等等
  • create:如果说 meta 表达了我们想做什么,那么 create 则用表达了这条 rule 具体会怎么分析代码

3.2.2 eslint rule 规则模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
name: "规则名称",
meta: {
type: "规则类型,如suggestion",
docs: {
description: "规则描述",
category: "规则分类:如Possible Errors",
recommended: true,
url: "说明规则的文档地址,如https://eslint.org/docs/rules/no-extra-semi",
},
fixable: "是否可以修复,如code",
schema: [],
},
create: function (context) {
// 事件回调
return {};
},
};

使用方式就是在事件回调函数中使用 context 中获取的 AST 等信息进行分析。

  • 事件回调函数
  • 选择器 selector 通过 AST selectors 我们可以方便的找到静态代码中的内容
  • 访问器 visitor
  • context 它是一个树对象
  • context.report 这个方法用来向用户报告错误信息
    更多详见 Working with Rules 官方文档写的非常详细。
    我们拿个实际生产中的例子,统计我们物料市场的物料 @finance/searchTable 在项目中引用的次数,

3.2.3 编写检测规则

规则实现 use-material-num.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
importdocsUrlfrom'../docsUrl';
// 定义规则名称
varRULE_NAME = 'use-material-num';
module.exports = {
name:RULE_NAME,
meta: {
// 规则类型
type:'suggestion',
docs: {
// 说明规则的文档地址
url:docsUrl(RULE_NAME),
},
// 是否可以修复,如code
fixable:null,
messages: {
useMaterialNum:"{{ value }} is used in the project",
},
},
create(context) {
constmaterialArr = ['@finance/searchTable'];
// 事件回调
return {
Literal:functionhandleRequires(node) {
if (node.parent && node.parent.type === 'ImportDeclaration' && materialArr.indexOf(node.value) !== -1) {
context.report({
node:node,
messageId:'useMaterialNum',
data: {
value:node.value,
},
});
},
},
};
},
};

规则测试 test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
varrule = require("./use-material-num");
varRuleTester = require("eslint").RuleTester;
varruleTester = newRuleTester();
ruleTester.run("use-material-num", rule, {
valid: [
{
code: "import SearchTable from '@finance/searchTable'",
},
],
invalid: [
{
code: "import SearchTable from '@finance/searchTable'",
output: "import SearchTable from '@finance/searchTable'",
errors: [
{
message: "'@finance/searchTable' is used in the project",
},
],
},
],
});

我们直接调用 ruleTester 的 run 函数就可以完成扫码了,看起来是不是很简单。

3.2.4 实现过程分析

目标是在代码中找到 import SearchTable from '@finance/searchTable'

1 确定代码 AST 树形结构

我们可以利用在线 astexplorer

2 分析树编写规则

ast
根据上面的 code vs AST 关系图可以发现 type'ImportDeclaration' 的文本就代表这引入,同时 node.value 代表当前节点的值,通过值的对比我们就可以知道当前的是不是物料市场里的组件,当然具体生产环境还会有 import A,{ SearchTable }from '@finance/searchTable' 等场景这时候就需要分析 specifiers 里的类型是 ImportDefaultSpecifier 或者 ImportSpecifier。根据上面分析就很容易理解下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
constmaterialArr = ['@finance/searchTable'];
return {
Literal:functionhandleRequires(node) {
if (node.parent && node.parent.type === 'ImportDeclaration' && materialArr.indexOf(node.value) !== -1) {
context.report({
node:node,
messageId:'useMaterialNum',
data: {
value:node.value,
},
});
},
},
};
3 Eslint Api 检测

通过 Eslint Api 执行我们的规则够就很容易检测出物料的使用,如下示例:
物料

3.3 更换 ESLint 的 AST 解析器

ESLint 也支持使用第三方 AST 解析器,比如我们可以用 @babel/eslint-parser 来替换 espree

1
2
3
module.exports = {
parser: "@babel/eslint-parser",
};

四、总结

本文先简单介绍 AST 和用途,并介绍了几款业内常见的解析器,然后围绕如何代码扫码分析介绍两种方式 babel 和 eslint 的实现,通过前端质量平台的应用情况来看针对一些通用型的问题,利用 AST 代码扫描能够很容易检测并发现问题,AST 能力十分强大,学习 AST 相关可以帮助我们在实际的开发过程中能够更容易的解决一些问题。

参考