6

使用Jscodeshift做自动化重构

 2 years ago
source link: https://developer.51cto.com/article/705260.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者 | 邱俊涛

在这篇文章里我想要通过一些小例子来介绍使用jscodeshift来进行自动化重构的技术。具体来说,我想要介绍在一个组件库的开发和维护过程中,如何使用jscodeshift来自动修改公开的API接口,从而尽可能小的产生对组件用户的影响。

如果你们团队开发的组件被其消费者(组织内部或者外部)使用了,而这些代码又不在你的控制之内,那么这里讨论的技术和模式可能对你很有帮助。而如果你的日常工作更多的是使用组件库来开发应用程序,我希望这里的知识和技巧仍然对你有所启发,毕竟在软件系统中,我们往往都既是某些库的消费者,又同时是另外一些库的生产者。

从一个简单场景出发

2218423719fb9c17038587df78e918e082b27b.png

设想这样一个场景,你发布了一个酷炫的组件库(fancylib),其中有一个按钮(Button)组件。这个Button的一个属性是当点击后处于加载中(loading)状态时现实一个表示加载中的小图标。

b3b771938bc5f715539574b78025e6bd52ab82.png

(图片来源:https://xd.adobe.com/ideas/process/ui-design/designing-interactive-buttons-states/)

在代码实现中,这个加载中状态被定义为了名为isInLoadingStatus公开prop。用户可以通过设置其值来控制Button的状态:

import Button from '@fancylib/button';

const app = () => (
    <Button isInLoadingStatus>Click me</Button>
)

一个实习生在某一天code review的时候提出了一个问题:在组件库中的其他地方,所有的boolean状态都是用一个单词来表示的,比如checked, disabled等。如果按照这个惯例,这里应该把isInLoadingStatus简化为loading。好主意!

import Button from '@fancylib/button';

const app = () => (
    <Button loading>Click me</Button>
)

假如所有用到Button的地方都在你的控制之内,字符串替换大约是一个快速且80%有效的方案。不过稍微分析一下,你就会发现简单的Shift+F6会遇到很多问题。

比如用户对其做了二次包装以适配更符合自己用户的使用习惯,这使得简单的全局字符串替换变成了不可能::

import Button as FancyButton from '@fancylib/button';

const MyEvenFancierButton = (props: FancyButtonProps) => (
    const theme = {
        backgroundColor: "orangered",
        color: "white"
    };
    <FancyButton {...props} theme={theme}>Click me</FancyButton>
);

除了这些问题之外,由于这是一个非常受欢迎的组件库,Button在很多(包括内部和外部的)产品中都有使用,你没有办法访问所有的用户代码,更没有办法让所有人都用手工的查找替换来做更新,你需要另寻出路。

你需要一个工具 -- 一个可以读懂代码意图的工具 -- 来帮助你做修改,而且整个过程最好可以自动化,比如通过执行一个脚本来完成。

使用jscodeshift

jscodeshift就是这样一个工具(工具集)。简单来说,jscodeshift的工作方式就是将源代码分析成一棵树(抽象语法树),然后提供API来修改这棵树,最后再把树生成为代码。

a3525c2702c27cdbfde6951fbc2aeaa725de8e.png

也就是说,她可以读懂你的代码,并提供指令(API)来根据你的意愿修改相应的代码。

接下来,我们可以通过实现一个可以完成上述场景的自动重构的脚本来对jscodeshift的使用做一个简单介绍。简单来说,jscodeshift的工作流程是:首先你需要定义一个转换脚本(transform),这个脚本需要符合一定的规范以便jscodeshift调用;然后jscodeshift的命令行工具会启动runner,并将转换脚本应用到某个文件或者某个文件夹中的所有文件中:

jscodeshift -t myTransform src

定义一个transform

也就是说,我们所有的逻辑都会定义在转换脚本中。transform脚本需要导出一个固定格式的函数:

import { Transform } from "jscodeshift";

const transform: Transform = (file, api, options) => {
  //...
};

export default transform;

file为解析后的文件对象,api是jscodeshift的API对象,可以通过它来查找,修改文件对象,options是一个可选的,用来传递其他参数(比如格式化最终输出格式等)的对象。在函数体中,我们可以使用jscodeshift提供的API来操纵抽象语法树(Abstract Syntax Tree)来实现对代码的修改。这个过程和通过DOM API来操作浏览器中的页面元素非常类似:按照属性查找元素,对查找结果进行增删改等操作,只不过这里的操作对象是语法树(比如变量定义,函数体,条件语句等等)。

在详细讨论如何使用jscodeshift的API来修改代码之前,我们来略微看一下抽象语法树的概念。这将是我们脚本需要操作的主要对象。

抽象语法树AST

抽象语法树,是编译器将源码解析(parse)之后形成的一课树形结构。简单来说,我们的代码被解析成为Token,Token再根据语法规则形成子树,子树最终根据文法归并成一颗树。我们可以通过AST Explorer工具来实时查看代码对应的语法树。

举个例子,我们的代码片段:

import Button from '@fancylib/button';

const app = () => (
    <Button isInLoadingStatus>Click me</Button>
)

经过解析(jscodeshift默认使用babel来解析,你可以选择其他的解析器)之后,会形成右侧的一颗树,比如isInLoadingStatus被识别成JSXIdentifier类型,而变量app定义则被识别为VariableDeclarator等。所有符合语法的元素都会被抽取成Token,并体现为树上的一个节点。

08e443403cd24779a82608f8db5b893093bb44.png

有了这些基本概念之后,我们就可以开始编写一个简单的transform了。这里我们可以通过AST Explorer提供的在线IDE中的Transform功能来实时调试(此处选择jscodeshift作为转换器)。

然后我们定义这样一个转换函数:

// Press ctrl+space for code completion
export default function transformer(file, api) {
  const j = api.jscodeshift;

  return j(file.source)
    .find(j.JSXIdentifier)
    .forEach(path => {
        if(path.node.name === "isInLoadingStatus") {
          j(path).replaceWith(
            j.identifier('loading')
          )
        }
    })
    .toSource();
}

比如上述代码中,我们查找所有的j.JSXIdentifier,并迭代每一个找到的节点,如果它的值是isInLoadingStatus的话,就将其替换为loading。可以观察到右下侧的调试器窗口中的转换结果:

0444e4e4508c52161833916ea40a0b3ea8ebc1.png

测试驱动开发

当然了,作为一个严肃的程序员,我们不应该通过一个在线IDE来进行开发。幸运的是jscodeshift可以和jest完美配合,同时我发现编写自动化脚本是一个非常适合测试驱动开发的场景:

  • 输入输出都非常明确
  • 各种不同的边界场景很容易想象/编写成用例
  • 每一个步骤都可以划分的比较小

jscodeshift提供了一个小工具defineInlineTest,通过它你可以很方便的定义测试用例:

import { defineInlineTest } from 'jscodeshift/dist/testUtils';
import transformer from './transformer';

describe('transformer', () => {
    defineInlineTest(
    { default: transformer, parser: 'tsx' }, 
    {},
    `
    import Button from '@fancylib/button';
  
    export default () => (
      <Button isInLoadingStatus>Click me</Button>
    );
    `,
    `
    import Button from '@fancylib/button';
  
    export default () => (
      <Button loading>Click me</Button>
    );
    `,
    'change isInLoadingStatus to loading'
  );
});

当然,如果你不习惯字符串模板的话,它同时还提供了基于文件形式的测试定义,这样你可以将测试的输入(转化前)和输出(转化后)外置到文件中,并在其中构建较为复杂的使用场景。

比如我们希望这个transform不要误伤我们代码中使用的其他Button,比如我们使用了另外一个组件库,而巧合的是那个库中Button也有一个isInLoadingStatus。

那么对应的测试用例会是:

   defineInlineTest(
    { default: transformer, parser: 'tsx' }, 
    {},
    `
    import Button from '@facebook/button';
  
    export default () => (
      <Button isInLoadingStatus>Click me</Button>
    );
    `,
    `
    import Button from '@facebook/button';
  
    export default () => (
      <Button isInLoadingStatus>Click me</Button>
    );
    `,
    'should not change isInLoadingStatus to loading from other package'
  );

对应的我们需要在代码中加入相应的逻辑:

// Press ctrl+space for code completion
export default function transformer(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  const specifiers = root
    .find(j.ImportDeclaration)
    .filter((path) => path.node.source.value === "@fancylib/button")
    .find(j.ImportDefaultSpecifier);

  if (specifiers.length === 0) {
    return;
  }
  
  //...
}

即,我们先查找所有的import语句,如果没有找到从@fancylib/button导入的Button就跳过后续的操作。你应该已经注意到了,我们这里又很多的诸如j.ImportDeclaration和j.ImportDefaultSpecifier之类的Token定义,你可以从AST Explorer的树结构中找到类似的名称,然后用jscodeshift的API来查找并访问改节点。

这个过程或多或少有点像我们通过DOM的API来选择HTML节点一样:

document.querySelectorAll('a')
    .filter(anchor => anchor.classList.includes('button'))
    .forEach(anchor => anchor.style["text-decoration"] = "underline")

如果你觉得这里要素太多,这是很正常的。尝试着多写几个就会发现规律。

如果把所有的实现细节都列举在一篇文章中,我觉得文章会非常枯燥(可能写成一个系列教程等),因此这里我不再贴代码,相关的源码可以在https://github.com/abruzzi/codemod-demo找到。

可能的陷阱

使用脚本来自动化重构的想法当然非常有诱惑了,特别是对于疲于为已经公布的API打补丁的人们来说,简直太过于美好。不过公平起见,我还是得略微说一些它的一些drawbacks。

首先,jscodeshift 的API略显晦涩,有一定的学习成本。开发过程中可能会有很多调试的工作。其次,它并不定覆盖100%的使用场景,比如对于复杂的spreading操作,需要调试和分析的工作量不容小觑,也就是说你仍然需要人工校对一些edge cases。最后,需要一些脚本来支持组件的消费团队使用,比如自动化补丁工具等,如果有多个transform,如何一次patch等问题。

在这篇文章中,我们从一个简化了的实际例子出发,描述了为何jscodeshift在某些场景下可以提供的帮助,比如降低大型修改可能带来的影响(而如果影响不可避免,那么如何使其变得不那么痛苦)。随后我们描述了jscodeshift中的一些基本概念和基本的工作方式,并结合之前讨论的例子实现了部分的自动化重构。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK