编写代码以重写代码(jscodeshift)

本文概述

  • 带有jscodeshift的Codemods
  • 什么是jscodeshift?
  • 练习1:删除对控制台的调用
  • 练习2:替换导入的方法调用
  • 练习3:更改方法签名
  • 带有jscodeshift概述的Codemods
带有jscodeshift的Codemods 你使用目录中的查找和替换功能对JavaScript源文件进行了几次更改?如果你还不错, 那么你会很喜欢并在捕获组中使用了正则表达式, 因为如果你的代码库相当大, 那么值得付出努力。正则表达式有局限性。对于非平凡的更改, 你需要一个开发人员, 该开发人员应了解上下文中的代码, 并愿意承担冗长, 繁琐且易于出错的过程。
这是” codemods” 出现的地方。
Codemod是用于重写其他脚本的脚本。将它们视为可以读写代码的查找和替换功能。你可以使用它们来更新源代码, 以适应团队的编码约定, 在修改API时进行大范围更改, 或者在公共包进行重大更改时甚至自动修复现有代码。
编写代码以重写代码(jscodeshift)

文章图片
将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可以记录对象类型并提供其他关键信息。
编写代码以重写代码(jscodeshift)

文章图片
了解节点, 节点路径和集合之间的区别很重要。
练习1:删除对控制台的调用 为了弄清楚我们的脚步, 首先从在代码库中删除对所有控制台方法的调用开始。尽管你可以使用find和replace以及一些正则表达式来做到这一点, 但它在多行语句, 模板文字和更复杂的调用上开始变得棘手, 因此, 这是一个理想的示例。
首先, 创建两个文件, remove-consoles.js和remove-consoles.input.js:
//remove-consoles.jsexport default (fileInfo, api) => { };

//remove-consoles.input.jsexport 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.jsexport 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.jsexport 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.jsexport 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.jsexport 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.jsexport default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };

deprecated.input.jsimport 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节点替换为你从回调返回的任何节点。
编写代码以重写代码(jscodeshift)

文章图片
再次, 了解集合, 节点路径和节点之间的区别对于使之有意义是必要的。
替换完成后, 我们将照常生成源。这是我们完成的转换:
//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.jsexport default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };

//signature-change.input.jsimport 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.jsexport 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。
编写代码以重写代码(jscodeshift)

文章图片
用’ 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.jsexport 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)】要花费一点时间和精力才能达到这一点, 但是面对大规模重构时, 好处是巨大的。 jscodeshift擅长将文件组分配给不同的进程并并行运行它们, 从而使你可以在几秒钟内跨庞大的代码库运行复杂的转换。随着你对Codemod的熟练掌握, 你将开始重新利用现有脚本(例如react-codemod github存储库或为各种任务编写自己的脚本), 这将使你, 你的团队和你的包用户更加高效。

    推荐阅读