使用 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 要如何处理上述需求吧:
-
找到 tsx 文件里
FancyButton
的 import 语句对应的引用名称(示例:import Button from '@components/FancyButton
中的Button
名称) -
找到对应引用名称的 jsx 组件(示例:
<Button />
) -
遍历该组件的属性,处理所有可能的写法:
-
<Button dynamic/>
:可移除dynamic
-
<Button dynamic={true} />
:可移除dynamic
-
<Button dynamic={false} />
:不做处理 -
<Button />
:需要添加dynamic={false}
-
<Button dynamic={variable} />
:这种处理有两种处理方式:- 将其转换成
!variable
- 打印此处的日志信息然后人工处理
这里我将选用第二种方式,因为写起来比较简单,第一种方式可交给读者练手(狗头)
- 将其转换成
-
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 的转换结果总是符合预期的,下面根据文档里看看如何编写测试用例。
-
安装
jest
:npm i jest
-
创建
__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", });
-
创建
__testfixtures__
目录存放 fixture 文件,内部创建transform-component.input.tsx
和transform-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 等工具搭配使用。