开发基于 AST 的前端工具

最近在遇到了两个小需求:

  1. 要给一个已开发完的项目进行国际化, 希望可以遍历获取代码中注释之外的中文文本
  2. 一个较为复杂的项目希望在编译阶段根据代码中的特殊格式语句控制代码执行

这两个需求本质上比较接近: 都是对代码本身进行分析与处理. 而对于代码这种文本的处理, 用正则就太麻烦了. 不仅要考虑上下文的关系, 还要括号配对的行为. 将代码转换成抽象语法树再进行处理则要方便的多.

按照维基百科的定义:

在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。 树上的每个节点都表示源代码中的一种结构。

简单说来 AST 就是将整个代码作为一个根节点, 其中的每一个表达式作为一个子节点, 表达式内部, 又是由若干表达式与字符, 操作符作为子节点, 一点点细分直到所有单独的文本或操作符作为叶子节点… (怀念大学操作系统前几章的介绍状态机解析程序语言的部分)

生成 AST 的过程我们不需要手工完成, 像是 acorn, babylon 等许多模块都可以实现代码文本向 AST 的转换. 本文就是基于 babylon 实现的. 大部分的 AST 解析器功能和思路都比较接近, 我选择 babylon 的原因是 babylon 直接支持 JSX 语法的转译, 对于我们 React 的项目来说省去了很多麻烦.

babylon

babylonbabel 内的 JavaScript 语法分析器. 作为一个功能独立的库, 也可以在 babel 之外调用, 例如:

1
2
3
4
5
6
7
8
9
function parse (text, filePath) {
try {
return babylon.parse(text, {
plugins: ['jsx', 'classProperties'],
})
} catch (e) {
throw new Error(`Parse error: in ${filePath}`, e)
}
}

babylon.parse(code, [options]) 会将代码字符串转译成 AST. 参数中的 pluginsbabylon 内置的一些 ECMA 标准外的其他常用语法的支持. 我的工具打算运行在 React 项目上, jsx 插件可以解析 jsx 语法, 以及 classProperties插件提供一些 ES6 标准之外的语法支持.

babylon-walk

babylon 也提供了一个 babylon-walk 的工具来遍历语法树.

1
2
3
4
5
6
7
const walk = require('babylon-walk');
const visitors = {
StringLiteral: func,
JSXText: func,
}
const state = {}
walk.simple(node, visitors, state);

node是待遍历的 ast 节点, 如果需要遍历整个代码只需传递根节点就好

visitors 是一个 nodeType 与回调函数的对应表. 遍历时如果一个节点的类型存在对应的 visitors, babylon-walk 就会调用对应的回调函数, 并传递 nodestate

state 是一个普通的 object, 或是其他任何东西. 他的作用是在遍历途中保存一些需要的东西, 而不需要在 walk 的作用域外创建一个变量

babylon-walk 除上面用到的simple, 一共支持三种遍历模式:

  1. sample: 最普通的遍历
  2. ancestor: 回调函数中会附加所有遍历的先代节点信息
  3. recursive: 遍历时根据回调函数的返回值选择继续遍历的子节点

例子: 搜索中文字符串

有了 babylon 支持, 从代码中搜索所有中文字符就很简单了.

首先, 先使用 glob 之类的工具从代码目录遍历出所有 .js.jsx 的文件

1
2
3
4
5
6
glob('**/*.{js,jsx}', {
cwd: cwd,
ignore,
}, (err, files) => {
//
});

读取每一个文件, 进行多字节文本的检测, 由于我们希望知道匹配的内容是出自哪一个文件, 检测的过程还需要保留文件路径

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
function parse (text, filePath) {
try {
return babylon.parse(text, {
plugins: ['jsx', 'classProperties'],
})
} catch (e) {
throw new Error(`Parse error: in ${filePath}`, e)
}
}

function matchChinese() {
return function(node, errors) {
if (node.value.match(/[^\x00-\xff]/g)) {
errors.push(node);
}
}
}

const ast = parse(text, filePath);
const visitors = {
StringLiteral: matchChinese,
JSXText: matchChinese,
}
const errors = [];
walk.simple(ast, visitors, errors);

每一个 node 节点的内容如下:

1
2
3
4
5
6
7
8
9
Node {
type: 'JSXText',
start: 1270,
end: 1280,
loc:
SourceLocation {
start: Position { line: 33, column: 64 },
end: Position { line: 33, column: 65 } },
value: 'something' }

从中可以取得代码在文本中的位置, 以及源码中对应的行, 列.

最后只需要格式化输出结果,返回文件地址, 对应的行, 列和值即可.

另外有了 startend, 我们也可以直接用国际化后的 i18n('xxx') 替换掉原文件中对应的中文字符串, 我们期望用靠谱一点的英文作为国际化后的索引名, 所以并没有这么做.

@TODO: 这里还可以封装成一个 eslint 插件, 避免疏忽提交的汉字.

完整的代码见: https://github.com/ekoneko/chinese-seeker