CodeMod 代码重构/升级必知必会
CodeMod
(Code Modification) 的应用场景非常多,我在过去几年就使用 ‘codemod‘ 升级过多个项目,节省了大量的人力成本:
- 将原生微信小程序转换到 Taro; 后面又从 Taro 2 升级到 Taro 3
- Sonar / Eslint 问题修复。
- 前端多语言自动提取
- …
除此之外,codemod 也可以用在以下场景:
- 框架升级,比如 Next.js 升级、Vue 3 升级
- 语言升级,将废弃的旧语法替换从新语法
- 代码格式化
- API 重构
- 代码检查等等
如果你有这方面的需求,那这篇文章很适合你。
前置知识:你需要对编译原理有基本了解,如果你感到吃力,可以看看我之前写的文章:深入浅出 Babel 上篇:架构和原理 + 实战
编写一个代码升级/重构程序主要涉及以下环节:
这里每个环节都有很多库/方案可以选择,比如:
- 文件查找: 可以使用
Glob
通配符库来查找或忽略文件,比如 node-glob、fast-glob、globby 等 - AST parse: 这个需要根据特定的语言进行选择。比如 JavaScript 可以选择
Babel
(推荐)、Esprima
、Acorn
、swc
;CSS 可以使用postcss
、lightning css
;Vue SFC 可以使用其官方的 vue-template-parser 等等。更多方案,可以探索一下 AST Explorer,这里列举了市面上主流的 Parser - AST Transform: 将 AST 解析出来之后,可以根据自己的需求来改写 AST。不同语言/parser 处理规则会有较大的差异。AST parse 和 transform 可以选择一些工具来简化工作,比如
Jscodeshift
、gogocode
,本文接下来会深入讲解这些工具。 - Code Generate: 将 AST 转换为代码。我们要尽可能地维持原有的代码格式,否则代码 Diff 会很难看。这个阶段可以选择
recast
这类方案,它可以尽量维持代码的原有格式;另一种方案就是使用代码格式化工具,比如prettier
、eslint
,也可以最大限度维持代码的格式。 - 写入代码: 调用 fs 写入。
将这些东西串起来,你可能还需要一些库,帮你快速编写命令行工具,例如 yargs、commander、inquirer.js
接下来我将介绍 codemod 这个领域一些主流的库,这些库都各有所长,有些提供了一整套的流程,有些则提供了更高效的 AST 查找和替换方法。
Recast
recast 是一个知名的库,很多 CodeMod 工具都是基于它来实现的。我们通常将它作为 JavaScript 的 AST 转换器
和非破坏(nondestructive)代码格式化
工具来使用。
简单说就是使用 recast 进行’代码生成‘可以最大程度地保持代码原本的格式。
💡原理: 在解析代码生成 AST 时,Recast 使用其解析器(默认是 Esprima)收集代码的原始格式信息。当你修改 AST 时,Recast 记录了哪些部分的 AST 被修改了。最后在代码生成时,Recast 复用未修改部分的原始代码,然后只为修改过的部分生成新的代码,尽可能地保留原始格式。
它的 API 也非常简单:
import { parse, print } from "recast"; |
核心 API 就两个 parse
和 print
。顾名思义,也不用多介绍了。
recast 默认使用的 Parser 是 Esprima, 也允许用户使用其他的 Parser,比如 Babel、Acorn。
为什么它能兼容不同的 Parser 呢?
兼容不同的 Parser 并不是一件新鲜事,我们在使用 Eslint 时,它也支持自定义 Parser。实际上只要 AST 符合一定的标准就行。
如果深入去挖,会发现 recast 底层就是使用 ast-types 来对 AST 进行表示、查找、操作的。而 ast-types 又是 Mozilla Parser API 规范的实现。
基于 Mozilla Parser API 又发展出了 EsTree 这个社区标准,旨在为 ECMAScript 语法树定义一个更为正式的规范,它会随着 JavaScript 语言的演进,不断发展和扩展,以支持新的 ECMAScript 特性。
如上图,目前大部分 Parser 都是基于 ESTree 标准的。因此理论上它们都支持作为 recast 的 parser。
对开发者来说,选择不同的 parser 主要基于性能、资源消耗、支持的语言特性等多个方面去权衡。
目前普适性比较强的是 Babel,原因在于支持的语言特性很多,比如 Typescript、Flow 以及最新的 ECMAScript 特性,另外它的生态也比较庞大。
为了方便开发者使用,recast 也将 ast-types 的 API 重新导出了:
// 🔴 类型断言 |
Jscodeshift
jscodeshift 是 Meta 开源的 CodeMod 工具,很多前端框架都是基于它来实现代码升级,比如 Nextjs、storybook、react、antd、vue 等,算是能见度最高的 CodeMod 方案了。
一句话来总结 jscodeshift 就是它是一个 CodeMod Runner 和 Recast 的封装:
Runner:负责文件的查找、转换、生成的整个流程,还提供了 CLI 和
单元测试套件
。开发者只需要编写转换逻辑即可:module.exports = function(fileInfo, api, options) {
// transform `fileInfo.source` here
// ...
// return changed source
return source;
};Recast 封装: jscodeshift 内部的 AST parse、transform、generate 都是基于 recast。
在我看来,jscodeshift 比较有趣的是它封装了类似 jQuery 的 AST 查找方法(主要是它的扩展方式、链式调用、集合方法),可以简化 AST 的查找和转换:
// 🔴 recast 原本的查找形式,访问者模式 |
其中核心类是 Collection:
class Collection { |
Collection 的内置方法不过就是一些集合操作,其余的方法都是通过 registerMethods
扩展的:
// 🔴 固定类型方法 |
jscodeshift 内部内置了很多实用的方法,比如 find、closestScope、closest、replaceWith、insertBefore、remove、renameTo 等等。
借助这些方法,可以写出比较优雅的代码(相比visitor 而言):
api.jscodeshift(fileInfo.source) |
这些方法都没有在文档说明,建议读者直接去看源码和它的测试用例。代码并不多,非常适合练手。
Gogocode
国内阿里妈妈开源的 gogocode 用来做 codemod 也是不错的选择,它支持类似通配符
的语法来进行 AST 树查找,比如:
// 1️⃣ 精确查找语句 |
不过你不能真把它当做‘正则表达式
’,否则你照着官方文档吭哧吭哧搞起来,会踩很多坑,比较挫败。别问为什么,亲身经历。
不过,如果你理解了背后的原理,就会豁然开朗,从此就会走上阳光大道。
当你传入一个选择器
时,gogocode 实际上会将选择器
也转换为 AST
, 我们尚且称它为 Selector AST
吧,然后再在源码 AST
中查找和 Selector AST
‘结构吻合’的节点,并收集匹配信息
>
整体过程如下:
- 第一步: 将选择器中的
通配符
替换从特殊字符串
,比如 gogocode 内部就是一个 g123o456g789o, 没有实际的意义,就是为了避免冲突 - 第二步:将选择器解析成 AST,即 Selector AST
- 第三步:在源码 AST 中查找吻合 Selector AST 结构的节点,在匹配的过程中,
$_$
可以匹配任意值; 而$$$
主要用于匹配序列/数组。这些匹配的信息会被反正 match 对象中,类似正则匹配的分组捕获
。
⚠️ gogocode 不会去检查通配符分组是否相等,例如
$_$1 === $_$1
, 你可能期望匹配两侧相等的节点,例如foo === foo
, 但是 gogocode 会匹配到所有的全等表达式,例如1=== 2
,foo() === bar
。
理解这个过程很关键,举一些实际的例子
示例1️⃣:
ast.find('import Vue from "vue"') |
选择器 parse 出来的 Selector AST 为:
接下来, gogocode 首先会通过 recast
的 visit
函数,查找到所有的 ImportDeclaration
节点,然后依次递归匹配节点属性,例如:
- importKind 是否是 value?
- source 是否是字符串 vue?
- specifiers:第一项是否为 ImportDefaultSpecifier, ImportDefaultSpecifier 的 local 是否为 Vue?
- …
示例 2️⃣:
// 假设源代码如下,这是一个序列表达式(SequenceExpression) |
AST 结构如下:
我们想要匹配序列表达式中的所有成员,怎么做呢?
ast.find('($$$)') |
你会发现上面的选择器会将源码的所有标识符
都匹配出来了。因为 ($$$)
最终 parse 识别出来的不是序列表达式
,而是 Identifier
(()
在这里没有实际意义),因此会查找出来所有的标识符。
最终解决办法是:
ast.find('($_$, $$$)') |
这个选择器 parse 出来就是 SequenceExpression 节点啦。
示例 3️⃣
再举一个比较反直觉的例子,假设我们想要通过 ast.find('function $_$() {}')
查找所有函数定义:
function a() {} |
猜一下会匹配到哪些函数?
答案是:
function a() {} // ✅ |
为什么?
Ok,通过上面的讲解,你应该知道 gogocode 选择器的能力边界了。也就是说选择器必须也是合法的 JavaScript 代码,并且它只能进行简单的结构匹配。
另外,gogocode 的 find 方法也支持直接传入 AST 对象结构来匹配查找,如果你不想使用上面的字符串形式的选择器,或者处在歧义时,可以试试它:
const importer = script.find({ |
因为 gogocode 底层就是 Babel 和 Recast, 如果你需要处理更复杂的场景,可以直接使用它们提供的 visit 或 traverse 等方法。
gogocode 还提供了很多便利的 API, 还支持 Vue,可以直接去看它的文档。
不过文档比较一般,整个使用的过程中并不舒畅,而且遗憾的是目前开发也不活跃了。🙏
AST Grep
如果你比较喜欢 gogocode 这种通配符
查找/替换的语法,那就不得不给你安利一下 ast-grep:
$ sg --pattern '$PROP && $PROP()' --lang ts TypeScript/src # path to TS source |
ast-grep 可以认为是 grep 命令的升级版,支持多种主流的编程语言,支持对代码进行查找、Lint、和重写。查找语法和上文介绍的 gogocode 差不多,通配符规则更加严谨,文档也写得很棒👍。
ast-grep 足矣满足大部分简单的代码替换工作,比如取代 VsCode、WebStorm 这些编辑器的代码查找/替换功能。
复杂的代码升级/重构,涉及到的查找规则会比较多,可能还有副作用处理(比如注入import 语句),还是老老实实用前面介绍的方案吧。
总结
其实到最后比拼的是谁能更优雅、更快捷地进行 AST 查找和转换,如上图的金字塔所示,上层的方案需要写的代码更少。如果你有更复杂的需求,也可以回退到底层 Parser 提供的 visit 访问器。
以下是一些横向对比:
定位/亮点 | Parser | 查找/转换 | 代码生成 | |
---|---|---|---|---|
Babel | 通用的 Javascript 编译器。主要用于转译最新的(包括实验性的) JavaScript 语言特性,并且支持 Typescript、Flow、JSX 等非标准语法 | @babel/parser | 基于 visit 访问器模式。 | @babel/generator。无法保证原代码格式 |
recast | 非破坏性的代码生成 | 默认 https://esprima.org/, 也支持 Babel 等 estree 标准的 AST | 使用 ast-types 的 visit 方法,也是访问器模式。查找和转换的过程和 Babel 类似 | 可以保留原有代码格式 |
jscodeshift | codemod runner、recast wrapper。 | 基于 recast | 类 jquery 方法,可扩展 | 基于 recast |
gogocode | codemod runner、recast wrapper、AST 模式匹配 | 基于 recast,默认使用 Babel;另外还支持 Vue、html | 类 jquery 方法,支持模式匹配 | 基于 recast |
ast-grep | AST 模式匹配和替换;rust 高性能; | tree-sitter, 支持多种语言 | 模式匹配 |