前端进阶:来自 2022 年的代码转换教程

过去一年,我们新鲜开源的代码转换工具 GoGoCode 获得了社区不少朋友的喜爱和支持,在这样一个小众的领域获得了 2.7k 个 star 以及热情的用户反馈,让我们感到「代码转换」有着比我们想象的广泛得多的需求,只不过以前它总是作为一种潜藏的技术不为人所知。

于是在 2022 年,我们花了一些时间编写一份从零开始的、现代的代码转换教程,试图用一个个鲜活的例子来践行 GoGoCode 的 slogan:代码转换从未如此简单。

篇幅有点长,例子有点多,请做好准备。

一次代码转换的基本流程

CodMod

上图概述了一次代码转换的四个流程,我们接下来的教程也会按照这四步依次进行:

  1. 把代码解析成抽象语法树(AST)
  2. 找到我们要改动的代码
  3. 把它修改成我们想要的样子
  4. 把它再生成回字符串形式的代码

通过 GoGoCode 读取并解析代码

首先我们安装并引入 GoGoCode

npm install gogocode --save
复制代码
import $ from 'gogocode';
// or for commonjs
const $ = require('gogocode');
复制代码

我们借用 jQuery 的 $ 命名让代码写起来更简单!

使用 GoGoCode 解析不同类型的代码:

// source 为待解析代码的字符串

// 解析 JavaScript/TypScript 文件
const ast = $(source);

// 解析 html 文件需要在传入的 parseOptions 中指定 language
const ast = $(source, { parseOptions: { language: 'html' } });

// 解析 Vue 文件
const ast = $(source, { parseOptions: { language: 'vue' } });
复制代码

Tips:本教程中的代码片段你可以在 GoGoCode PlayGround 中立刻尝试一下!

playground

如图所示的下拉框中可以切换代码类型,右侧会提供对应的样板代码。

通过代码选择器选择代码

在把代码从字符串解析成 AST 后,我们进入第二步,从一整段代码中精确查找到我们要修改的 AST 节点。

ast.find 代码选择器

与其它代码转换工具通过 AST 类型去匹配语法树节点不同,GoGoCode 提供了更直观的「用代码找代码」的方式,和 jQuery 查找 DOM 一样,你只需要编写一段代码片段作为「代码选择器」,GoGoCode 就能智能地帮你匹配到源码中和它吻合的片段。

假设你想在下面代码中挑选出名为 log 的函数:

function log(a) {
  console.log(a);
}

function alert(a) {
  alert(a);
}
复制代码

只需要按照如下方式使用 find 方法即可:

const ast = $(source);
const test1 = ast.find('function log() {}');
复制代码

GoGoCode 会根据 function log() {} 自动去帮你匹配名为 logfunction 节点,返回能满足匹配条件的子节点。

用 generate 把节点输出成代码字符串

只要对找到 AST 节点调用 .generate,就可以得到这个节点对应的代码字符串。

const ast = $(source);
const test1 = ast.find('function log() {}');

const code = test1.generate()
// code 是如下字符串:
// function log(a) {
//   console.log(a);
// }
复制代码

Playgroud 在线演示

$_$ 通配符

假设你想在下面代码中挑选出对于变量a的声明和初始化语句:

const a = 123;
复制代码

按照之前介绍的,我们只要像下面这么写就可以了:

const aDef = ast.find('const a = 123');
复制代码

但这只能匹配到 const a = 123,对于 const a = 456 就无能为力了,在实际的代码匹配中,我们往往不确定代码的全貌,这时候 GoGoCode 支持使用通配符来做模糊匹配:

const aDef = ast.find('const a = $_$0');
复制代码

$_$0 替代原来的 123 能帮你匹配到对 const a 做初始化的所有语句:

// 以下每一种都能被匹配到
const a = 123;
const a = b;
const a = () => 1;
// ……
复制代码

$_$0 位置的节点可以通过查询结果的 match 属性获取:

const aDef = ast.find('const a = $_$');
const match = aDef.match;
复制代码

如下图所示, match 是一个字典结构,$_$ 后面的数字即为 match 的索引,通过 match[0] 就能取出 $_$0 位置匹配到的 AST 集合。

这个集合只有一个元素,对应着 const a = 123 中的 123,你可以通过node拿到它对应的原始 AST 节点,也可以通过 value 直接拿到这个节点在代码中的片段。

