likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 21

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

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

业务背景

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

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

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

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

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

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

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

  • 耗时巨大
  • 手动处理大量的重复的无聊的东西,可能存在失误
  • 同时造成很多文件的修改,假如你和你的同事在不同的分支处理代码,合并的时候需要解决冲突

于是笔者想能不能 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 ),再将它们转化成源码输出到文件中。

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

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的结构。

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

recast

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

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

reacst提供了两个基础接口,一个( . 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 的包装器执行的

简单对比下 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 的组成单元

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

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

图片来源

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

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

图片来源

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 之间的合作关系以及它的执行流程。

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

练习

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

  1. 删除所有的console调用

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

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

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

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. 大规模的代码改动,例如前面笔者遇到的问题
  1. 组件库升级导致的 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) =>{})
  1. 可以借助 Js-codemod, js-transforms,这些 codmods 将你的代码更新至新的 modern js 规范( no-vars, template-literals, arrow-function 等)
  1. 更过可以看这里 Awesome codemods

什么时候不该用

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

例如这里要从 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 自动生成查询语句
  1. 借助 ast-builder 自动生成构建语句
  1. 借助 astexplorer.net 实时查看AST转换结果
  1. jsodeshift CLI 可以帮我们批量处理文件

社区生态

Awesome 系列

工具

一些库的 codemods

References

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