编写代码来重写你的代码:jscodeshift
已发表: 2022-03-11带有 jscodeshift 的 Codemods
您有多少次使用跨目录的查找和替换功能来更改 JavaScript 源文件? 如果你做得很好,你就会喜欢并使用正则表达式来捕获组,因为如果你的代码库相当大,那么付出努力是值得的。 不过,正则表达式有限制。 对于重要的更改,您需要一位了解上下文代码并且愿意承担冗长、乏味且容易出错的过程的开发人员。
这就是“codemods”的用武之地。
Codemods 是用于重写其他脚本的脚本。 将它们视为可以读写代码的查找和替换功能。 您可以使用它们来更新源代码以适应团队的编码约定,在修改 API 时进行广泛的更改,甚至在您的公共包进行重大更改时自动修复现有代码。
在本文中,我们将探索一个名为“jscodeshift”的 codemod 工具包,同时创建三个复杂度越来越高的 codemod。 到最后,您将广泛了解 jscodeshift 的重要方面,并准备好开始编写自己的 codemod。 我们将完成三个练习,涵盖一些基本但很棒的 codemod 使用,您可以在我的 github 项目上查看这些练习的源代码。
什么是 jscodeshift?
jscodeshift 工具包允许您通过转换抽取一堆源文件并将它们替换为另一端的内容。 在转换内部,您将源解析为抽象语法树 (AST),四处寻找以进行更改,然后从更改后的 AST 重新生成源。
jscodeshift 提供的接口是recast
和ast-types
包的包装器。 recast
处理从源到 AST 的转换,而ast-types
处理与 AST 节点的低级交互。
设置
首先,从 npm 全局安装 jscodeshift。
npm i -g jscodeshift
您可以使用一些运行器选项和一个自以为是的测试设置,这使得通过 Jest(一个开源 JavaScript 测试框架)运行一套测试非常容易,但为了简单起见,我们现在将绕过它:
jscodeshift -t some-transform.js input-file.js -d -p
这将通过转换some-transform.js
运行input-file.js
并在不更改文件的情况下打印结果。
不过,在开始之前,了解 jscodeshift API 处理的三种主要对象类型很重要:节点、节点路径和集合。
节点
节点是 AST 的基本构建块,通常被称为“AST 节点”。 这些是您在使用 AST Explorer 探索代码时看到的内容。 它们是简单的对象,不提供任何方法。
节点路径
节点路径是由ast-types
提供的围绕 AST 节点的包装器,作为遍历抽象语法树(AST,还记得吗?)的一种方式。 孤立地,节点没有关于其父级或范围的任何信息,因此节点路径负责处理。 您可以通过node
属性访问包装的节点,并且有多种方法可用于更改底层节点。 节点路径通常被称为“路径”。
收藏品
集合是 jscodeshift API 在查询 AST 时返回的零个或多个节点路径组。 他们有各种有用的方法,我们将探索其中的一些。
集合包含节点路径,节点路径包含节点,节点是 AST 的组成部分。 请记住这一点,这样就很容易理解 jscodeshift 查询 API。
跟踪这些对象及其各自 API 功能之间的差异可能很困难,因此有一个名为 jscodeshift-helper 的漂亮工具可以记录对象类型并提供其他关键信息。
练习 1:删除对控制台的调用
为了让我们的脚湿透,让我们从删除对我们代码库中所有控制台方法的调用开始。 虽然您可以使用查找和替换以及一些正则表达式来完成此操作,但对于多行语句、模板文字和更复杂的调用,它开始变得棘手,因此它是一个理想的开始示例。
首先,创建两个文件remove-consoles.js
和remove-consoles.input.js
:
//remove-consoles.js export default (fileInfo, api) => { };
//remove-consoles.input.js export const sum = (a, b) => { console.log('calling sum with', arguments); return a + b; }; export const multiply = (a, b) => { console.warn('calling multiply with', arguments); return a * b; }; export const divide = (a, b) => { console.error(`calling divide with ${ arguments }`); return a / b; }; export const average = (a, b) => { console.log('calling average with ' + arguments); return divide(sum(a, b), 2); };
这是我们将在终端中用来通过 jscodeshift 推送它的命令:
jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
如果一切设置正确,当您运行它时,您应该会看到类似这样的内容。
Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 0 unmodified 1 skipped 0 ok Time elapsed: 0.514seconds
好吧,这有点虎头蛇尾,因为我们的转换实际上还没有做任何事情,但至少我们知道这一切都在工作。 如果它根本不运行,请确保您全局安装了 jscodeshift。 如果运行转换的命令不正确,如果找不到输入文件,您将看到“错误转换文件...不存在”消息或“类型错误:路径必须是字符串或缓冲区”。 如果您对某些东西进行了粗略的处理,那么应该很容易发现具有非常描述性的转换错误。
但是,在成功转换之后,我们的最终目标是查看以下来源:
export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); };
为此,我们需要将源代码转换为 AST,找到控制台,删除它们,然后将更改后的 AST 转换回源代码。 第一步和最后一步很简单,只是:
remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
但是我们如何找到控制台并删除它们呢? 除非您对 Mozilla Parser API 有一些特殊的了解,否则您可能需要一个工具来帮助理解 AST 的样子。 为此,您可以使用 AST Explorer。 将remove-consoles.input.js
的内容粘贴到其中,您将看到 AST。 即使在最简单的代码中也有很多数据,因此它有助于隐藏位置数据和方法。 您可以使用树上方的复选框切换 AST Explorer 中属性的可见性。
我们可以看到对控制台方法的调用称为CallExpressions
,那么我们如何在转换中找到它们呢? 我们使用 jscodeshift 的查询,记住我们之前关于集合、节点路径和节点本身之间差异的讨论:
//remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
行const root = j(fileInfo.source);
返回包含根 AST 节点的一个节点路径的集合。 我们可以使用集合的find
方法来搜索某种类型的后代节点,如下所示:
const callExpressions = root.find(j.CallExpression);
这将返回另一个节点路径集合,其中仅包含 CallExpressions 节点。 乍一看,这似乎是我们想要的,但它太宽泛了。 我们最终可能会通过我们的转换运行成百上千个文件,因此我们必须精确地确信它会按预期工作。 上面的天真的find
不仅会找到控制台 CallExpressions,还会找到源中的每个 CallExpression,包括
require('foo') bar() setTimeout(() => {}, 0)
为了增强特异性,我们为.find
提供了第二个参数:附加参数的对象,每个节点都需要包含在结果中。 我们可以查看 AST Explorer 以了解我们的 console.* 调用具有以下形式:
{ "type": "CallExpression", "callee": { "type": "MemberExpression", "object": { "type": "Identifier", "name": "console" } } }
有了这些知识,我们就知道用一个只返回我们感兴趣的 CallExpressions 类型的说明符来完善我们的查询:
const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, });
现在我们已经准确地收集了调用站点,让我们从 AST 中删除它们。 方便的是,集合对象类型有一个remove
方法可以做到这一点。 我们的remove-consoles.js
文件现在看起来像这样:
//remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source) const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ); callExpressions.remove(); return root.toSource(); };
现在,如果我们使用jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
从命令行运行我们的转换,我们应该看到:
Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); }; All done. Results: 0 errors 0 unmodified 0 skipped 1 ok Time elapsed: 0.604seconds
这看起来不错的样子。 现在我们的转换改变了底层的 AST,使用.toSource()
生成一个与原始字符串不同的字符串。 我们命令中的 -p 选项会显示结果,并且每个已处理文件的处置汇总显示在底部。 从我们的命令中删除 -d 选项,会将 remove-consoles.input.js 的内容替换为转换的输出。
我们的第一个练习完成了……差不多了。 代码看起来很奇怪,可能对任何功能纯粹主义者都非常冒犯,因此为了使转换代码更好地流动,jscodeshift 使大多数东西都可以链接。 这允许我们像这样重写我们的转换:
// remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; return j(fileInfo.source) .find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ) .remove() .toSource(); };
好多了。 回顾练习 1,我们包装了源,查询节点路径的集合,更改 AST,然后重新生成该源。 我们已经用一个非常简单的例子弄湿了我们的脚,并触及了最重要的方面。 现在,让我们做一些更有趣的事情。
练习 2:替换导入的方法调用
对于这种情况,我们有一个“几何”模块,其中包含一个名为“circleArea”的方法,我们已弃用该方法以支持“getCircleArea”。 我们可以很容易地找到这些并将其替换为/geometry\.circleArea/g
,但是如果用户导入了模块并为其分配了不同的名称怎么办? 例如:
import g from 'geometry'; const area = g.circleArea(radius);
我们怎么知道替换g.circleArea
而不是geometry.circleArea
? 我们当然不能假设所有circleArea
调用都是我们正在寻找的,我们需要一些上下文。 这就是 codemods 开始显示其价值的地方。 让我们先创建两个文件deprecated.js
和deprecated.input.js
。
//deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
deprecated.input.js import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.circleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));
现在运行这个命令来运行 codemod。
jscodeshift -t ./deprecated.js ./deprecated.input.js -d -p
您应该看到指示转换已运行的输出,但尚未更改任何内容。
Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 1 unmodified 0 skipped 0 ok Time elapsed: 0.892seconds
我们需要知道我们的geometry
模块已导入为什么。 让我们看看 AST Explorer 并找出我们在寻找什么。 我们的导入采用这种形式。
{ "type": "ImportDeclaration", "specifiers": [ { "type": "ImportDefaultSpecifier", "local": { "type": "Identifier", "name": "g" } } ], "source": { "type": "Literal", "value": "geometry" } }
我们可以指定对象类型来查找节点集合,如下所示:
const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, });
这为我们提供了用于导入“几何”的 ImportDeclaration。 从那里,向下挖掘以找到用于保存导入模块的本地名称。 由于这是我们第一次这样做,所以让我们在第一次开始时指出一个重要且令人困惑的点。