match结构

Tip: 多使用 debugger 查看一下中间结果是编写代码转换的不二法门

Playgroud 在线演示

集合操作

回过头来看这个例子:

function log(a) {
  console.log(a);
}

function alert(a) {
  alert(a);
}
复制代码

如果使用通配符,我们能匹配到所有名字的函数定义,所以 .find 查询的结果可能是一个集合

// fns 是一个结果集,包含了所有名称的函数定义
const fns = ast.find(`function $_$0() {}`);
复制代码

这个结果集合 fns 拥有和 ast 为同一类型,拥有完全相同的成员方法,如果集合里有多个元素,对其直接使用方法,将只对第一个 AST 节点生效。

我们提供 each 方法来遍历这个结果集合,下面的例子把 match 到的函数名收集到了名为 names 的数组里:

const fns = ast.find(`function $_$0() {}`);
const names = [];
fns.each((fnNode) => {
  const fnName = fnNode.match[0][0].value;
  names.push(fnName);
});
复制代码

Playgroud 在线演示

使用多个通配符

有时我们不止需要一个通配符,你可以在代码选择器中书写 $_$0$_$1$_$2$_$3……达到你的目的。

比如你想匹配下面函数的两个参数:

sum(a, b);
复制代码
const sumFn = ast.find('sum($_$0, $_$1)');
const match = sumFn.match;
console.log(`${match[0][0].value},${match[1][0].value}`); // a,b
复制代码

Playgroud 在线演示

匹配多个同类节点

前面我们学习了使用 $_$ 通配符来做模糊的查询,假设有下面的代码:

console.log(a);

console.log(a, b);

console.log(a, b, c);
复制代码

它们的参数列表长度不一致,我们分别用以下几种选择选择器进行查找结果会如何呢?

ast.find(`console.log()`);
ast.find(`console.log($_$0)`);
// 上面两条语句会找到全部三行代码

ast.find(`console.log($_$0, $_$1)`);
// 这条语句会找到前两行代码

ast.find(`console.log($_$0, $_$1, $_$2)`);
// 这条语句只会找到第三行代码
复制代码

可以看出 GoGoCode 的通配符匹配的原则:写得越多,查询限制越大。

如果你想匹配任意数量的同类型节点,GoGoCode 提供了 $$$ 形式的通配符,对于上面不定参数的语句,你可以统一使用 ast.find('console.log($$$0)') 来匹配。

比起 ast.find('console.log()') ,使用 $$$ 可以通过 match 属性捕获占位符里的所有同类节点。例如用它去匹配 console.log(a, b, c)

const res = ast.find('console.log($$$0)');
const params = res.match['$$$0'];
const paramNames = params.map((p) => p.name);
// paramNames: ['a', 'b', 'c']
复制代码

和之前一样,我们可以从 match 里面拿到通配符 $$$0 匹配到的节点数组 params,这个数组里的元素分别对应了 abc 的 AST 节点:

match

Playgroud 在线演示

除了匹配不定长的参数,$$$ 还有很多能发挥作用的地方:

匹配名为 dict 字典的所有 key 和 value 并打印

const dict = {
  a: 1,
  b: 2,
  c: 'f',
};
复制代码
const res = ast.find('const dict = { $$$0 }');
const kvs = res.match['$$$0'];
kvs.map((kv) => `${kv.key.name}:${kv.value.value}`);
// a:1,b:2,c:f
复制代码

Playgroud 在线演示

ast.has 判断代码是否存在

我们通过 .has 可以判断某段代码是否存在于源码中,例如:

if (ast.has(`import $_$0 from 'react'`)) {
  console.log('has React!');
}
复制代码

可以判断这段代码是否导入了 React 包,其实它等价于:

if (ast.find(`import $_$0 from 'react'`).length) {
  console.log('has React!');
}
复制代码

也就是判断是否有查找到至少一个匹配的语句。

替换代码

通过上面的教程,相信你已经了解到如何根据代码选择器和通配符找到代码里的特定语句了,接下来我们进入到第三步,把找到的语句改成我们想要的样子。

万能的 replace

日常我们在编辑器中批量修改代码的时候也会经常使用到「查找\替换」的功能去做一些基本操作,但它们都基于字符串或正则表达式,对于不同的缩进、换行乃至加不加分号都无法兼容,而利用 GoGoCode 的代码选择器特性配合 replace 方法,可以让你以接近字符串替换的形式完成 AST 级别的代码替换操作。

