本文最后更新于:2022年8月7日 上午
场景
最近迁移了一些 API,因为所有前端项目都在同一个 monorepo 中,所以作为 lib 维护者吾辈还需要帮助迁移其他使用的模块。由于项目数量较多(大约有 30 多个),手动迁移非常麻烦而且难以测试。所以在调研了一些现有的大规模重构的方法后,吾辈选择了 jscodeshift 作为主要工具来做自动化迁移。
那么,它相比于使用 ide 的重构功能、使用字符串搜索替换亦或是手工一个个替换有什么不同呢?
- ide(vscode)的重构大多数时候不太好用,尤其在 monorepo 中以及包含 vue 文件时,它基本上无效的。
- 字符串替换我们经常使用,它只能处理简单的情况,并不能处理一些更复杂的情况,例如替换导入的变量并修改下面对应的值。
- 手工一个个替换最大的问题是浪费时间,并且难以形成积累以供后续复用,处理大量文件时是不现实的。
使用
jscodeshift 支持多种解析器,包括常见的 babel、ts、tsx,也提供链式调用 API,类似于 jquery。在调研的过程中,吾辈还发现了一个非常好的工具 astexplorer,它可以非常方便的浏览一段代码的 ast,便于确认如何找到想要处理的 ast 节点。
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
但却无法使用 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))
|
这与 string.prototype.replace 的一些奇怪实现有关,具体参考 mdn,目前 StackOverflow 上的推荐方法是先处理一次要替换的值
1
| console.log(s.replaceAll(s, s.replaceAll('$', '$$$$')))
|
glob 模式依赖于 bash
这点很烦人,它并未使用 node-glob 之类的包来实现文件匹配,而是直接依赖于 shell 本身的 glob 匹配,而默认情况下并不支持 **
。某种变通的方法是使用 find + xargs
来绕过
参考