注意:重要的是要知道root.find()
返回节点路径的集合。 从那里, .get(n)
方法返回该集合中索引n
处的节点路径,为了获取实际节点,我们使用.node
。 该节点基本上就是我们在 AST Explorer 中看到的。 请记住,节点路径主要是有关节点范围和关系的信息,而不是节点本身。
// find the Identifiers const identifierCollection = importDeclaration.find(j.Identifier); // get the first NodePath from the Collection const nodePath = identifierCollection.get(0); // get the Node in the NodePath and grab its "name" const localName = nodePath.node.name;
这使我们能够动态地确定我们的geometry
模块已导入为什么。 接下来,我们找到它正在使用的地方并进行更改。 通过查看 AST Explorer,我们可以看到我们需要找到如下所示的 MemberExpressions:
{ "type": "MemberExpression", "object": { "name": "geometry" }, "property": { "name": "circleArea" } }
但是请记住,我们的模块可能已经以不同的名称导入,因此我们必须通过使查询看起来像这样来解决这个问题:
j.MemberExpression, { object: { name: localName, }, property: { name: "circleArea", }, })
现在我们有了一个查询,我们可以将所有调用站点的集合获取到我们的旧方法,然后使用集合的replaceWith()
方法将它们交换出来。 replaceWith()
方法遍历集合,将每个节点路径传递给回调函数。 然后将 AST 节点替换为您从回调中返回的任何节点。
完成替换后,我们照常生成源代码。 这是我们完成的转换:
//deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "geometry" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, }); // get the local name for the imported module const localName = // find the Identifiers importDeclaration.find(j.Identifier) // get the first NodePath from the Collection .get(0) // get the Node in the NodePath and grab its "name" .node.name; return root.find(j.MemberExpression, { object: { name: localName, }, property: { name: 'circleArea', }, }) .replaceWith(nodePath => { // get the underlying Node const { node } = nodePath; // change to our new prop node.property.name = 'getCircleArea'; // replaceWith should return a Node, not a NodePath return node; }) .toSource(); };
当我们通过转换运行源代码时,我们看到geometry
模块中对已弃用方法的调用已更改,但其余部分保持不变,如下所示:
import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.getCircleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));
练习 3:更改方法签名
在前面的练习中,我们介绍了查询特定类型节点的集合、删除节点和更改节点,但是创建全新的节点呢? 这就是我们将在本练习中解决的问题。
在这种情况下,随着软件的发展,我们得到了一个方法签名,该方法签名随着软件的发展而失去了对单个参数的控制,因此决定接受包含这些参数的对象会更好。
而不是car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);
我们想看看
const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, });
让我们首先进行转换和输入文件进行测试:
//signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
//signature-change.input.js import car from 'car'; const suv = car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true); const truck = car.factory('silver', 'Toyota', 'Tacoma', 2006, 100000, true, true);
我们运行转换的命令是jscodeshift -t signature-change.js signature-change.input.js -d -p
,我们需要执行这个转换的步骤是:
- 查找导入模块的本地名称
- 查找 .factory 方法的所有调用站点
- 读取所有传入的参数
- 将该调用替换为包含具有原始值的对象的单个参数
使用 AST Explorer 和我们在前面练习中使用的过程,前两个步骤很简单:
//signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .toSource(); };
为了读取当前传入的所有参数,我们在 CallExpressions 集合上使用replaceWith()
方法来交换每个节点。 新节点将用一个新的单个参数(一个对象)替换 node.arguments。
让我们用一个简单的对象来试一试,以确保我们在使用正确的值之前知道它是如何工作的:
.replaceWith(nodePath => { const { node } = nodePath; node.arguments = [{ foo: 'bar' }]; return node; })
当我们运行这个( jscodeshift -t signature-change.js signature-change.input.js -d -p
)时,转换会爆炸:
ERR signature-change.input.js Transformation error Error: {foo: bar} does not match type Printable
事实证明,我们不能只是将普通对象塞进我们的 AST 节点。 相反,我们需要使用构建器来创建适当的节点。
节点构建器
构建器允许我们正确地创建新节点; 它们由ast-types
提供并通过 jscodeshift 呈现。 他们严格检查是否正确创建了不同类型的节点,这在您快速破解时可能会令人沮丧,但最终,这是一件好事。 要了解如何使用构建器,您应该记住两件事:
所有可用的 AST 节点类型都定义在 ast-types github 项目的def
文件夹中,主要在 core.js 所有 AST 节点类型都有构建器,但它们使用的是驼峰式版本的节点类型,而不是 pascal -案子。 (这没有明确说明,但您可以在 ast-types 源中看到这种情况
如果我们将 AST Explorer 与我们想要的结果示例一起使用,我们可以很容易地将它们拼凑在一起。 在我们的例子中,我们希望新的单个参数是具有一堆属性的 ObjectExpression。 查看上面提到的类型定义,我们可以看到这意味着什么:
def("ObjectExpression") .bases("Expression") .build("properties") .field("properties", [def("Property")]); def("Property") .bases("Node") .build("kind", "key", "value") .field("kind", or("init", "get", "set")) .field("key", or(def("Literal"), def("Identifier"))) .field("value", def("Expression"));
因此,为 { foo: 'bar' } 构建 AST 节点的代码如下所示:
j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]);
获取该代码并将其插入到我们的转换中,如下所示:
.replaceWith(nodePath => { const { node } = nodePath; const object = j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]); node.arguments = [object]; return node; })
运行它可以得到结果:
import car from 'car'; const suv = car.factory({ foo: "bar" }); const truck = car.factory({ foo: "bar" });
既然我们知道了如何创建一个合适的 AST 节点,就很容易循环遍历旧参数并生成一个新对象来使用。 这是我们的signature-change.js
文件现在的样子:
//signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // current order of arguments const argKeys = [ 'color', 'make', 'model', 'year', 'miles', 'bedliner', 'alarm', ]; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .replaceWith(nodePath => { const { node } = nodePath; // use a builder to create the ObjectExpression const argumentsAsObject = j.objectExpression( // map the arguments to an Array of Property Nodes node.arguments.map((arg, i) => j.property( 'init', j.identifier(argKeys[i]), j.literal(arg.value) ) ) ); // replace the arguments with our new ObjectExpression node.arguments = [argumentsAsObject]; return node; }) // specify print options for recast .toSource({ quote: 'single', trailingComma: true }); };
运行转换( jscodeshift -t signature-change.js signature-change.input.js -d -p
),我们将看到签名已按预期更新:
import car from 'car'; const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, }); const truck = car.factory({ color: 'silver', make: 'Toyota', model: 'Tacoma', year: 2006, miles: 100000, bedliner: true, alarm: true, });
带有 jscodeshift 的 Codemods 回顾
达到这一点花费了一些时间和精力,但是在面对大规模重构时,好处是巨大的。 将文件组分配给不同的进程并并行运行它们是 jscodeshift 擅长的,它允许您在几秒钟内跨庞大的代码库运行复杂的转换。 随着您对 codemod 的精通,您将开始重新利用现有脚本(例如 react-codemod github 存储库或为各种任务编写自己的脚本,这将使您、您的团队和您的包用户更有效率.