借助 JSCodeshfit 快速重构、升级、迁移

avatar
FE @字节跳动

这篇文章将会带你认识 jscodeshift ——一个超级实用的代码转换工具,你可以用它实现大型代码重构、升级等工作。

接下来将以笔者遇到业务问题为背景,介绍 jscodeshift 相关概念和基础用法(如何查询节点、修改节点、新建节点),以及涉及到的数据结构 CollectionsNodePaths,最后介绍了 jscodeshift 更多的使用场景以及丰富的社区资源。

业务背景

维护老的代码库通常是令人非常头痛的,里面有大量的老旧代码。我们很难完全及时地跟上不断变化的新 JavaScript 标准、语法、编码规范、以及一些第三方库的 break changes,这些老旧代码成为了代码迁移和升级路上的巨大绊脚石。例如自建平台老的代码,老的接口调用是基于 graphQL 写的,需要改写成 useRequest 的方式。(为什么要改造可以点这里BFF+Ferry 替换 GraphQL 改造记录 )。

改写方式如下,可以看到老的代码和新的代码有一定的映射关系。

  • Case1. const [...] = useXXLazyQuery({...})

  • Case2. const {....} = useXXQuery({})

而整个项目中一共有一百多处这样的接口调用需要修改,如果全部是手动改,有很多的弊端:

  • 耗时巨大

  • 手动处理大量的重复的无聊的东西,可能存在失误

  • 同时造成很多文件的修改,假如你和你的同事在不同的分支处理代码,合并的时候需要解决冲突

于是笔者想能不能 write some codes/scripts to rewrite my codes 呢?就像许多 JS 框架(eg. reactAnt designnext.js)都提供自己了的 codemods ,来帮助用户快速地迁移到新的 API 或升级框架的版本。这里我们同样需要一个脚本帮助我们完成自动化更改,这样的话:

  • 我们就只需要编写 codemod

  • 我们只会对 codemod 产生更改,不涉及到源代码文件的更改

  • 在预发布分支上运行 codemod,在合并到 master 之前对其进行测试和发布。

  • 任何在拉取 master 后发生冲突的人都可以忽略更改,并在重新在他们的分支上重新运行 codemod。

概念简介

我们可以通过 jscodeshift 等自动化工具来轻松的实现 codemod。接下来简单介绍一些相关的概念。

Codemod

Code that is written with the sole intent of transforming other code. An example would be a piece of code that takes a normal function, and rewrites it to be an arrow function.

Reference

Codemod 有两个含义。一个是指 Facebook 开发的,用于重构大规模代码库的 Python 工具(这个工具类似于正则匹配替换,功能有限,且一次只能处理一个文件)。现在,codemod 的概念更广义,通常是指,仅以转换其他代码为目的而编写的代码(例如写一段代码,将普通函数重写成箭头函数)。从技术发展的历程看,codemod 经历了3个阶段,最初是简单的字符串替换技术,后来到复杂的正则表达式。再到现在的,我们可以使用抽象语法树 ( AST ) 来遍历多种语言的源代码,这使得 codemod 更加地安全、强大、快速和容易。

AST (Abstract Syntax Tree)

在计算机科学中,抽象语法树( Abstract Syntax Tree,AST ),或简称语法树( Syntax tree ),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

Wiki:抽象语法树

babeljsrecasteslint 这些工具会将原文件解析( parse )为由若干节点组成的 AST ,然后对这些“节点”进行一些操作( mutate ),再将它们转化成源码输出到文件中。

Code — AST — AST — Code

图片来源

不同的库(例如 babel 和 reacts )解析出的 AST 并不是完全相同,现在我们来看下 recast 对这段代码console.log('Hello, World');的解析结果:

const AST = {
  type: 'File',
  program: {
    type: 'Program',
    sourceType: 'module',
    body: [
      {
        type: 'ExpressionStatement'
        expression: {
          type: 'CallExpression',
          callee: {
            type: 'MemberExpression',
            object: {
              type: 'Identifier',
              name: 'console',
            },
            computed: false,
            property: {
              type: 'Identifier',
              name: 'log',
            },
          },
          arguments: [
            {
              type: 'StringLiteral',
              extra: {
                rawValue: 'Hello, World',
                raw: "'Hello, World'",
              },
              value: 'Hello, World',
            },
          ],
        },
      },
    ],
  },
};

