使用 jscodeshift 重构你的代码

jscodeshift 是什么

见名猜其意,js-code-shift,即 js 代码转换,比如可以将某个变量名 A 统一重命名为 A2。

听起来是手动也可以实现的事情,为何需要工具呢?那自然是工具比人的出错率更低,效率也更高。人为修改多是基于文本匹配,无法获取语义信息,即便有 IDE 提供的 refactor 功能协助,也还是会发生改到了不应该修改的地方。

对于初次使用这个工具,必然要花些时间先学习它,虽然这些时间也够手动把那些变量名替换掉了,但这样可会带来更多的沉默成本,因为重构、迁移并非只有一次,学会使用工具则会让我们一劳永逸,毕竟机器只会取代那些和机器做着相同事情但不会使用机器的人(哥你是不是写跑偏了)。

场景模拟

现在有这么个模拟场景:我们的一个多人协作项目内,有个公共 React 组件 FancyButton 内部逻辑进行了调整,原先的 dynamic 属性从默认 false 变更为了 true。那么现在需要调整所有用到该组件的地方:给原先没有设置该属性的组件添加 dynamic={false},而已经设置了 dynamic={true} 的则可以移除掉。

这是 FancyButton 组件的伪代码:

// src/common/components/FancyButton.tsx

import { FC } from "react";

const FancyButton: FC<{ dynamic?: boolean }> = ({ dynamic = true }) => {
  return <></>;
};

export default FancyButton;

jscodeshift 的基本使用

现在我们来看看如何使用 jscodeshift 实现上述转换吧。

jscodeshift 通过使用不同的 transformer(一个特定签名的函数)来操作 ast 从而实现代码的转换,其内部借助了 recast 操作 ast,recast 则借助 ast-types 提供的 ast nodes。在执行转换时,jscodeshift 会基于 cpu 核数创建 worker 进行多线程转换。该工具支持命令行和 API 两种调用调用,下面将使用 API 的方式来使用该工具。

新建一个目录,专门存放我们的转换脚本:

mkdir my-transforms
cd my-transforms

npm init -y

安装所需依赖:

# 必要依赖
npm i jscodeshift

# 用于批量匹配待转换的文件
npm i fast-glob

一般在写脚本内容时,笔者更偏向能快速实现需求和很方便的执行他们,所以下面不会使用 typescript,它会徒增配置和代码复杂度,虽然会丢失一些类型提示,不过对于外部依赖很少的小脚本来说无伤大雅。

创建入口文件 index.js

// index.js

const path = require("node:path");
const { run: jscodeshift } = require("jscodeshift/src/Runner");
const fg = require("fast-glob");

async function main() {
  // transformer 文件
  const transformerPath = path.resolve("transform-component.js");
  // 匹配项目内的所有 tsx 文件
  const paths = fg.sync(["~/my-code/project1/**/*.tsx"], { dot: false });

  // 执行转换
  const res = await jscodeshift(transformerPath, paths, {
    // 参数配置,和命令行参数对应,或者可以查看源码里具体有哪些
    // dry: false,
    // ...

    // 支持传入自定义配置项,可以在 transformer 内访问
    customOption: "v1",
  });

  // 打印转换结果
  console.log("done:", res);
}

main();

transformer

transformer 指的是一个特定签名的函数 ,其格式如下:

// transform-component.js

module.exports = function transformer(file, api, options) {
  const j = api.jscodeshift;

  // 函数 `j` 返回的是 jscodeshift 自己内部封装的数据结构 Collection,而非一个 ast(源码对应 src/core.js 中的 `core`` 函数)
  const rootSource = j(file.source);

  // 通过调用 Collection 的相关函数对来遍历节点,而不同于 babel 插件使用 visitor 模式
  rootSource
    .find(
      // jscodeshift 将 ast-types 的节点类型都暴露了出来,大写开头的便表示的是类型了
      j.ImportDeclaration
    )
    .forEach((nodePath) => {
      // jscodeshift 将 ast-types 的 builder 也暴露了出来,小写开头的便表示的是 builder/构造器了,使用 builder 来构建新的 ast 节点来替换掉不需要的节点
      const ast = j.callExpression(j.identifier("foo"), [j.identifier("bar")]);
    });

  // 返回转换后的内容,参数可以配置一些生成的格式,比如单双引号
  return rootSource.toSource({});
};

// jscodeshift 支持处理不同语法如 flow typescript 等,但需要在此手动设置下
module.exports.parser = "tsx";

AST 节点判断

节点类型的判断使用 check 函数:

// 大写开头
j.TemplateElement.check(value);

AST 节点创建

在写 transformer 时会涉及较多的 ast 节点创建,我们看下是如何处理的:

我们自然是记不住也没必要记住那么多节点类型,而是通过使用 astexplorer 在线工具分析我们的代码语法树,查看想要改变或是新建的节点,然后回过头用代码对应实现。构建 ast node 所需参数可见 ast-types,下面以模板字面量(template literal)里的字符串(比如 a b ${variable} 中的字符串)举个例子:

ast-types 里对于模板字面量里的字符串是这么定义的:

def("TemplateElement")
  .bases("Node")
  // 所需参数
  .build("value", "tail")
  // 参数1的类型:需要是一个对象,包含了这两个字符串属性
  .field("value", { cooked: String, raw: String })
  // 参数2的类型:需要是一个布尔值
  .field("tail", Boolean);

那么在 jscodeshift 里我们便这么写来创建它:

// 小写开头表示 builder
const astNode = j.templateElement(
  // 参数一传入对象
  { cooked: ``, raw: `` },
  // 参数二传入布尔值
  true
);

开始实现

