跳到主要内容

Fun with Codemod & AST

· 阅读需 19 分钟

TL;DR

  • Facebook 为了解决「大型代码库」迁移,基于 AST 造了个工具 Codemod
  • 基于 Codemod 又构建了 JavaScript 代码迁移专用的工具 jscodeshiftReact-codemod
  • 理解这些工具背后的原理有助于从一个单纯的「API 使用者」变成一个工程师般的「创造者」
  • Demo Time!Let's write a codemod
  • 一些有价值的参考

背景

作为一个自信而自豪的前端弄潮儿(F2E),我们总是希望能够在这个每天都在飞速迭代的行业,与时渐进。

前端们是一群不安分的人,大家喜爱新框架、新语法,而 JavaScript 也是一门非常灵活的语言,它提供给我们的 API 也在与时渐进。比如,当 ES2015 / ES2016 / ES2017… 出来的时候,那些新语法糖,简洁漂亮,且更易于理解逻辑,于是我们都想去尝试下。

但是尝试归尝试,对于新项目尝试起来成本很低,只需要把新功能都用新语法编写就好。

而创建新项目的同时,其实我们也在维护着一些已有的旧项目。如果你还并没怎么理它们,可能它们还活得不错。但是一旦 PM 心情好想加个新功能,或者你哪天心情好想去更新下代码库,然后看到自己之前写的那些代码,那些现在其实可以更优雅漂亮的代码,是不是手里特痒痒特想把它们更新了?