可以看到每个节点都是有类型的,你不需要知道每种 AST 的节点类型,可以通过 AST explorer 在线查看AST的结构。

recast

recast 是 jscodeshift 用来解析( parse )、转换( transform )和输出( output )文件 的底层库。

recast 本身重度依赖于 ast-types。ast-types 里定义了一些遍历 AST、访问节点字段,以及构建新节点的方法,它将每个 AST 节点包装成一个 node-path,node-path里包含了 AST 节点的元信息和处理AST节点的工具方法。

recast提供了两个基础接口,一个( . parse )用于解析 Javascript 代码,另一个( .print )用于打印修改后的语法树(它会尽可能多地保留现有格式的代码)。

下面是如何使用 .parse 和 . print/.prettyPrint 的例子:

import * as recast from "recast";

// Let's turn this function declaration into a variable declaration.
const code = [
  "function add(a, b) {",
  "  return a +",
  "    // Weird formatting, huh?",
  "    b;",
  "}"
].join("\n");

// Parse the code using an interface similar to require("esprima").parse.
const ast = recast.parse(code);

现在,你可以对 ast进行操作,然后用 recast.print 打印结果:

// 这里使用的prettyPrint , 可以美化输出
var output = recast.prettyPrint(ast, { tabWidth: 2 }).code;
// output
var add = function(b, a) {
  return a + b;
}
// 可以看到格式已被美化

jscodeshift

在 2015 年的 JSConf EU上,来自 Facebook 的Chris Pojer介绍了一个名为jscodeshift的工具。它是一个 codemod 运行器,包装了 recast,同时提供了不同于 recast 的 jQuery-like API,更加方便我们遍历、搜索和更改源代码。总的说:

  • 它提供用于执行 transforms 的 CLI 和用于操作 AST 的类似 jQuery 的 API
  • AST 转换是使用 recast 的包装器执行的
  • AST 在 ast-types 中实现,它本身基于 esprima

简单对比下 reacst 和j scodeshift 的使用:

// recast
// 先解析srouce
var ast = recast.parse(src);
//不支持链式调用
recast.visit(ast, {
  visitIdentifier: function(path) {
    // do something with path
    return false;
  }
});

// jscodeshift
/**
 * This replaces every occurrence of variable "foo".
 */
module.exports = function(fileInfo, api, options) {
  return api.jscodeshift(fileInfo.source) // source -> ast nodes -> collections
    .findVariableDeclarators('foo') // collection.find
    .renameTo('bar') //chainCall
    .toSource(); // ast -> source string
}
// 可以看到支持jQuery-like API的关键在于collection,这个后面会讲到

Nodes

节点是 AST 的基本构成单元,也被称为“ AST 节点”。在 AST Explorer 上可以看到节点的内部结构,它本身是一个简单的对象,并不提供任何方法。

Node-paths

节点路径( Node-paths )是由 ast-types 提供的 AST 节点的包装器,用来遍历抽象语法树( AST )。节点本身上是没有关于其父级的任何信息的,这些由 Node-paths 负责。你可以通过 node-path 上的node 属性来访问节点内容。

Collections

Collections(集合)是 由 jscodeshift 提供的,它是由 jscodeshift 这个 API 在查询 AST 时返回的0 个或多个 node-paths 的 group。

所以你需要记住 Collections 包含 node-paths,node-paths 包含 node,而 node 是 AST 的组成单元

了解节点、节点路径和集合之间的区别很重要。

图片来源

查看更多的 collection 上定义的方法可以戳这里 Collection.js ,还有它的3种扩展.

图片来源

Builders

在编写 codemod 的时候,collection 可以为我们提供一些便利的查找和更改方法,那么创建新的节点怎么办呢?为了让创建 AST 节点更加的方便和安全,ast-types 定义里一些 builder 方法,而 jscodeshift 对外暴露了这些方法。

例如,下面的代码创建了一个等价于foo(bar)和一个{ foo: 'bar' } 的 AST:

// inside a module transform
var j = jscodeshift;
// foo(bar);
var ast = j.callExpression(
  j.identifier('foo'),
  [j.identifier('bar')]
);

// { foo: 'bar' }
j.objectExpression([
  j.property('init',
    j.identifier('foo'),
    j.literal('bar')
  )  
]);

所有可用的 AST 节点类型都定义在ast-types github 项目 的 def 文件夹中,主要在 core.js 中。另外,我们通过 jscodeshift 提供的 ts 类型定义 nameTypes 也能够了解到有哪些节点类型以及提供哪些构造器。

// ast-types@0.14.2/node_modules/ast-types/gen/namedTypes.d.ts
export declare namespace namedTypes {
    interface Printable {
        loc?: K.SourceLocationKind | null;
    }
    interface SourceLocation {
        start: K.PositionKind;
        end: K.PositionKind;
        source?: string | null;
    }
    interface Node extends Printable {
        type: string;
        comments?: K.CommentKind[] | null;
    }
    interface Comment extends Printable {
        value: string;
        leading?: boolean;
        trailing?: boolean;
    }
    interface Position {
        line: number;
        column: number;
    }
    //...
}

// ast-types@0.14.2/node_modules/ast-types/gen/builders.d.ts
export interface builders {
    file: FileBuilder;
    program: ProgramBuilder;
    identifier: IdentifierBuilder;
    blockStatement: BlockStatementBuilder;
    emptyStatement: EmptyStatementBuilder;
    expressionStatement: ExpressionStatementBuilder;
    ifStatement: IfStatementBuilder;
    labeledStatement: LabeledStatementBuilder;
    breakStatement: BreakStatementBuilder;
    continueStatement: ContinueStatementBuilder;
    withStatement: WithStatementBuilder;
    switchStatement: SwitchStatementBuilder;
    switchCase: SwitchCaseBuilder;
    returnStatement: ReturnStatementBuilder;
    throwStatement: ThrowStatementBuilder;
    tryStatement: TryStatementBuilder;
    catchClause: CatchClauseBuilder;
    whileStatement: WhileStatementBuilder;
    doWhileStatement: DoWhileStatementBuilder;
    forStatement: ForStatementBuilder;
    //...
 }

小结

上面提到了 recast 包装了 ast-types,jscodeshift 又包装了 reacst,下图描述了 jscodeshift 与 recast、ast-types 之间的合作关系以及它的执行流程。

练习

介绍了 jscodeshift 相关的概念和原理后,让我们来完成几个简单的练习吧。

  1. 删除所有的console调用

这个场景很常见,在推送代码前你需要手动检查 console 的调用,并删除。虽然你也可以通过查找和替换或者正则的方式来实现,但是对于多行语句、模板文字或者一些更复杂的调用情况就没那么容易了,让我们用 jscodeshift 来试试吧。

Playground - remove all calls to console (你可使用 ast-finder 自动生成 finder 语句)

AST Explorer 中可以实时查看语句对应的 AST 节点

 //remove-consoles.js 
export  default (fileInfo, api) => {  const j = api. jscodeshift ;  // 返回一个collection, 里面包装了根AST Node。   // 我们可以使用collection的find方法来搜索某种type的节点   const root = j (fileInfo. source );   return ( root . find (  // 返回一个collection,包含所有type为CallExpressio的node-path。  j. CallExpression ,  // matchNode精确查找  {  callee : {  type : "MemberExpression" ,  object : { type : "Identifier" , name : "console" },  // property: { type: 'Identifier', name: 'log' },  }, } )  // 从AST中移除该节点  . remove () . toSource () ); }; 

remove-consoles.js

  1. 替换调用的方法名

在这个场景中,我们需要将 geometry.circleArea 替换成 geometry.getCircleArea,你也许会想用 /geometry.circleArea/g 来查找和替换所有的方法名,但是如果用户如果对 import 的module 进行了重命名,正则就处理不了这种情况了。

Playground - Replacing Imported Method Calls

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

input.js

// outputs.js
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);

output.js

// transform.ts

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

transform.ts

  1. 更改方法签名

在上面的练习中,我们学会了如何查询指定 type 的节点,移除节点、还有替换节点。现在我们试着创建新的节点。

场景如下,随着这个方法的单个参数越来越多,代码变得不直观,我们需要将方法签名改成传递 object 的方式。

Playground - Changing a Method Signature(使用 ast-builder 自动生成 builder 语句)

// input.js
car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);

input.js

// output.js
const suv = car.factory({
  color: 'white',
  make: 'Kia',
  model: 'Sorento',
  year: 2010,
  miles: 50000,
  bedliner: null,
  alarm: true,
});

output.js

我们需要以下几个步骤:

  • 查找导入模块的本地名称
  • 查找 .factory 方法的所有调用的地方
  • 读取所有传入的参数
  • 将该调用的参数由多个替换单个
//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) =>
          // we can’t just jam plain objects into our AST nodes.
          // { foo: 'bar' }
         //  Instead, we need to use builders to create proper nodes.
          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 });
};

Transform File: signature-change.js

解决我的问题

在上面的练习中,我们学会了如何利用 jscodeshift 的 api,查询节点、更改节点和新建节点。再回到文章开头笔者遇到的问题,来用 codemod 的形式实现代码的转换。

My Playground - Chang useXXQuery to useRequest

更多的使用场景

  1. 大规模的代码改动,例如前面笔者遇到的问题

  2. 组件库升级导致的 break changes,例如 bytedesign 升级到 arco-design

    1.    Playground: Upgrade from bytedesign to arco-design
    2. Button

      • ghost ---> type="outline"

      • ghost={true} ---> type="outline"

      • type="danger" ---> status="danger"

      Select

      • hideArrowIcon ---> arrowIcon={null}

      • hideArrowIcon={true} ---> arrowIcon={null}

      Form的 ref 生成方式和 validate 方法签名有改动,使用 codemod 批量更新

      • const form = useRef(null); ---> const [form] = Form.useForm();

      • form.current.validateFields((fields, error)=>{})

        •   ---> form.validate((error, fields) =>{})
  3. 可以借助 Js-codemod, js-transforms,这些 codmods 将你的代码更新至新的 modern js 规范( no-vars, template-literals, arrow-function 等)

  4. 更过可以看这里 Awesome codemods

什么时候不该用

  1. 需要太多的人为干预,无法自信的使用 codemod 完成更改。例如 react 类组件迁移到函数组件,需要考虑所有可能存在的差异。
  2. 需要依赖到运行时的信息。

例如这里要从 my-module.js 中删除 DEPRECATED_BAZ,但是使用的时候我们将 utils 的传播了下去,无法静态的分析出 DEPRECATED_BAZ 是否被使用。

 // src/utils/my-module.js
export {
  DEPRECATED_BAZ: 'DEPRECATED_BAZ',
  foo: () => 'hello',
};
 // src/components/App.js
import React from 'react';
import * as utils from '../utils/my-module';

const App = props => {
  return <div {...props} {...utils}>{props.children}</div>;
};
  1. 需要用户输入的情况,建议插入 todo 注释
import React from 'react';
import MyComponent from '../utils/my-module';

+/** TODO (Codemod generated): Please provide a security token here */
const App = props => {
  return <div {...props} securityToken="???" />;
};

最后

本文介绍了 codemod 的优势以及 jscodeshift 相关的概念和基础用法。最后总结下如何快速写一个 codemod:

  1. 借助 ast-finder 自动生成查询语句

  2. 借助 ast-builder 自动生成构建语句

  3. 借助 astexplorer.net 实时查看AST转换结果

  4. jsodeshift CLI 可以帮我们批量处理文件

社区生态

Awesome 系列

工具

一些库的 codemods

******References

  1. Transform your codebase using codemods
  2. When not to codemod
  3. Write Code to Rewrite Your Code: jscodeshift