了解了 jscodeshift 的基本要掌握的内容后,现在开始梳理下我们的 transformer 要如何处理上述需求吧:

  1. 找到 tsx 文件里 FancyButton 的 import 语句对应的引用名称(示例:import Button from '@components/FancyButton 中的 Button 名称)

  2. 找到对应引用名称的 jsx 组件(示例:<Button />

  3. 遍历该组件的属性,处理所有可能的写法:

    1. <Button dynamic/>:可移除 dynamic

    2. <Button dynamic={true} />:可移除 dynamic

    3. <Button dynamic={false} />:不做处理

    4. <Button />:需要添加 dynamic={false}

    5. <Button dynamic={variable} />:这种处理有两种处理方式:

      • 将其转换成 !variable
      • 打印此处的日志信息然后人工处理

      这里我将选用第二种方式,因为写起来比较简单,第一种方式可交给读者练手(狗头)

    6. React.createElement(Button, {..}):这种写法不做处理,因为经过人工检索项目发现没有这种写法,故不需要花费精力处理(也可以交给读者练手)

从上述可以看出,编写转换逻辑的难点在于要尽可能考虑到 JS 语法所支持的所有合理的写法,虽然可能有比如 eslint 规则来规范我们的代码,但是并非人人都会严格遵循,工具层面还是要尽可能处理全面,当然人是活的,并不是在任何时候写工具都要处理的十分完备,能先解决当下需求才是首要。

下面便是最终的 transformer 逻辑:

// transform-component.js
module.exports = function transformer(file, api, options) {
  const j = api.jscodeshift;
  const rootSource = j(file.source);

  // import 语句的变量名
  let targetName;

  rootSource.find(j.ImportDeclaration).forEach(function (path) {
    const node = path.value;
    const importFromStr = node.source.value;
    if (importFromStr.includes("components/FancyButton")) {
      const specifiers = node.specifiers;
      if (!specifiers.length) {
        return;
      }

      for (const s of specifiers) {
        if (j.ImportDefaultSpecifier.check(s)) {
          targetName = s.local.name;
          break;
        }
      }
    }
  });

  if (targetName) {
    rootSource.find(j.JSXOpeningElement).forEach(function (path) {
      const node = path.value;
      const name = node.name.name;

      if (name === targetName) {
        let idxForDelete = -1;
        let hasDynamicProp = false;

        for (let [idx, attribute] of node.attributes.entries()) {
          const name = attribute.name.name;
          if (name === "dynamic") {
            hasDynamicProp = true;

            // <div xx/>
            if (!attribute.value) {
              idxForDelete = idx;
              break;
            }

            // <div xx={true/false} />
            if (j.BooleanLiteral.check(attribute.value.expression)) {
              if (attribute.value.expression.value === false) {
                return false;
              }
              if (attribute.value.expression.value === true) {
                idxForDelete = idx;
                break;
              }
            }
            // <div xx={variable}>
            else {
              console.log(
                `文件 ${file.path} 检测到 dynamic 传入了变量,请手动处理`
              );
              return false;
            }
          }
        }

        // 处理场景1和2
        if (idxForDelete >= 0) {
          node.attributes.splice(idxForDelete, 1);
        }

        // 处理场景4
        if (!hasDynamicProp) {
          node.attributes.push(
            j.jsxAttribute(
              j.jsxIdentifier("dynamic"),
              j.jsxExpressionContainer(j.booleanLiteral(false))
            )
          );
        }
      }
    });
  }

  return rootSource.toSource();
};

module.exports.parser = "tsx";

单元测试

通过编写单元测试来保证我们的 transformer 的转换结果总是符合预期的,下面根据文档里看看如何编写测试用例。

  1. 安装 jest

    npm i jest
    
  2. 创建 __tests__ 目录存放测试文件,内部创建 transform-component-test.js

    jest.autoMockOff();
    const { defineTest } = require("jscodeshift/dist/testUtils");
    
    // 参数对应含义:dirName, transformName, options, testFilePrefix, testOptions
    defineTest(__dirname, "transform-component", null, "transform-component", {
      parser: "tsx",
    });
    
  3. 创建 __testfixtures__ 目录存放 fixture 文件,内部创建 transform-component.input.tsxtransform-component.output.tsx

    转换前的文件内容:

    // transform-component.input.tsx
    
    import { FC } from "react";
    import Button from "@components/FancyButton";
    
    const List: FC = () => {
      const variable = 1 + 1;
    
      return (
        <>
          <Button dynamic />
          <Button dynamic={true} />
          <Button dynamic={false} />
          <Button />
          <Button dynamic={variable} />
        </>
      );
    };
    
    export default List;
    

    转换后的预期内容:

    // transform-component.output.tsx
    
    import { FC } from "react";
    import Button from "@components/FancyButton";
    
    const List: FC = () => {
      const variable = 1 + 1;
    
      return (
        <>
          <Button />
          <Button />
          <Button dynamic={false} />
          <Button dynamic={false} />
          <Button dynamic={variable} />
        </>
      );
    };
    
    export default List;
    

现在我们运行 npx jest transform-component 执行测试用例即可。

小结

上述便是 jscodeshift 的完整使用了,当自捯饬一番后,再去看 README 会发现看得更明白了。文档底部也提供了一些现有的 examples,我们可以阅读这些代码,看他们是如何实现某个转换的,从而丰富自己的实现思路。

当然 jscodeshift 也不是没有缺陷,在代码转换后,我们的代码格式可能会被更改,比如这个 issue 里反馈多添加括号,因此我们可能需要配合项目内的 prettier/eslint 做进一步格式化。

此外 jscodeshift 仅支持 js 的转换,如果我们的转换还涉及比如 scss 文件,则需要自行引入 sass、postcss 等工具搭配使用。