编写代码以重写代码:jscodeshift

本文概述

带有jscodeshift的Codemods

你使用目录中的查找和替换功能对JavaScript源文件进行了几次更改?如果你还不错, 那么你会很喜欢并在捕获组中使用了正则表达式, 因为如果你的代码库相当大, 那么值得付出努力。正则表达式有局限性。对于非平凡的更改, 你需要一个开发人员, 该开发人员应了解上下文中的代码, 并愿意承担冗长, 繁琐且易于出错的过程。

这是” codemods”出现的地方。

Codemod是用于重写其他脚本的脚本。将它们视为可以读写代码的查找和替换功能。你可以使用它们来更新源代码, 以适应团队的编码约定, 在修改API时进行大范围更改, 或者在公共包进行重大更改时甚至自动修复现有代码。

jscodeshift工具包非常适合使用codemods。

将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节点替换为你从回调返回的任何节点。

Codemods允许你编写"智能"注意事项脚本以进行重构。

再次, 了解集合, 节点路径和节点之间的区别对于使之有意义是必要的。

替换完成后, 我们将照常生成源。这是我们完成的转换:

//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。

使用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.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存储库或为各种任务编写自己的脚本), 这将使你, 你的团队和你的包用户更加高效。

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?