7

使用 jscodeshift 做重构 - rxliuli blog

 2 years ago
source link: https://blog.rxliuli.com/p/e124cb73d5864c24bb5547cd3431e338/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

使用 jscodeshift 做重构

使用 jscod_
2022年7月14日 晚上

5.9k 字

50 分钟

本文最后更新于:2022年7月31日 晚上

最近迁移了一些 API,因为所有前端项目都在同一个 monorepo 中,所以作为 lib 维护者吾辈还需要帮助迁移其他使用的模块。由于项目数量较多(大约有 30 多个),手动迁移非常麻烦而且难以测试。所以在调研了一些现有的大规模重构的方法后,吾辈选择了 jscodeshift 作为主要工具来做自动化迁移。
那么,它相比于使用 ide 的重构功能、使用字符串搜索替换亦或是手工一个个替换有什么不同呢?

  • ide(vscode)的重构大多数时候不太好用,尤其在 monorepo 中以及包含 vue 文件时,它基本上无效的。
  • 字符串替换我们经常使用,它只能处理简单的情况,并不能处理一些更复杂的情况,例如替换导入的变量并修改下面对应的值。
  • 手工一个个替换最大的问题是浪费时间,并且难以形成积累以供后续复用,处理大量文件时是不现实的。

codemod.drawio.svg

codemod.drawio.svg

jscodeshift 支持多种解析器,包括常见的 babel、ts、tsx,也提供链式调用 API,类似于 jquery。在调研的过程中,吾辈还发现了一个非常好的工具 astexplorer,它可以非常方便的浏览一段代码的 ast,便于确认如何找到想要处理的 ast 节点。

1658135431847

1658135431847

jscodeshift 同时提供了 cli/lib 的使用方式,下面是基本的使用命令,它会在匹配的 ts 文件上运行转换脚本 transform.ts,-d 表示尝试运行并确定影响范围。

