本文概述
- 带有jscodeshift的Codemods
- 什么是jscodeshift?
- 练习1:删除对控制台的调用
- 练习2:替换导入的方法调用
- 练习3:更改方法签名
- 带有jscodeshift概述的Codemods
带有jscodeshift的Codemods
你使用目录中的查找和替换功能对JavaScript源文件进行了几次更改?如果你还不错, 那么你会很喜欢并在捕获组中使用了正则表达式, 因为如果你的代码库相当大, 那么值得付出努力。正则表达式有局限性。对于非平凡的更改, 你需要一个开发人员, 该开发人员应了解上下文中的代码, 并愿意承担冗长, 繁琐且易于出错的过程。
这是” codemods”出现的地方。
Codemod是用于重写其他脚本的脚本。将它们视为可以读写代码的查找和替换功能。你可以使用它们来更新源代码, 以适应团队的编码约定, 在修改API时进行大范围更改, 或者在公共包进行重大更改时甚至自动修复现有代码。
将codemods视为可以读取和写入代码的脚本式查找和替换功能。
鸣叫
在本文中, 我们将探索一个名为” jscodeshift”的代码模块工具包, 同时创建三个复杂性不断提高的代码模块。到最后, 你将广泛了解jscodeshift的重要方面, 并准备开始编写自己的codemod。我们将进行三个练习, 这些练习涵盖了codemods的一些基本但很棒的用法, 你可以在我的github项目上查看这些练习的源代码。
什么是jscodeshift?
jscodeshift工具箱允许你通过转换来泵送一堆源文件, 并用另一端的源文件替换它们。在转换内部, 你将源解析为抽象语法树(AST), 在其中进行更改, 然后从更改后的AST重新生成源。
jscodeshift提供的接口是recast和ast-types包的包装。重铸可处理从源到AST的转换, 而ast-types可处理与AST节点的低级交互。
设定
首先, 从npm全局安装jscodeshift。
npm i -g jscodeshift
你可以使用运行程序选项和经过验证的测试设置, 这些设置使通过Jest(开放源代码JavaScript测试框架)运行一系列测试非常容易, 但是为了简单起见, 我们暂时不进行测试:
jscodeshift -t some-transform.js输入文件.js -d -p
这将通过转换some-transform.js运行input-file.js并在不更改文件的情况下打印结果。
但是, 在进入之前, 重要的是要了解jscodeshift API处理的三种主要对象类型:节点, 节点路径和集合。
节点数
节点是AST的基本组成部分, 通常称为” AST节点”。这些是使用AST Explorer浏览代码时看到的。它们是简单的对象, 不提供任何方法。
节点路径
节点路径是ast类型提供的AST节点周围的包装器, 是遍历抽象语法树(AST, 还记得吗?)的一种方法。孤立地, 节点没有关于其父节点或作用域的任何信息, 因此节点路径会处理这些信息。你可以通过node属性访问包装的节点, 并且有几种方法可以更改基础节点。节点路径通常被称为”路径”。
馆藏
集合是当你查询AST时jscodeshift API返回的零个或多个节点路径的组。它们具有各种有用的方法, 我们将探索其中的一些方法。
集合包含节点路径, 节点路径包含节点, 而节点是AST的组成部分。记住这一点, 将很容易理解jscodeshift查询API。
跟踪这些对象及其各自的API功能之间的差异可能很困难, 因此有一个漂亮的工具jscodeshift-helper可以记录对象类型并提供其他关键信息。
了解节点, 节点路径和集合之间的区别很重要。
练习1:删除对控制台的调用
为了弄清楚我们的脚步, 首先从在代码库中删除对所有控制台方法的调用开始。尽管你可以使用find和replace以及一些正则表达式来做到这一点, 但它在多行语句, 模板文字和更复杂的调用上开始变得棘手, 因此, 这是一个理想的示例。
首先, 创建两个文件, 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。如果运行转换的命令不正确, 如果找不到输入文件, 则会显示”错误转换文件……不存在”消息或” TypeError:路径必须是字符串或缓冲区”。如果你胖了一些, 应该很容易发现非常描述性的转换错误。
相关:srcmini的快速实用JavaScript备忘单:ES6及更高版本
但是, 在成功完成转换之后, 我们的最终目标是查看此源:
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资源管理器。将remove-consoles.input.js的内容粘贴到其中, 你将看到AST。即使使用最简单的代码也有很多数据, 因此它有助于隐藏位置数据和方法。你可以使用树上方的复选框切换AST Explorer中属性的可见性。
我们可以看到对控制台方法的调用称为CallExpressions, 那么如何在转换中找到它们?我们使用jscodeshift的查询, 还记得我们之前关于Collection, 节点路径和节点本身之间的区别的讨论:
//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节点的节点路径集合。乍一看, 这似乎是我们想要的, 但是它太宽泛了。我们可能最终通过转换运行了数百或数千个文件, 因此我们必须非常精确, 以确保它可以按预期运行。上面朴素的查找不仅会找到控制台CallExpressions, 还会找到源代码中的每个CallExpression, 包括
require('foo')
bar()
setTimeout(() => {}, 0)
为了提高特异性, 我们为.find提供了第二个参数:一个带有附加参数的对象, 每个节点都需要包含在结果中。我们可以查看AST资源管理器以查看我们的控制台。*调用的形式为:
{
"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”的方法的” geometry”模块, 我们已弃用了” 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
我们需要知道什么导入了几何模块。让我们看一下AST浏览器, 找出我们要寻找的东西。我们的进口采用这种形式。
{
"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;
这使我们可以动态地找出已导入几何模块的内容。接下来, 我们找到它的使用位置并进行更改。通过查看AST Explorer, 我们可以发现我们需要找到如下所示的MemberExpression:
{
"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集合上使用thereplaceWith()方法来交换每个节点。新节点将用新的单个参数(对象)替换node.arguments。
用’replacewith()’更改方法签名并换出整个节点。
让我们尝试一个简单的对象, 以确保在使用适当的值之前我们知道它是如何工作的:
.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节点。相反, 我们需要使用构建器来创建适当的节点。
相关:雇用3%的自由JavaScript开发人员。
节点构建器
构建器使我们能够正确创建新节点;它们由ast-types提供, 并通过jscodeshift浮出水面。他们严格检查是否正确创建了不同类型的节点, 当你一劳永逸地投入工作时, 这可能会令人沮丧, 但是最终, 这是一件好事。要了解如何使用构建器, 应牢记两件事:
所有可用的AST节点类型都在ast-types github项目的deffolder中定义, 主要是在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存储库或为各种任务编写自己的脚本), 这将使你, 你的团队和你的包用户更加高效。