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(推荐)、EsprimaAcornswc;CSS 可以使用 postcsslightning css;Vue SFC 可以使用其官方的 vue-template-parser 等等。更多方案,可以探索一下 AST Explorer,这里列举了市面上主流的 Parser
  • AST Transform : 将 AST 解析出来之后,可以根据自己的需求来改写 AST。不同语言/parser 处理规则会有较大的差异。AST parse 和 transform 可以选择一些工具来简化工作,比如 Jscodeshiftgogocode,本文接下来会深入讲解这些工具。
  • Code Generate : 将 AST 转换为代码。**我们要尽可能地维持原有的代码格式,否则代码 Diff 会很难看。**这个阶段可以选择 recast 这类方案,它可以尽量维持代码的原有格式;另一种方案就是使用代码格式化工具,比如 prettiereslint,也可以最大限度维持代码的格式。
  • 写入代码: 调用 fs 写入。

将这些东西串起来,你可能还需要一些库,帮你快速编写命令行工具,例如 yargs、commander、inquirer.js

接下来我将介绍 codemod 这个领域一些主流的库,这些库都各有所长,有些提供了一整套的流程,有些则提供了更高效的 AST 查找和替换方法。

Recast

recast 是一个知名的库,很多 CodeMod 工具都是基于它来实现的。我们通常将它作为 JavaScript 的 AST 转换器非破坏(nondestructive)代码格式化工具来使用。

简单说就是使用 recast 进行'代码生成'可以最大程度地保持代码原本的格式

💡原理: 在解析代码生成 AST 时,Recast 使用其解析器(默认是 Esprima)收集代码的原始格式信息。当你修改 AST 时,Recast 记录了哪些部分的 AST 被修改了。最后在代码生成时,Recast 复用未修改部分的原始代码,然后只为修改过的部分生成新的代码,尽可能地保留原始格式。

它的 API 也非常简单:

jsx 复制代码
import { parse, print } from "recast";
console.log(print(parse(source)).code);

核心 API 就两个 parseprint。顾名思义,也不用多介绍了。

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 重新导出了:

jsx 复制代码
// 🔴 类型断言
const n = recast.types.namedTypes;
n.FunctionDeclaration.assert(add);

// 🔴 AST 节点构造器
const b = recast.types.builders;
ast.program.body[0] = b.variableDeclaration("var", [
  b.variableDeclarator(add.id, b.functionExpression(
    null, // Anonymize the function expression.
    add.params,
    add.body
  ))
]);

// 🔴 AST 访问器
recast.types.visit(ast, {
  // This method will be called for any node with .type "MemberExpression":
  visitMemberExpression(path) {
    // Visitor methods receive a single argument, a NodePath object
    // wrapping the node of interest.
    var node = path.node;

    if (
      n.Identifier.check(node.object) &&
      node.object.name === "arguments" &&
      n.Identifier.check(node.property)
    ) {
      assert.notStrictEqual(node.property.name, "callee");
    }

    // It's your responsibility to call this.traverse with some
    // NodePath object (usually the one passed into the visitor
    // method) before the visitor method returns, or return false to
    // indicate that the traversal need not continue any further down
    // this subtree.
    this.traverse(path);
  }
});

Jscodeshift

jscodeshift 是 Meta 开源的 CodeMod 工具,很多前端框架都是基于它来实现代码升级,比如 Nextjs、storybook、react、antd、vue 等,算是能见度最高的 CodeMod 方案了。

一句话来总结 jscodeshift 就是它是一个 CodeMod Runner 和 Recast 的封装

  • Runner:负责文件的查找、转换、生成的整个流程,还提供了 CLI 和单元测试套件。开发者只需要编写转换逻辑即可:

    jsx 复制代码
    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 的查找和转换:

jsx 复制代码
// 🔴 recast 原本的查找形式,访问者模式
var ast = recast.parse(src);
recast.visit(ast, {
  visitIdentifier: function(path) {
    // do something with path
    return false;
  }
});

// 🔴 jscodeshift,类似 jquery 支持链式调用
jscodeshift(src)
  .find(jscodeshift.Identifier)
  .forEach(function(path) {
    // do something with path
  });

其中核心类是 Collection:

jsx 复制代码
class Collection {

  /**
   * @param {Array} paths An array of AST paths
   * @param {Collection} parent A parent collection
   * @param {Array} types An array of types all the paths in the collection
   *  have in common. If not passed, it will be inferred from the paths.
   * @return {Collection}
   */
  constructor(paths, parent, types) {
    this._parent = parent;
    this.__paths = paths;
    this._types = types.length === 0 ? _defaultType : types;
  }

  filter(callback) {
    return new this.constructor(this.__paths.filter(callback), this);
  }

  forEach(callback) {
    this.__paths.forEach(
      (path, i, paths) => callback.call(path, path, i, paths)
    );
    return this;
  }

  some(callback) {}
  every(callback) {}
  map(callback, type) {}
  size() {}
  nodes() {}
  paths() }
  getAST() {  }
  toSource(options) {}
  at(index) {}
  get() {}
}

Collection 的内置方法不过就是一些集合操作,其余的方法都是通过 registerMethods 扩展的:

jsx 复制代码
// 🔴 固定类型方法
jscodeshift.registerMethods({
  logNames: function() {
    return this.forEach(function(path) {
      console.log(path.node.name);
    });
  }
}, jscodeshift.Identifier);

// 🔴 任意类型方法
jscodeshift.registerMethods({
  findIdentifiers: function() {
    return this.find(jscodeshift.Identifier);
  }
});

