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, 支持多种语言 模式匹配

扩展阅读

相关推荐
小远yyds13 分钟前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
阿伟来咯~1 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端1 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱1 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai1 小时前
uniapp
前端·javascript·vue.js·uni-app
也无晴也无风雨1 小时前
在JS中, 0 == [0] 吗
开发语言·javascript
bysking2 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓3 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4113 小时前
无网络安装ionic和运行
前端·npm
理想不理想v3 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试