使用 jscodeshift 做重构

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

场景

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

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

codemod.drawio.svg

使用

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

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

但却无法使用 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

参考


使用 jscodeshift 做重构
https://blog.rxliuli.com/p/e124cb73d5864c24bb5547cd3431e338/
作者
rxliuli
发布于
2022年7月14日
许可协议