函数更名

回想我们的第一个例子:

function log(a) {
  console.log(a);
}

function alert(a) {
  alert(a);
}
复制代码

如果我们想给 log 函数改名成 record,用 replace 做非常简单:

ast.replace('function log($$$0) { $$$1 }', 'function record($$$0) { $$$1 }');
复制代码

Playgroud 在线演示

replace 接受两个参数,第一个参数是代码选择器,第二个参数是我们要替换成的样子,我们使用了 $$$0 来匹配参数列表,$$$1 来匹配函数体内的语句,在第二参里把他们放回原有的位置,就可以保证唯一有变动的是函数的名字。

枚举列表属性更名

我们经常使用这样的枚举列表:

const list = [
  {
    text: 'A策略',
    value: 1,
    tips: 'Atip',
  },
  {
    text: 'B策略',
    value: 2,
    tips: 'Btip',
  },
  {
    text: 'C策略',
    value: 3,
    tips: 'Ctip',
  },
];
复制代码

有一天为了统一代码里的各种枚举,我们需要把 text 属性更名为 name,把 value 属性更名为 id,这个用正则很难精确匹配容易误伤,用 GoGoCode 只需要这么替换一下就行了:

ast.replace(
  '{ text: $_$1, value: $_$2, $$$0 }',
  '{ name: $_$1, id: $_$2, $$$0 }',
);
复制代码

其中 $_$1$_$2 匹配了名称的 value 节点,$$$ 则可以匹配剩下的节点,有点像 es6 里的 ... ,这段代码匹配出了 textvalue 这对应的值填给了 nameid,剩下的原封不动放回去。

Playgroud 在线演示

JSX 标签属性替换

再举一个更为复杂些的例子,对一份代码做这样的修改:

  • 从 @alifd/next 导入改成 antd
  • 转译前 改成 转译后
  • Button 中 type 参数转换:normal -> default,medium -> middle
  • Button 中有 text 参数的改成 type="link"
  • Button 中 warning 参数的改成 danger
import * as React from 'react';
import * as styles from './index.module.scss';
import { Button } from '@alifd/next';

const Btn = () => {
  return (
    <div>
      <h2>转译前</h2>
      <div>
        <Button type="normal">Normal</Button>
        <Button type="primary">Prirmary</Button>
        <Button type="secondary">Secondary</Button>

        <Button type="normal" text>
          Normal
        </Button>
        <Button type="primary" text>
          Primary
        </Button>
        <Button type="secondary" text>
          Secondary
        </Button>

        <Button type="normal" warning>
          Normal
        </Button>
      </div>
    </div>
  );
};

export default Btn;
复制代码
ast
  .replace(`import { $$$0 } from "@alifd/next"`, `import { $$$0 } from "antd"`)
  .replace(`<h2>转译前</h2>`, `<h2>转译后</h2>`)
  .replace(
    `<Button type="normal" $$$0></Button>`,
    `<Button type="default" $$$0></Button>`,
  )
  .replace(
    `<Button size="medium" $$$0></Button>`,
    `<Button size="middle" $$$0></Button>`,
  )
  .replace(`<Button text $$$0></Button>`, `<Button type="link" $$$0></Button>`)
  .replace(`<Button warning $$$0></Button>`, `<Button danger $$$0></Button>`);
复制代码

Playgroud 在线演示

用函数进行更复杂的替换

如果在替换中需要更大的自由度,也可以给第二参传入和一个函数,它将接收到 match 字典作为参数,并返回一段新的代码用来替换匹配到的代码。

假如我们有如下的常量定义:

const stock_code_a = 'BABA';
const stock_code_b = 'JD';
const stock_code_c = 'TME';
复制代码

想把它们的变量名批量改成大写的字符串:

ast.replace(`const $_$0 = $_$1`, (match, node) => {
  const name = match[0][0].value;
  const value = match[1][0].raw;
  return `const ${name.toUpperCase()} = ${value}`;
});
复制代码

Playgroud 在线演示

replaceBy

除了使用 .replace 替换代码,你也可以在 .find 查找到对应语句后直接通过 .replaceBy 把这条语句替换掉,例如我们想把下面 log 函数内的 console.log(a) 改写成 alert(a) 而不误伤下面的语句:

function log(a) {
  console.log(a);
}

console.log(a);
复制代码

可以先通过 .find 链式查找到函数体内的 console.log(a) 再通过 .replaceBy 替换掉

const console = ast.find('function log($_$0) {}').find('console.log($_$0)');

console.replaceBy('alert(a)');
复制代码

Playgroud 在线演示

插入代码

学习到这里,我们可以来尝试着解决一个复杂一点的代码转换问题了!

下面这是一段 React 文档的代码:

class Toggle extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isToggleOn: true };

    // This binding is necessary to make `this` work in the callback
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState((prevState) => ({
      isToggleOn: !prevState.isToggleOn,
    }));
  }

  render() {
    return (
      <button onClick={this.handleClick}>
        {this.state.isToggleOn ? 'ON' : 'OFF'}
      </button>
    );
  }
}
复制代码

文档告诉我们对于 React 事件的回调函数需要在 constructor 里特殊绑定 this,我们接下来把绑定语句this.handleClick = this.handleClick.bind(this);删掉,考虑编写一段转换逻辑,利用 GoGoCode 自动识别 JSX 中 onClick 的回调函数并在 constructor 里帮我们把这个绑定语句补上。

巧用 replace 插入代码

万能的 .replace 并不只能做单纯的替换,合理利用 $$$ 捕获和填充原有的内容,在后面补上你想插入的语句即可实现插入代码的操作,下面是详细的操作步骤:

const ast = $(source);

// 找到 reactClass 定义的语句
const reactClass = ast.find('class $_$0 extends React.Component {}');

// 找到 jsx 里面带有 onClick 属性的标签
const onClick = reactClass.find('<$_$0 onClick={$_$1}></$_$0>');

// 创建一个数组用来收集 onClick 对应的 hanlder 的名称
const clickFnNames = [];

// 有可能找到很多个带有 onClick 的标签,我们这里用 each 去处理每一条
onClick.each((e) => {
  // 用 match[1][0] 来找到 $_$1 匹配到的第一个 onClick 属性对应的 handler 节点
  // 取 value 即为节点名
  // handlerName = 'this.handleClick'
  const handlerName = e.match[1][0].value;
  clickFnNames.push(handlerName);
});

// 替换原有的 constructor,但利用 $$$ 保留原有的参数和语句,只是在最后补上 bind 语句即可
reactClass.replace(
  'constructor($$$0) { $$$1 }',
  `constructor($$$0) { 
    $$$1;
    ${clickFnNames.map((name) => `${name} = ${name}.bind(this)`).join(';')}
  }`,
);
复制代码

Playgroud 在线演示

用 append, prepend 给函数添两行

你也可以用 .append 方法实现插入代码,.append 支持两个参数

第一个参数是你要插入的位置,你可以填写 'params' 或者 'body',分别对应着插入一个新的函数参数和插入到大括号包裹着的区块内。

我们用 .append 实现刚才同样的功能:

const ast = $(source);

// 找到 reactClass 定义的语句
const reactClass = ast.find('class $_$0 extends React.Component {}');

// 找到 jsx 里面带有 onClick 属性的标签
const onClick = reactClass.find('<$_$0 onClick={$_$1}></$_$0>');

// 创建一个数组用来收集 onClick 对应的 hanlder 的名称
const clickFnNames = [];

// 有可能找到很多个带有 onClick 的标签,我们这里用 each 去处理每一条
onClick.each((e) => {
  // 用 match[1][0] 来找到 $_$1 匹配到的第一个 onClick 属性对应的 handler 节点
  // 取 value 即为节点名
  // handlerName = 'this.handleClick'
  const handlerName = e.match[1][0].value;
  clickFnNames.push(handlerName);
});

/** 以上代码与之前相同 **/

// 找到 constructor 方法
const constructorMethod = ast.find('constructor() {}');

// 给它的函数体内添加 bind 语句
constructorMethod.append(
  'body',
  `
    ${clickFnNames.map((name) => `${name} = ${name}.bind(this)`).join(';')}
  `,
);
复制代码

Playgroud 在线演示

使用 .prepend 的方法和 .append 完全一致,不同的是语句将添加到最前面。

Playgroud 在线演示

用 before, after 给代码前后插入代码

