重构神器 jscodeshift

2,819 阅读6分钟

如果你遇到很机械但又很费人力的任务,那么就该请 jscodeshift 出山了!

jscodeshift

jscodeshift 是一个工具包,用于在多个 JavaScriptTypeScript 文件上运行 codemods,它是:

  • 一个运行器,它为传递给它的每个文件执行提供的转换。它还输出已(未)转换的文件数量统计信息;
  • recast 的包装器,提供不同的 APIrecast 是一个 ASTAST 的尽量保留原始代码的风格转换工具。

上图清晰地说明了 jscodeshift 的工作机制,跟 babel小抄 讲到的 parse -> transform -> generate 流程基本一致。

codemod

codemod 是一个可用于大规模重构部分自动化 python 工具。举一个官方的 🌰:

codemod -m -d /home/jrosenstein/www --extensions php,html \
    '<font *color="?(.*?)"?>(.*?)</font>' \
    '<span style="color: \1;">\2</span>'

上面的命令可以将 /home/jrosenstein/wwwphp、html 格式的文件全部 <font> 替换成 <span>,并保留字体颜色。

recast

recast 是一个 Node 包,调用 parse 生成 AST(生成的抽象树支持 ast-types 的接口),再对 AST 调用 print 方法就能还原成代码。看一个官方的 🌰:

// Let's turn this function declaration into a variable declaration.
const code = [
  "function add(a, b) {",
  "  return a +",
  "    // Weird formatting, huh?",
  "    b;",
  "}"
].join("\n");

// Parse the code using an interface similar to require("esprima").parse.
const ast = recast.parse(code);

然后写一个操作(manipulate)函数:

export default function transformer(code, { recast, parsers }) {

  // 这里编译器使用 recast 默认的 exprime,也可以换成其他的编译器比如 acorn,具体可见官方 API 用法
  const ast = recast.parse(code, { parser: parsers.esprima });

  // Grab a reference to the function declaration we just parsed.
  const add = ast.program.body[0];

  // 确认是一个函数声明语句
  const n = recast.types.namedTypes;
  n.FunctionDeclaration.assert(add);

  // builders 用于创建新的节点,来自于 ast-types
  const b = recast.types.builders;

  // 将 AST program 节点的 body 数组第一个值赋值为新创建的 var 变量声明
  ast.program.body[0] = b.variableDeclaration("var", [
    b.variableDeclarator(
      add.id,
      b.functionExpression(
        null, // Anonymize the function expression.
        add.params,
        add.body
      )
    )
  ]);

  add.params.push(add.params.shift());

  // 调用 print 生成最终的代码
  return recast.print(ast).code;
}

最终生成的代码结果是:

var add = function(b, a) {
  return a + 
    b;
};

小结

先简单了解 jscodeshift 会涉及到几个概念——codemodrecast,通过官方的 🌰 简单了解它们的用法。

小试牛刀

前面已经看过 codemodrecast 的示例,那本文的主角 jscodeshift 怎么用呢?本节就通过一个工作中会遇到的场景来深入感受工作机制。

业务场景

前端调试代码都会使上 console 家族函数,比如 console.logconsole.errorconsole.warn 等等。而且常常因为 console 写得太多了,上库时偶尔会漏删。虽然 eslintno-console 规则帮你识别还有 console 存余的问题,但这个规则是不支持自动修复的:

需要你根据 eslint 报错信息定位到指定文件,然后将 console 删掉,重新 git add -> git commit。这个过程还是挺机械繁琐的,那么能不能在 git commit 的时候自动将变更文件中的 console 删除掉呢?答案当然是可以,本文会通过 jscodeshift 来实现这个需求。

开始写测试用例之前,先把用到的 npm 包安装一下。jscodeshift 的测试套件也是基于 jest 做的封装,所以我们需要安装 jest 包:

yarn add jscodeshift jest -D

测试先行

jscodeshift 提供了一个测试套件,避免我们写大量的面条代码。unit-testing 有详细的测试工具说明。我们先在根目录建一个 __tests__/remove_console-test.js 文件:

// __tests__/remove_console-test.js
const defineTest = require('jscodeshift/dist/testUtils').defineTest;

defineTest(__dirname, 'remove_console');

调用 defineTest 定义我们的测试名称,这个名称有下面 2 个约定:

  1. 指定的 transform 文件名需要是 remove_console;
  2. __testfixtures__ 目录下我们的输入、输出文件名必须是 remove_console.input.jsremove_console.output.js