执行力强的小伙伴可能说干就干了。嗯,就假设我们有个项目,里面使用的是用ES5React 作为 UI View层,然后它大概四个页面(Page),每个页面包含大概四个组件(Component),然后你从某个看起来比较小、不容易出错的Component 入手,开始一行一行改写代码,嗯,var React = require('react’) 改为 import React from 'react’var API = ‘/j/app/xxx’ 改为 const API = ‘/j/app/xxx’var foo 改为 let foofunction () {…} 改为 () => {…}module.exports = React.createClass({…}) 改为 export default class MyComponent extends React.Component {…}

天哪,有完没完,一个组件改完下来,你已经感到身体被掏空,望了望 Components 列表,更不用说,重新 build 过的测试还没过。你陷入了绝望...

那么有没有更快一点的办法呢?

稍微有点经验的前端儿可能想到「正则表达式匹配替换」。Bash Awk | Sed 命令,或者 Vim :%s/var/let/g。可是如果需要有些变量是 const 类型,有些是 let,而有些保持 var 不变怎么办?再比如说以下这段很常见的代码:

merge(a, {b: 1}, c);  // Old

// 需要变为

({...a, b: 1, ...c}); // New

这里光是这个函数的 arguments 就可能有多种形式,比如 variable,一个匿名函数返回的 Object 或者 Plain Object 那种。

所以这里已经相当于是一个 Context-non-free 的问题,也就是说,上下文语义很重要。

这样的话,无论再怎么强大的RegExp 也无能为力。因为正则的本质,其实是根据一定的 Pattern 来匹配字符串,但是在真正的代码里,所有的字符串都有语义,都有上下文,这里的正则表达式会既复杂又无用。 所以,我们得升一个维度思考问题。

Codemod

对「代码库的批量迁移更新」,其实也是程序员的一个需求,所以,很感恩地,已经有一群懒惰又聪明的程序员造出了工具:Codemod,将「大型仓库代码的批量迁移」自动化,省时省力。

好吧,所以 Codemod 到底是什么呢?

官方文档这样写着:

Codemod is a tool/library to assist you with large-scale codebase refactors that can be partially automated but still require human oversight and occasional intervention.

这样看来,可以很好的解决我们的需求了。

基于 Codemod,又出现了针对 JavaScript 代码迁移的工具 Facebook jscoodeshift.

基于 jscodeshift,又构建了迁移一般 JavaScript 代码(比如 ES5 -> ES2015) 的工具 js-codemod 和迁移 React 相关项目的 react-codemod

嗯,这么看来,我们的事情就变得容易多了。

根据上面那些工具的官方文档,我们只需要按顺序执行以下命令:

> npm i -g jscodeshift
> git clone https://github.com/reactjs/react-codemod.git
> git clone https://github.com/cpojer/js-codemod.git
> jscodeshift -t react-codemod/transforms/class.js --mixin-module-name=react-addons-pure-render-mixin --flow=true --pure-component=true --remove-runtime-proptypes=false src/register/component/myComponent.jsx
> jscodeshift -t js-codemod/transforms/no-vars.js ./src/register/component/myComponent.jsx

然后,再次 git status 一下或者直接打开刚才 transform 的 myComponent.jsx 文件查看,你会发现,Wow,神奇般,你的代码都成为了它们应该成为的样子!

这里暂时以我之前做的 Accounts 项目为例:

https://github.intra.douban.com/accounts/accounts/pull/553/files#diff-b2286efdea6a62288250264e82bd948b

基本步骤如下:

  1. 因为是第一次使用 codemod,所以比较谨慎,一个一个 component 来;

  2. 先用 react-codemod 转,把大部头代码迁移;

  3. 然后 js-codemod 小步更新整理;

  4. 然后再根据一些自己的 Code Style 做些细节上的修改。比如使用 standard-format 工具格式化代码,符合我个人写的代码风格。

  5. 毕竟 JS 太过于灵活,每个人写代码时候风格和结构都是各异的,有时候的转换还是会出现一些与想象中不一致的结果,官方文档也是说仍然需要人工干预,所以会根据 transform 后的结果手动修改下代码细节;

  6. 一切组件迁移就绪,npm run test 测试通过以后,重新 build 运行

这里我把已有的十几个组件和页面文件,全部使用上面的工具进行了更新。

然后当你重新 build 后,你会发现测试仍然通过,组件功能仍然 work,但是代码库却是使用新语法糖进行了大规模彻彻底底地更新!简直太神奇了!🤓 那么,它是怎么做到的呢?

这里就要好好深究下这个工具了。

jscodeshift

让我们来重新读一下 jscodeshift 的文档

jscodeshift is a toolkit for running codemods over multiple JS files. It provides:

  • A runner, which executes the provided transform for each file passed to it. It also outputs a summary of how many files have (not) been transformed.
  • A wrapper around recast, providing a different API. Recast is an AST-to-AST transform tool and also tries to preserve the style of original code as much as possible.

那么这里就出现了两个关键的概念:RunnerAST

  • Runner

  • A runner/worker feature that can apply transforms to thousands of files in parallel. -- CPojer Effective JavaScript Codemods

  • AST,Abstract Syntax Tree,抽象语法分析树。

为了更好理解以上概念,先来看一下之前运行 jscodeshift 命令过程。

我们先是把一个里面包含了 JS 代码的源文件传给了它,然后它读取了源代码,又根据写好的 transform.js 对源代码进行了相应的变换,最后输出了变换后的 JS 代码,覆盖了原文件。

这个过程简单的说,就是: SourceCode => codemod => ObjectCode

那么再详细一点,根据 jscodeshift 作者之一的 CPojer 在一次 JSConf 上对这个工具的介绍,jscodeshift 操作基本是按以下过程:

Parse => Find => Create => Update => Print

  1. Parse: SourceCode => AST (Tree Nodes)
  2. Find: Find the Nodes we want to replace // Transform
  3. Create: Create the New Nodes we want to insert // Transform
  4. Update: Update the AST at the right location // Transform
  5. Print: Print it back into JavaScript Source with proper formatting and should like human wrote this.

第一步,将源代码解析 (parse) 成 AST.

现在我们先回到语言的本质。

我们知道自然语言(Natural Language),无论什么语种,都会有「主语」「动词」「宾语」「标点符号」来描述一个现实世界所发生的事件。

而在计算机编程语言 (Programming Language),无论什么语种,都会有「类型」「运算符」「流程语句」「函数」「对象」等概念来表达计算机中存在内存中的0和1,以及背后运算与逻辑。

不同的语言,都会配之不同的语法分析器(parser)。

对于自然语言,我们的大脑就是一个 Parser。对于编程语言,语法分析器是把源代码作为字符串读入、解析,并建立语法树的程序。

什么是语法树?摘自 Wiki 一段:

计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

这么说其实还是有点抽象,我们先打开 wiki 看到 wikipedia 这个图,

AST Tree

前端er 一定会觉得很相似,这里不就是 DOM 语法树的终极抽象版本吗,只是把一个个 DOM Nodes 换成了一个个更加无语义的字符 Token。

FB 有一个很棒的工具 ASTExplorer,可以用来更形象地展示。

比如说,我们现在就只有一个很简单的表达式a+b,这里是 recast Parser 解析后的 AST 结构:

a + b AST Tree

看上去特别复杂。注意那些蓝色字体 File, Programme, ExpressionStatement, Identifier… 这些都是 AST Nodes,其他的都是和这个 Node 相关的数据。

根据前文可以知道,每种语言的 AST 都是不同的。有专门的 Parser 来生成 AST。

关于 Parser 又是一门很深的学问了。

在 ASTExplorer.net 上可以看到有很多 parser,比较著名的有 Esprima(jQuery),Uglify-JS, Babylon(Babel), Acorn(Tern / Webpack), 及 jscodeshift 使用的 recast。

虽然有很多 Parser,但是基本上,一个 parser 的结构都差不多,对源代码进行词法分析,生成 Tokens,对 Tokens 进行语法分析,然后生成 AST。

Parser

具体可以参考看下 Esprima Parse Demo

生成的 AST 都遵循一个统一标准 ESTree or [Mozilla SpiderMonkey](parser API https://developer.mozilla.org/en-US/docs/Mozilla/Projects/SpiderMonkey/Parser_API),也就是说都会返回一个 ESTree Compatible AST。

第二三四步,对生成的 AST 进行操作修改 (Find Nodes & Create New Nodes & Update Nodes)

Wiki 有介绍说,parse AST 的代码基本是使用Visitor Pattern(游客模式),如:

// recast
var ast = recast.parse(src);
recast.visit(ast, {
visitIdentifier: function(path) {
// do something with path
return false;
}
});

这其实也很容易理解,AST 就是一个树形结构,然后解析它的话就是以一个游客一样遍历这棵树。

但是这个模式在前端中还是用得比较少的,所以 js-codeshift 基于 Collections 概念,很贴心的给这些 Parser API 继续包了一层,提供了一个不一样的前端友好型 API.

// jscodeshift
jscodeshift(src)
.find(jscodeshift.Identifier)
.forEach(function(path) {
// do something with path
});

// Provide jQuery-likely and F2E-friendly Syntax API
// Manipulate AST nodes conveniently.

读这段代码的时候,一下子觉得又似曾相识。这不就和使用 jQuery 操作 DOM 一样嘛。

可以对比下 “普通 Parser” 与 jscodeshift 操纵 AST 的区别:

可以看到如果使用 esprima ,AST Traverse / Walk 基本是 visitor pattern. https://github.intra.douban.com/zhangbinliu/ast_demo/tree/esprima

第五步,输出转换后的代码

据 jscodeshift 创造者之一 CPojer 说,根据转换后的 AST,以及一些输出 Options(比如是否单引号、tab 宽度是多少、需不需要去掉尾部分号…),是一个挺困难的过程。

但是最终,jscodeshift 的输出 API 却简洁明了,只要一行代码即可搞定。

 .toSource({quote: 'single'}); // sets strings to use single quotes in transformed code.

(其实 Recast 在这做了大量的工作

经过这五个步骤,一次 jscodeshift 的转换过程就算完成啦。

DEMO TIME! Write a codemod transform

jscodeshift -t <transform.js> /to/file/path

我们来写transform.js,也就是说,你打算使用 jscodeshift 对源代码进行何种变换,这里面就是变换函数。

现在考虑一个 ES5 -> ES6 的经典问题:

Problem:

// Before
'Hello, ' + name + ', I am a string.'
// After
`Hello, ${name}, I am a string.`

Solution:

  1. Simplify, 简化问题,考虑一个最简单的情况
// Before
a + b
// After
`${a}${b}`

a + b AST:

a + b AST

${a}${b} AST:

${a}${b}

对比两个 AST 可以发现,我们只需要

  1. 读入需转换的代码,找到 BinaryExpression
  2. 保存 BinaryExpression 左右两边的值(node.left & node.right)
  3. 生成一个为 TemlateLiteral Node,quasis 是一个包含了三个 TemplateElement 的数组,cookde & raw keys 都是 ''expressions 是一个包含 node.left, node.right 值的数组。
  4. 然后将它返回输出;

这里贴下我的 Solution Example:

  1. http://astexplorer.net/#/gist/33b63b7dc8da6d9ea4936c631adc994d/7905ef5b30dfc0cb3c811af8a8960f11014ef402
export default function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source)

const toTempLiteral = (p) => {
const quasis = [
j.templateElement({"cooked": '', "raw": ''}, false),
j.templateElement({"cooked": '', "raw": ''}, false),
j.templateElement({"cooked": '', "raw": ''}, true),
]

const tempLiteral = j.templateLiteral(quasis, [p.node.left, p.node.right])

return tempLiteral
}

return root
.find(j.BinaryExpression, {operator : '+'})
.replaceWith(toTempLiteral)
.toSource()
}
  1. http://astexplorer.net/#/gist/a71957902ec1fd79f199eb37e5e6801a/55c4910295973b3a4d09770d94cec73d5096daa4
export default function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source)

