15分钟阅读

写代码以重写您的代码:JscodeShift

Jeremy是一名高级软件工程师,对现代JavaScript-Client激发了激情&服务器端 - 包括React,Redux,Angular和Express。

Jscodeshift.的CodeMods

您有多少次在目录中使用了查找和替换功能,以更改JavaScript源文件?如果您是好的,您已经抓住了迷人并使用捕获组使用正则表达式,因为如果您的代码库是相同的,则值得努力。然而,Regex有限。对于非琐碎的变化,您需要一个明智者,他们在上下文中理解代码,也愿意承担长期乏味和出错的过程。

这是“CodeMods”进来的地方。

CodeMods是用于重写其他脚本的脚本。将它们视为一个可以读写代码的查找和替换功能。您可以使用它们来更新源代码以适合团队的编码约定,在修改API时,甚至在公共包装逐渐变化时自动修复现有代码的广泛更改。

JscodeShift Toolkit非常适合使用CodeMods。

将CodeMod视为脚本的查找和替换可以读写代码的功能。

在本文中,我们将探索一个名为“jscodeShift”的代码级的工具包,同时创建了三个CodeMod,越来越复杂。到底,您将有广泛地曝光了重要方面 Jscodeshift. 并准备开始写自己的CodeMods。我们将经历三个练习,涵盖一些基本,但很棒的代码,您可以在我的练习中查看源代码 GitHub项目.

什么是Jscodeshift?

JscodeShift Toolkit允许您通过转换泵送一堆源文件,并用另一端替换它们。在变换中,将源解析为抽象语法树(AST),戳绕以进行更改,然后从更改的AST重新生成来源。

The interface that jscodeshift provides is a wrapper around recast and ast-types packages. recast handles the conversion from source to AST and back while ast-types handles the low-level interaction with the AST nodes.

设置

要开始,请从NPM全局安装JscodeShift。

npm i -g jscodeshift

有您可以使用的runner选项以及通过Jest(开源JavaScript测试框架)的套件进行了一系列的经过自由的测试设置,但我们将绕过它,因为现在赞成简单:

Jscodeshift. -t some-transform.js input-file.js -d -p

This will run input-file.js through the transform some-transform.js and print the results without altering the file.

然而,在跳进之前,重要的是要理解JscodeShift API处理的三种主要对象类型:节点,节点路径和集合。

节点

节点是AST的基本构建块,通常被称为“AST节点”。在使用AST Explorer探索您的代码时,这些是您所看到的。它们是简单的对象,不提供任何方法。

节点路径

节点路径 are wrappers around an AST node provided by ast-types as a way to traverse the abstract syntax tree (AST, remember?). In isolation, nodes do not have any information about their parent or scope, so node-paths take care of that. You can access the wrapped node via the node property and there are several methods available to change the underlying node. node-paths are often referred to as just “paths.”

收藏品

集合是零个或多个节点路径的组,jscodeShift API在查询AST时返回。他们有各种各样的方法,其中一些我们将探索。

集合包含节点路径,节点路径包含节点,节点是AST的作用。请记住这一点,很容易理解JscodeShift查询API。

跟踪这些对象与各自API功能之间的差异可能很困难,因此有一个漂亮的工具 Jscodeshift-helper. 记录对象类型并提供其他关键信息。

知道节点,节点路径和集合之间的区别很重要。

知道节点,节点路径和集合之间的区别很重要。

练习1:删除对控制台的呼叫

为了让我们的脚湿润,让我们从删除对代码库中所有控制台方法的调用。虽然您可以使用查找和替换和一点正则表达式执行此操作,但它开始与多行语句,模板文字和更复杂的呼叫棘手,因此它是首先启动的理想示例。

First, create two files, remove-consoles.js and 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:路径必须是字符串或缓冲区”如果找不到输入文件。如果您胖了胖的东西,应该易于发现具有非常描述性的转换错误。

我们的最终目标虽然,在成功转变后,是看这个来源:

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.. Paste the contents of remove-consoles.input.js into it and you’ll see the AST. There is a lot of data even in the simplest code, so it helps to hide location data and methods. You can toggle the visibility of properties in AST Explorer with the checkboxes above the tree.

We can see that calls to console methods are referred to as CallExpressions, so how do we find them in our transform? We use jscodeshift’s queries, remembering our earlier discussion on the differences between Collections, node-paths and nodes themselves:

//remove-consoles.js

export default (fileInfo, api) => {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  return root.toSource();
};

The line const root = j(fileInfo.source); returns a collection of one node-path, which wraps the root AST node. We can use the collection’s find method to search for descendant nodes of a certain type, like so:

const callExpressions = root.find(j.CallExpression);

This returns another collection of node-paths containing just the nodes that are CallExpressions. At first blush, this seems like what we want, but it is too broad. We might end up running hundreds or thousands of files through our transforms, so we have to be precise to have any confidence that it will work as intended. The naive find above would not just find the console CallExpressions, it would find every CallExpression in the source, including

require('foo')
bar()
setTimeout(() => {}, 0)

To force greater specificity, we provide a second argument to .find: An object of additional parameters, each node needs to be included in the results. We can look at the AST Explorer to see that our console.* calls have the form of:

{
  "type": "CallExpression",
  "callee": {
    "type": "MemberExpression",
    "object": {
      "type": "Identifier",
      "name": "console"
    }
  }
}

通过该知识,我们知道将您的查询与将只能返回的标本符,只返回我们对的Callex压缩类型:

const callExpressions = root.find(j.CallExpression, {
  callee: {
    type: 'MemberExpression',
    object: { type: 'Identifier', name: 'console' },
  },
});

Now that we’ve got an accurate collection of the call sites, let’s remove them from the AST. Conveniently, the collection object type has a remove method that will do just that. Our remove-consoles.js file will now look like this:

//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();
};

Now, if we run our transform from the command line using Jscodeshift. -t remove-consoles.js remove-consoles.input.js -d -p, we should see:

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

It looks good. Now that our transform alters the underlying AST, using .toSource() generates a different string from the original. The -p option from our command displays the result, and a tally of dispositions for each file processed is shown at the bottom. Removing the -d option from our command, would replace the content of remove-consoles.input.js with the output from the transform.

我们的第一个练习完成......几乎。代码是奇异的看起来,可能非常冒犯到那里的任何功能纯粹主义者,所以要使变换代码流得更好,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:替换导入的方法调用

For this scenario, we’ve got a “geometry” module with a method named “circleArea” that we’ve deprecated in favor of “getCircleArea.” We could easily find and replace these with /geometry\.circleArea/g, but what if the user has imported the module and assigned it a different name? For example:

import g from 'geometry';
const area = g.circleArea(radius);

How would we know to replace g.circleArea instead of geometry.circleArea? We certainly cannot assume that all circleArea calls are the ones we’re looking for, we need some context. This is where codemods start showing their value. Let’s start by making two files, deprecated.js and 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

您应该看到输出指示变换ran,但尚未更改任何内容。

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

We need to know what our geometry module has been imported as. Let’s look at the AST Explorer and figure out what we’re looking for. Our import takes this form.

{
  "type": "ImportDeclaration",
  "specifiers": [
    {
      "type": "ImportDefaultSpecifier",
      "local": {
        "type": "Identifier",
        "name": "g"
      }
    }
  ],
  "source": {
    "type": "文字",
    "value": "geometry"
  }
}

我们可以指定一个对象类型以查找这样的节点集合:

const importDeclaration = root.find(j.ImportDeclaration, {
    source: {
      type: 'Literal',
      value: 'geometry',
    },
  });

这使我们用于导入“几何”的ImportedEclaration。从那里,挖掘以查找用于保存导入模块的本地名称。由于这是我们第一次完成它,让我们在第一次开始时指出一个重要和令人困惑的点。

