使用 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 表示尝试运行并确定影响范围。

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

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

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

转换前

1
2
3
import * as _ from 'lodash'

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

转换后

1
2
3
import { sort, uniq } from 'lodash'

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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 的代码。
例如我们希望替换以下代码

转换前

1
2
3
4
5
6
import { RendererApiFactory } from 'ipc-renderer'

export const { vmBasicMessageChannel } = RendererApiFactory.createAll()
export const { systemApi } = RendererApiFactory.createAllIpcMainApi(
vmBasicMessageChannel,
)

转换后

1
2
3
4
import { ApiFactory } from 'app-utils'

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

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

  • 删除 import 的指定导入
  • 创建新的导入
  • 删除变量
  • 创建新的导出
  • 清理空的导入、导出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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 中是现成的功能)

转换前

1
show('liuli', 17, false)

转换后

1
show({ name: 'liuli', age: 17, sex: false })

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 找到

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

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

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

1
2
const s = 'hell$$ w$$rld'
console.log(s.replaceAll(s, s)) // hell$ w$rld

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

1
console.log(s.replaceAll(s, s.replaceAll('$', '$$$$')))

glob 模式依赖于 bash

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

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

参考


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