对于上面的 React 组件示例,如果你想在每一个 setState 前后添上一条打印 state 的 log,可以使用 .before.after 方法,它会把传进去的参数插入到当前 ast 节点的前面或后面。

const ast = $(source);

const reactClass = ast.find('class $_$0 extends React.Component {}');

reactClass.find('this.setState()').each((setState) => {
  setState.before(`console.log('before', this.state)`);
  setState.after(`console.log('after', this.state)`);
});
复制代码

Playgroud 在线演示

删除代码

经过我们之前的努力,写了一个转换程序,给原来的代码里所有回调函数都加上了 .bind(this),然后你往后多看了半页文档,发现竟然可以这么写:

class Toggle extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isToggleOn: true };
    // 下面一行不再需要了
    // this.handleClick = this.handleClick.bind(this)
  }

  // 这里从成员方法改成 public class fields syntax
  handleClick = () => {
    this.setState((prevState) => ({
      isToggleOn: !prevState.isToggleOn,
    }));
  };

  render() {
    return (
      <button onClick={this.handleClick}>
        {this.state.isToggleOn ? 'ON' : 'OFF'}
      </button>
    );
  }
}
复制代码

首先,这告诉我们,学一个新的工具,文档一定要看全,否则会留下遗憾。

其次,我们可以考虑再写一个转换工具,把代码转换成这个样子,不留遗憾!

我们先请出万能的 replace,把回调函数 handleClick() {} 改转换成 handleClick = () {}

const ast = $(source);

// 找到 reactClass 定义的语句
const reactClass = ast.find('class $_$0 extends React.Component {}');

// 找到 jsx 里面带有 onClick 属性的标签
const onClick = reactClass.find('<$_$0 onClick={$_$1}></$_$0>');

// 创建一个数组用来收集 onClick 对应的 hanlder 的名称
const clickFnNames = [];

// 有可能找到很多个带有 onClick 的标签,我们这里用 each 去处理每一条
onClick.each((e) => {
  // 用 match[1][0] 来找到 $_$1 匹配到的第一个 onClick 属性对应的 handler 节点
  // 取 value 即为节点名
  // handlerName = 'this.handleClick'
  const handlerName = e.match[1][0].value;
  clickFnNames.push(handlerName);
});

clickFnNames.forEach((name) => {
  // 剔除掉前面的 this. 获得纯粹的函数名
  const fnName = name.replace('this.', '');

  // 把 class method 改成 public class fields syntax
  reactClass.replace(
    `${fnName}() {$$$0}`,
    `${fnName} = () => {
        $$$0
    }`,
  );
});
复制代码

Playgroud 在线演示

然后我们看看如何把原来的 .bind(this) 语句删掉。

用 replace 删除代码

删掉语句最简单的办法就是用 replace 把它替换成空:

clickFnNames.forEach((name) => {
  // 剔除掉前面的 this. 获得纯粹的函数名
  const fnName = name.replace('this.', '');

  // 把 class method 改成 public class fields syntax
  reactClass.replace(
    `${fnName}() {$$$0}`,
    `${fnName} = () => {
        $$$0
      }`,
  );

  // 移除原有的 bind
  reactClass.replace(`this.${fnName} = this.${fnName}.bind(this)`, ``);
});
复制代码

Playgroud 在线演示

用 remove 删除代码

或者,你也可以用先查找再调用 .remove 方法的方式来做到同样的事情:

clickFnNames.forEach((name) => {
  // 剔除掉前面的 this. 获得纯粹的函数名
  const fnName = name.replace('this.', '');

  // 把 class method 改成 public class fields syntax
  reactClass.replace(
    `${fnName}() {$$$0}`,
    `${fnName} = () => {
        $$$0
      }`,
  );

  // 移除原有的 bind
  reactClass.find(`this.${fnName} = this.${fnName}.bind(this)`).remove();
});
复制代码

Playgroud 在线演示

以上是用 GoGoCode 做代码转换的基本教程,感谢你的耐心看到这里,如果在使用过程中仍有疑问,可以查阅我们的 API 文档Cookbook,祝你代码转换顺利!

最后为我们的项目求一波 star 支持:GoGoCode ^_^

有问题和建议欢迎来我们的钉钉群:34266233

猜你喜欢

转载自juejin.im/post/7055129064796848135