jscodeshift(ast).findIdentifiers().logNames();
jscodeshift(ast).logNames(); // error, unless `ast` only consists of Identifier nodes

jscodeshift 内部内置了很多实用的方法,比如 find、closestScope、closest、replaceWith、insertBefore、remove、renameTo 等等。

借助这些方法,可以写出比较优雅的代码(相比visitor 而言):

jsx 复制代码
api.jscodeshift(fileInfo.source)
    .findVariableDeclarators('foo')
    .renameTo('bar')
    .toSource();

这些方法都没有在文档说明,建议读者直接去看源码和它的测试用例。代码并不多,非常适合练手。

Gogocode

国内阿里妈妈开源的 gogocode 用来做 codemod 也是不错的选择,它支持类似通配符的语法来进行 AST 树查找,比如:

jsx 复制代码
// 1️⃣ 精确查找语句
ast.find('const a = 123');
ast.find('import vue from "vue"')

// 2️⃣ 支持通配符
ast.find('const a = $_$')
ast.find(`function $_$() {}`)
ast.find('sum($_$0, $_$1)')

// 3️⃣ 多项匹配
ast.find('console.log($$$0)')
ast.find('{ text: $_$1, value: $_$2, $$$0 }')

不过你不能真把它当做'正则表达式',否则你照着官方文档吭哧吭哧搞起来,会踩很多坑,比较挫败。别问为什么,亲身经历。

不过,如果你理解了背后的原理,就会豁然开朗,从此就会走上阳光大道。

当你传入一个选择器时,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️⃣:

jsx 复制代码
ast.find('import Vue from "vue"')

选择器 parse 出来的 Selector AST 为:

接下来, gogocode 首先会通过 recastvisit 函数,查找到所有的 ImportDeclaration 节点,然后依次递归匹配节点属性,例如:

  • importKind 是否是 value?
  • source 是否是字符串 vue?
  • specifiers:第一项是否为 ImportDefaultSpecifier, ImportDefaultSpecifier 的 local 是否为 Vue?
  • ...

示例 2️⃣:

jsx 复制代码
// 假设源代码如下,这是一个序列表达式(SequenceExpression)
(a, b, c);

AST 结构如下:

我们想要匹配序列表达式中的所有成员,怎么做呢?

jsx 复制代码
ast.find('($$$)')

你会发现上面的选择器会将源码的所有标识符都匹配出来了。因为 ($$$) 最终 parse 识别出来的不是序列表达式,而是 Identifier(() 在这里没有实际意义),因此会查找出来所有的标识符。

最终解决办法是:

jsx 复制代码
ast.find('($_$, $$$)')

这个选择器 parse 出来就是 SequenceExpression 节点啦。

示例 3️⃣

再举一个比较反直觉的例子,假设我们想要通过 ast.find('function $_$() {}') 查找所有函数定义:

jsx 复制代码
function a() {}
function b() {}
(function c() {});
(function () {});

猜一下会匹配到哪些函数?

答案是:

jsx 复制代码
function a() {} // ✅
function b() {} // ✅
(function c() {}); // ❌
(function () {}); // ❌

为什么?


Ok,通过上面的讲解,你应该知道 gogocode 选择器的能力边界了。也就是说选择器必须也是合法的 JavaScript 代码,并且它只能进行简单的结构匹配

另外,gogocode 的 find 方法也支持直接传入 AST 对象结构来匹配查找,如果你不想使用上面的字符串形式的选择器,或者处在歧义时,可以试试它:

jsx 复制代码
const importer = script.find({
    type: 'ImportDeclaration',
    source: {
      type: 'StringLiteral',
      value: '@wakeadmin/i18n',
    },
  });

因为 gogocode 底层就是 Babel 和 Recast, 如果你需要处理更复杂的场景,可以直接使用它们提供的 visit 或 traverse 等方法。

gogocode 还提供了很多便利的 API, 还支持 Vue,可以直接去看它的文档。

不过文档比较一般,整个使用的过程中并不舒畅,而且遗憾的是目前开发也不活跃了。🙏

AST Grep

如果你比较喜欢 gogocode 这种通配符查找/替换的语法,那就不得不给你安利一下 ast-grep

bash 复制代码
$ sg --pattern '$PROP && $PROP()' --lang ts TypeScript/src # path to TS source
$ sg --pattern 'var code = $PAT' --rewrite 'let code = $PAT' --lang js

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 非破坏性的代码生成 默认 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, 支持多种语言 模式匹配

扩展阅读

相关推荐
鹧鸪yy7 分钟前
认识Node.js及其与 Nginx 前端项目区别
前端·nginx·node.js
跟橙姐学代码7 分钟前
学Python必须迈过的一道坎:类和对象到底是什么鬼?
前端·python
汪子熙9 分钟前
浏览器里出现 .angular/cache/19.2.6/abap_test/vite/deps 路径究竟说明了什么
前端·javascript·面试
Benzenene!11 分钟前
让Chrome信任自签名证书
前端·chrome
yangholmes888811 分钟前
如何在 web 应用中使用 GDAL (二)
前端·webassembly
jacy13 分钟前
图片大图预览就该这样做
前端
林太白15 分钟前
Nuxt3 功能篇
前端·javascript·后端
YuJie16 分钟前
webSocket Manager
前端·javascript
Mapmost31 分钟前
Mapmost SDK for UE5 内核升级,三维场景渲染效果飙升!
前端
Mapmost34 分钟前
重磅升级丨Mapmost全面兼容3DTiles 1.1,3DGS量测精度跃升至亚米级!
前端·vue.js·three.js