Note: It’s important to know that root.find() returns a collection of node-paths. From there, the .get(n) method returns the node-path at index n in that collection, and to get the actual node, we use .node. The node is basically what we see in AST Explorer. Remember, the node-path is mostly information about the scope and relationships of the node, not the node itself.

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

This allows us to figure out dynamically what our geometry module has been imported as. Next, we find the places it is being used and change them. By looking at AST Explorer, we can see that we need to find MemberExpressions that look like this:

{
  "type": "MemberExpression",
  "object": {
    "name": "geometry"
  },
  "property": {
    "name": "circleArea"
  }
}

但是,请记住,我们的模块可能已以不同的名称导入,因此我们必须通过使我们的查询看起来像这样,所以

j.MemberExpression, {
  object: {
    name: localName,
  },
  property: {
    name: "circleArea",
  },
})

Now that we have a query, we can get a collection of all the call sites to our old method and then use the collection’s replaceWith() method to swap them out. The replaceWith() method iterates through the collection, passing each node-path to a callback function. The AST Node is then replaced with whatever Node you return from the callback.

CodeMods允许您脚本'intelligent'重构的考虑因素。

再次,了解收集,节点路径和节点之间的差异是为了实现意义。

一旦我们完成替换,我们就会像往常一样生成源。这是我们完成的转换:

//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();
};

When we run the source through the transform, we see that the call to the deprecated method in the geometry module was changed, but the rest was left unaltered, like so:

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);

Our command to run the transform will be Jscodeshift. -t signature-change.js signature-change.input.js -d -p and the steps we need to perform this transformation are:

  • 查找导入模块的本地名称
  • 查找所有呼叫站点到.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();
};

For reading all the arguments currently being passed in, we use thereplaceWith() method on our collection of CallExpressions to swap each of the nodes. The new nodes will replace node.arguments with a new single argument, an object.

使用JscodeShift轻松交换方法参数!

将方法签名更改为“replacewith()”并交换整个节点。

让我们尝试使用一个简单的对象,以确保我们知道在使用适当的值之前如何工作:

    .replaceWith(nodePath => {
      const { node } = nodePath;
      node.arguments = [{ foo: 'bar' }];
      return node;
    })

When we run this (Jscodeshift. -t signature-change.js signature-change.input.js -d -p), the transform will blow up with:

 ERR signature-change.input.js Transformation error
Error: {foo: bar} does not match type Printable

事实证明,我们不能只是将普通对象堵塞到我们的AST节点中。相反,我们需要使用Builders创建适当的节点。

节点建设器

Builders allow us to create new nodes properly; they are provided by ast-types and surfaced through jscodeshift. They rigidly check that the different types of nodes are created correctly, which can be frustrating when you’re hacking away on a roll, but ultimately, this is a good thing. To understand how to use builders, there are two things you should keep in mind:

全部 of the available AST node types are defined in the deffolder of the AST型GitHub项目,主要是核心.js 所有AST节点类型都有建设者,但它们使用 骆驼壳 节点类型的版本,而不是 帕斯卡案例。 (这没有明确说明,但你可以看到这是这种情况 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("文字"), def("Identifier")))
    .field("value", def("Expression"));

因此,构建AST节点的代码为{foo:'bar'}看起来像:

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"
});

Now that we know how to create a proper AST node, it’s easy to loop through the old arguments and generate a new object to use, instead. Here’s what our signature-change.js file looks like now:

//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 });
};

Run the transform (Jscodeshift. -t signature-change.js signature-change.input.js -d -p) and we’ll see the signatures have been updated as expected:

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. recap的CodeMods

达到这一点需要一段时间和精力,但面对大规模重构时的好处是巨大的。将文件组分发到不同的进程并并行运行它们是JscodeShift excels的东西,允许您以秒为单位运行巨大的CodeBase的复杂转换。随着您变得更熟练的CodeMods,您将开始重新修复现有脚本(例如 React-CodeMod GitHub存储库 或者为各种各样的任务编写自己,这将使您的团队和您的包装用户更高效。