const toTempLiteral = (p) => {
const quasis = [
j.templateElement({"cooked": '', "raw": ''}, false),
j.templateElement({"cooked": '', "raw": ''}, false),
j.templateElement({"cooked": '', "raw": ''}, true),
]

const extractNodes = (node) => {
if (node.type === 'BinaryExpression' && node.operator === '+') {
return [...extractNodes(node.left), ...extractNodes(node.right)]
}
else { return [node] }
}

const tempLiteral = j.templateLiteral(quasis, extractNodes(p.node))

return tempLiteral
}

return root
.find(j.BinaryExpression, {operator : '+'})
.replaceWith(toTempLiteral)
.toSource()
}

  1. http://astexplorer.net/#/gist/a71957902ec1fd79f199eb37e5e6801a/cf3c898c5fe494d08a978dd1a3a3e56fc42828a8
export default function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source)

const toTempLiteral = (p) => {
const quasis = [
j.templateElement({"cooked": '', "raw": ''}, false),
j.templateElement({"cooked": '', "raw": ''}, false),
j.templateElement({"cooked": '', "raw": ''}, true),
]

const extractNodes = (node) => {
if (node.type === 'BinaryExpression' && node.operator === '+') {
return [...extractNodes(node.left), ...extractNodes(node.right)]
}
else { return [node] }
}

const tempLiteral = j.templateLiteral(quasis, extractNodes(p.node))

return tempLiteral
}