😉 整明白上面的约定,接下来我们写 __testfixtures__ 的测试用例:

// remove_console.input.js
export const sum = (a, b) => {
    console.log('计算下面两个数的和:', a, b);
    return a + b;
};

export const minus = (a, b) => {
    console.log('计算下面两个数的差:' + a + ',' + b);
    return a - b;
};

export const multiply = (a, b) => {
    console.warn('计算下面两个数的积:', a, b);
    return a * b;
};

export const divide = (a, b) => {
    console.error(`计算下面两个数相除 ${a}, ${b}`);
    return a / b;
};

输入方面,我们覆盖了 console 的各种家族函数,参数的各种形式。输出方面自然就是全部的 console.XXX 代码全部删除掉:

// remove_console.output.js
export const sum = (a, b) => {
    return a + b;
};

export const minus = (a, b) => {
    return a - b;
};

export const multiply = (a, b) => {
    return a * b;
};

export const divide = (a, b) => {
    return a / b;
};

有了测试用例,我们开启 jest 进行测试:

npx jest --watchAll

结果如下:

😎Nice,全部标红,说明我们的测试工具已经跑起来了,然后一步一步来实现我们的 transform module

transform

将我们的用例丢到 astexplorer 分析一下:

依据 AST 分析,要删除 console.XXX 代码,就是要将 AST 中满足以下条件的 ExpressionStatement 删除就可满足。

从上面的 AST 可以分析得出,要删除掉 console,就是要将满足标红特点的语句表达式从抽象语法树中删除即可。

transform module 初始模板都可以用下面的结构 :

/**
 * jscodeshift transform
 * @param {Object} fileInfo 处理文件的信息
 * @param {Object} api jscodeshift 所有的 api,这部分会在源码解析部分详细说明
 * @param {Object} options CLI 传入的参数
 * @returns {string} 生成的代码
 */
module.exports = (fileInfo, api, options) => {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  // 对 AST 做一系列操作...

  return root.toSource();
};

上述代码的 root 就是根 AST,然后就可以通过 ast 上的方法去找到满足条件的节点,然后移除,直接看代码:

/**
 * jscodeshift transform
 * @param {Object} fileInfo 处理文件的信息
 * @param {Object} api jscodeshift 所有的 api,这部分会在源码解析部分详细说明
 * @param {Object} options CLI 传入的参数
 * @returns {string} 生成的代码
 */
module.exports = (fileInfo, api, options) => {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  // 😮😮眼睛放光没,找到一个节点既然如此简单
  const expressionStatement = root.find(j.ExpressionStatement, {
    expression: {
      callee: {
        type: 'MemberExpression',
        object: { type: 'Identifier', name: 'console' },
      }
    },
  });

  expressionStatement.remove();

  return root.toSource();
};

上述代码保存之后可以发现我们的测试用例已经全部通过: tdd-success

虽然解决了问题,但是上面的代码看起来还是太命令式,jscodeshift 有着很好的链式调用支持:

module.exports = (fileInfo, api, options) => {
  const j = api.jscodeshift;
  
  return j(fileInfo.source)
    .find(j.ExpressionStatement, {
      expression: {
        callee: {
          type: 'MemberExpression',
          object: { type: 'Identifier', name: 'console' },
        }
      },
    })
    .remove()
    .toSource();
};

保存之后,测试用例依然是全部 passed 的。

总结

🥳 这个需求的剩下最后一步通过 huskycommit 钩子上添加 npx jscodeshift -t remove_console.js 就完成了。这里就不具体展开讲解,有疑问的欢迎在评论区留言哦。这一小节通过一个具体的业务需求,用 TDD 的方式实现一个 codemod。例子举得比较简单,没有涉及太多的 jscodeshiftAPIjscodeshift 文档的不完善是挺蛋疼的。对于 API 的了解,建议可以多看官方文档底下的几个 github 仓库,例如: js-codemodreact-codemod

如果文章对你有帮助,动动你的小手手,点个赞再走呗~😎😎下一篇会剖析 jscodeshift 的源码,让我们深入学习以下几点内容:

  1. jscodeshift 是如何实现链式调用的?
  2. 怎么做到 JavaScriptTypeScript 的编译?
  3. 测试套件 testUtils 做了哪些封装?了解这个机制,可以帮助我们做出高可测试性的工具、插件。
  4. 扩展性 API 如 registerMethods 是如何实现的?

精彩不容错过,欢迎亲们扫码注我的公众号!🤟🤟🤟