jscodeshift -t=./transform.ts --parser=ts ./*.ts -d

替换 import 导入的变量并替换所有使用到的 API

一种需求是将命名空间导入重构为命名导入,例如将 import * as _ from 'lodash' 转换为 import { uniq } from 'lodash' 便于构建工具能正确的 tree shaking。

import * as _ from "lodash";

console.log(_.sort(_.uniq([1, 2, 1])));
import { sort, uniq } from "lodash";

console.log(sort(uniq([1, 2, 1])));

这里只需要找到引用命名空间的所有调用,并分别替换导入命名空间方法调用即可,以下是实现

import {
  Identifier,
  ImportDeclaration,
  MemberExpression,
  Transform,
} from "jscodeshift";

const replaceImport: Transform = (fileInfo, api) => {
  const j = api.j;
  const root = j(fileInfo.source);
  const importNames = root
    .find(j.ImportDeclaration)
    .find(j.ImportNamespaceSpecifier)
    .find(j.Identifier)
    .nodes()
    .map((node) => (node as Identifier).name);
  console.log(importNames);
  const list = importNames.map((name) => ({
    name,
    list: root
      .find(j.MemberExpression, {
        object: { name },
      })
      .nodes()
      .map((node) => ((node as MemberExpression).property as Identifier).name),
  }));
  console.log(list);
  list.forEach(({ name, list }) => {
    root
      .find(j.ImportDeclaration, {
        specifiers: [{ type: "ImportNamespaceSpecifier", local: { name } }],
      })
      .replaceWith((path) => {
        const node = path.node as ImportDeclaration;
        node.specifiers = list.map((name) =>
          j.importSpecifier(j.identifier(name))
        );
        return node;
      });
    list.forEach((p) => {
      root
        .find(j.MemberExpression, { object: { name }, property: { name: p } })
        .replaceWith(j.identifier(p));
    });
  });
  return root.toSource();
};

export default replaceImport;

将废弃的 API 替换为新的 API 调用

还有一些时候我们废弃了一些 API,但目前仍然有引用,为了避免堆叠兼容式的代码,需要将使用旧 API 的代码转换为使用新 API 的代码。
例如我们希望替换以下代码

import { RendererApiFactory } from "ipc-renderer";

export const { vmBasicMessageChannel } = RendererApiFactory.createAll();
export const { systemApi } = RendererApiFactory.createAllIpcMainApi(
  vmBasicMessageChannel
);
import { ApiFactory } from "app-utils";

export const { basicMessageChannel, systemApi } =
  ApiFactory.createAll(basicMessageChannel);

这里替换稍微复杂一点,涉及到以下几个操作

  • 删除 import 的指定导入
  • 创建新的导入
  • 创建新的导出
  • 清理空的导入、导出
import { Identifier, ObjectProperty, Transform } from "jscodeshift";

const depretedApi: Transform = (fileInfo, api) => {
  const j = api.j;
  const root = j(fileInfo.source);

  const findImport = root
    .find(j.ImportDeclaration, { source: { value: "ipc-renderer" } })
    .filter(
      (path) =>
        j(path.node).find(j.ImportSpecifier, {
          imported: { name: "RendererApiFactory" },
        }).length !== 0
    );
  if (findImport.length === 0) {
    return;
  }
  findImport
    .find(j.ImportSpecifier, { imported: { name: "RendererApiFactory" } })
    .remove();
  findImport.insertAfter(
    j.importDeclaration(
      [j.importSpecifier(j.identifier("ApiFactory"))],
      j.literal("app-utils")
    )
  );
  if (findImport.find(j.ImportSpecifier).length === 0) {
    findImport.remove();
  }

  root
    .find(j.ExportNamedDeclaration)
    .filter(
      (path) =>
        j(path.node).find(j.MemberExpression, {
          object: { name: "RendererApiFactory" },
          property: { name: "createAll" },
        }).length !== 0
    )
    .remove();
  const createAllIpcMainApi = root.find(j.ExportNamedDeclaration).filter(
    (path) =>
      j(path.node).find(j.MemberExpression, {
        object: { name: "RendererApiFactory" },
        property: { name: "createAllIpcMainApi" },
      }).length !== 0
  );
  const keys = createAllIpcMainApi
    .find(j.ObjectPattern)
    .find(j.ObjectProperty)
    .nodes()
    .map((node) => ((node as ObjectProperty).key as Identifier).name);
  console.log(keys);
  createAllIpcMainApi.insertAfter(
    j(
      `export const { ${["basicMessageChannel", ...keys].join(
        ", "
      )} } = ApiFactory.createAll()`
    )
      .find(j.ExportNamedDeclaration)
      .nodes()[0]
  );
  createAllIpcMainApi.remove();
  console.log(root.toSource());
  return root.toSource();
};

export default depretedApi;

这里可以看到,吾辈并未使用 jscodeshift 构建 ast 的 api,而是直接使用了字符串拼接的方法。主要是使用 jscodeshift 的 api 构建过于繁琐,所以直接拼接字符串然后解析可能更简单一点。

替换方法调用到多个参数与对象参数

除此之外,我们还能变换方法调用的参数,例如将多个参数转换为对象参数(这在 ide 中是现成的功能)

show("liuli", 17, false);
show({ name: "liuli", age: 17, sex: false });

这里我们仅需要找到需要处理的函数调用,然后转换其参数即可。

import { CallExpression, Identifier, Transform } from "jscodeshift";

const replaceParams: Transform = (fileInfo, api) => {
  const j = api.j;
  const root = j(fileInfo.source);

  const names = ["name", "age", "sex"];
  root
    .find(j.CallExpression, { callee: { type: "Identifier", name: "show" } })
    .replaceWith((path) => {
      const node = path.node as CallExpression;
      const args = node.arguments;
      node.arguments = [
        j.objectExpression(
          names.map((name, i) =>
            j.objectProperty(j.identifier(name), args[i] as Identifier)
          )
        ),
      ];
      return node;
    });

  return root.toSource();
};

export default replaceParams;

踩到的一些坑

使用 ts 解析器得到的结果与 jscodeshift 的 API 差距很大

ts 的 ast 非常特立独行,可以在 astexplorer 看到。吾辈一般会选择使用 @typescript-eslint/parser,它既能解析 js/ts/tsx,又能与 jscodeshift 的 api 相结合判断如何检索节点。

无法直接按类型找到泛型参数

例如可以在 ast viewer 中看到节点 TSTypeParameterInstantiation

1658133727745

1658133727745

但却无法使用 jscodeshift 找到

const root = j(`wrap<IHelloApi>()`);
expect(root.find(j.TSTypeParameterInstantiation).length).toBe(0);

string.prototype.replace 替换包含 $ 的字符串时会出现奇怪的现象

运行下面这段代码,可能会得到让你意外的结果

const s = "hell$$ w$$rld";
console.log(s.replaceAll(s, s)); // hell$ w$rld

这与 string.prototype.replace 的一些奇怪实现有关,具体参考 mdn,目前 StackOverflow 上的推荐方法是先处理一次要替换的值

console.log(s.replaceAll(s, s.replaceAll("$", "$$$$")));

glob 模式依赖于 bash

这点很烦人,它并未使用 node-glob 之类的包来实现文件匹配,而是直接依赖于 shell 本身的 glob 匹配,而默认情况下并不支持 **。某种变通的方法是使用 find + xargs 来绕过

find ./*/src -iname '*.vue' -o -iname '*.ts' | xargs jscodeshift -t "./convertAppApi.ts" -d

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK