这篇文章将会带你认识 jscodeshift ------一个超级实用的代码转换工具,你可以用它实现大型代码重构、升级等工作。
接下来将以笔者遇到业务问题为背景,介绍 jscodeshift 相关概念和基础用法(如何查询节点、修改节点、新建节点),以及涉及到的数据结构 Collections 和 NodePaths,最后介绍了 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. react、Ant design、next.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:抽象语法树
babeljs、recast、eslint 这些工具会将原文件解析( parse )为由若干节点组成的 AST ,然后对这些"节点"进行一些操作( mutate ),再将它们转化成源码输出到文件中。
Code --- AST --- AST --- Code
-- 图片来源
不同的库(例如 babel 和 reacts )解析出的 AST 并不是完全相同,现在我们来看下 recast 对这段代码console.log('Hello, World');
的解析结果:
css
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 的例子:
vbnet
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 打印结果:
ini
// 这里使用的prettyPrint , 可以美化输出
var output = recast.prettyPrint(ast, { tabWidth: 2 }).code;
javascript
// 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 的使用:
javascript
// 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:
less
// 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
也能够了解到有哪些节点类型以及提供哪些构造器。
css
// 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 相关的概念和原理后,让我们来完成几个简单的练习吧。
删除所有的console调用
这个场景很常见,在推送代码前你需要手动检查 console 的调用,并删除。虽然你也可以通过查找和替换或者正则的方式来实现,但是对于多行语句、模板文字或者一些更复杂的调用情况就没那么容易了,让我们用 jscodeshift 来试试吧。
Playground - remove all calls to console (你可使用 ast-finder 自动生成 finder 语句)
在 AST Explorer 中可以实时查看语句对应的 AST 节点
lua
//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
替换调用的方法名
在这个场景中,我们需要将 geometry.circleArea
替换成 geometry.getCircleArea
,你也许会想用 /geometry.circleArea/g
来查找和替换所有的方法名,但是如果用户如果对 import 的module 进行了重命名,正则就处理不了这种情况了。
Playground - Replacing Imported Method Calls
javascript
// 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
ini
// 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
dart
// 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
更改方法签名
在上面的练习中,我们学会了如何查询指定 type 的节点,移除节点、还有替换节点。现在我们试着创建新的节点。
场景如下,随着这个方法的单个参数越来越多,代码变得不直观,我们需要将方法签名改成传递 object 的方式。
Playground - Changing a Method Signature(使用 ast-builder 自动生成 builder 语句)
dart
// input.js
car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);
input.js
php
// output.js
const suv = car.factory({
color: 'white',
make: 'Kia',
model: 'Sorento',
year: 2010,
miles: 50000,
bedliner: null,
alarm: true,
});
output.js
我们需要以下几个步骤:
- 查找导入模块的本地名称
- 查找 .factory 方法的所有调用的地方
- 读取所有传入的参数
- 将该调用的参数由多个替换单个
php
//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
更多的使用场景
-
大规模的代码改动,例如前面笔者遇到的问题
-
组件库升级导致的 break changes,例如 bytedesign 升级到 arco-design
- Playground: Upgrade from bytedesign to arco-design
-
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) =>{})
- --->
-
-
可以借助 Js-codemod,js-transforms,这些 codmods 将你的代码更新至新的 modern js 规范( no-vars, template-literals, arrow-function 等)
-
更过可以看这里 Awesome codemods
什么时候不该用
- 需要太多的人为干预,无法自信的使用 codemod 完成更改。例如 react 类组件迁移到函数组件,需要考虑所有可能存在的差异。
- 需要依赖到运行时的信息。
例如这里要从 my-module.js 中删除 DEPRECATED_BAZ,但是使用的时候我们将 utils 的传播了下去,无法静态的分析出 DEPRECATED_BAZ 是否被使用。
javascript
// src/utils/my-module.js
export {
DEPRECATED_BAZ: 'DEPRECATED_BAZ',
foo: () => 'hello',
};
javascript
// 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>;
};
- 需要用户输入的情况,建议插入 todo 注释
javascript
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:
-
借助 ast-finder 自动生成查询语句
-
借助 ast-builder 自动生成构建语句
-
借助 astexplorer.net 实时查看AST 和转换结果
-
jsodeshift CLI 可以帮我们批量处理文件