Fun with Codemod & AST
TL;DR
- Facebook 为了解决「大型代码库」迁移,基于 AST 造了个工具 Codemod
- 基于 Codemod 又构建了 JavaScript 代码迁移专用的工具 jscodeshift 和 React-codemod
- 理解这些工具背后的原理有助于从一个单纯的「API 使用者」变成一个工程师般的「创造者」
- Demo Time!Let's write a codemod
- 一些有价值的参考
背景
作为一个自信而自豪的前端弄潮儿(F2E),我们总是希望能够在这个每天都在飞速迭代的行业,与时渐进。
前端们是一群不安分的人,大家喜爱新框架、新语法,而 JavaScript 也是一门非常灵活的语言,它提供给我们的 API 也在与时渐进。比如,当 ES2015 / ES2016 / ES2017…
出来的时候,那些新语法糖,简洁漂亮,且更易于理解逻辑,于是我们都想去尝试下。
但是尝试归尝试,对于新项目尝试起来成本很低,只需要把新功能都用新语法编写就好。
而创建新项目的同时,其实我们也在维护着一些已有的旧项目。如果你还并没怎么理它们,可能它们还活得不错。但是一旦 PM 心情好想加个新功能,或者你哪天心情好想去更新下代码库,然后看到自己之前写的那些代码,那些现在其实可以更优雅漂亮的代码,是不是手里特痒痒特想把它们更新了?
执行力强的小伙伴可能说干就干了。嗯,就假设我们有个项目,里面使用的是用ES5
版 React
作为 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 foo
,function () {…}
改为 () => {…}
,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 项目为例:
基本步骤如下:
-
因为是第一次使用
codemod
,所以比较谨慎,一个一个component
来; -
先用
react-codemod
转,把大部头代码迁移; -
然后
js-codemod
小步更新整理; -
然后再根据一些自己的 Code Style 做些细节上的修改。比如使用
standard-format
工具格式化代码,符合我个人写的代码风格。 -
毕竟 JS 太过于灵活,每个人写代码时候风格和结构都是各异的,有时候的转换还是会出现一些与想象中不一致的结果,官方文档也是说仍然需要人工干预,所以会根据 transform 后的结果手动修改下代码细节;
-
一切组件迁移就绪,
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.
那么这里就出现了两个关键的概念:Runner 及 AST。
-
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
- Parse: SourceCode => AST (Tree Nodes)
- Find: Find the Nodes we want to replace // Transform
- Create: Create the New Nodes we want to insert // Transform
- Update: Update the AST at the right location // Transform
- 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 这个图,
前端er 一定会觉得很相似,这里不就是 DOM 语法树的终极抽象版本吗,只是把一个个 DOM Nodes 换成了一个个更加无语义的字符 Token。
FB 有一个很棒的工具 ASTExplorer,可以用来更形象地展示。
比如说,我们现在就只有一个很简单的表达式a+b
,这里是 recast Parser 解析后的 AST 结构:
看上去特别复杂。注意那些蓝色字体 File
, Programme
, ExpressionStatement
, Identifier
… 这些都是 AST Nodes,其他的都是和这个 Node 相关的数据。
根据前文可以知道,每种语言的 AST 都是不同的。有专门的 Parser 来生成 AST。