return root
.find(j.BinaryExpression, {operator : '+'})
.replaceWith(toTempLiteral)
.toSource()
}

官方没有太详细的 transform 编写指导,可以多谷歌或者学习已编写好的 transform:react-codemod/tranform 或者 js-codemod/transform

我个人觉得写 JS-Codeshift Transform | Babel-Plugin 本质其实就是对比两棵语法树,像解谜一样,看看如何「又好又快」变换成这样。

剩下的一开始如何读取源 JS 代码并解析成语法树,具体在 AST 上 traverse & find & create & update (这里我猜测其实是一个递归遍历节点树的过程?),及之后如何按一定的 code style 生成我们需要的代码,都是透明且不关心的。

总结 & 开脑洞

  • 总结下基本处理流程: Process

  • AST 是很有用的一个抽象概念。一旦你理解了这些规则,唯一的限制就是解析器和你的想象力。

  • 纯 AST parse 太过于理论,既然是工程师,还是需要自己动手写点什么来解决自己实际遇到的问题。

  • 可以继续拓展到「语法高亮」、「关键字匹配」、「代码格式化」、「作用域判断」、以及「代码压缩」、「Babel 插件」等等。

  • 渐渐深入底层进行分析,让自己对这门语言有了更多、更深入的了解,可以更好地成为产品「创造者」,而不单纯是「使用者」。

  • Write JavaScript that writes JavaScript! The best editor is JavaScript. Cool!

思考

  • 可以在一个 codemod transform 里面同时进行两个变换吗?个人觉得可能会很复杂且效果不太好,因为有些 transform 并不是正交的。🤔

Refs: