最近很火的AST分析原理&社区方案比较

4,540 阅读9分钟

引言

“代码分析转换”本来在前端开发中是一个比较小众的技能树,我所在的阿里妈妈前端技术团队(MUX)也是在大量业务的迁移架构的过程中遇到了需要批量转换代码的问题,所以对原理和工具进行了一些研究,最近发现社区里不少对此的讨论的文章也得到了大家的关注,所以也打算在此多分享一些我们的经验。

其实AST分析的过程与每一位开发同学的工作都密不可分,小到一次eslint语法检查,大到框架的升级,都涉及于此。简单、个别的转换可以通过人眼辨别、手动修改,批量的简单转换可以通过正则匹配、字符串替换,但更复杂的转换,基于AST是最有效的方案。

基于AST的代码分析转换

AST简要介绍


抽象语法树(Abstract Syntax Tree)简称 AST ,是以树状形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。JavaScript引擎工作工作的第一步就是将代码解析为AST,Babel、eslint、prettier等工具都基于AST。

将源代码解析为AST分为两步,词法分析和语法分析

1.词法分析,将字符序列转为单词(Token)序列的过程。
image.png
2.语法分析,将Token序列组合成各类语法短语,如Program,Statement,Expression等
image.png

基于AST进行分析转换的优势


AST会忽略代码风格,将代码解析为最纯粹的语法树,因此基于AST进行的转换是更加准确严谨的,而使用正则表达式来分析转换代码,无法有效的分析一段代码的上下文,即使对简单规则的匹配都需要考虑太多边界情况,兼容各种代码风格。

举个简单的例子,将所有由var定义的变量转为let定义,基于AST可以很容易的完成,而使用正则需要考虑各种情况,书写的表达式也难以调试、读懂。

基于AST转换的过程

  1. parse 源代码经过词法分析、语法分析解析为AST
  2. transform 对AST进行分析变换
  3. generate 将最终的AST输出为代码

image.png
其中1、3在社区都有成熟的工具,可以拿来即用。而第2步需要开发者自己操作AST,社区上有流行的工具,但存在一定问题。

社区流行方案现状及问题


社区流行的方案有Babel、jscodeshift以及esprima、recast、acorn、estraverse等,本文选择最具代表性的Babel和jscodeshift来分析。

Babel插件方案的剖析


没有Babel就没有JS社区今天在语言规范上的高度繁荣,babel/parser也是非常优秀的解析器。很多开发者进行代码分析转换时都离不开Babel插件,但是我个人认为Babel插件目前的编写方案上存在几个问题 1.上手难度高,学习成本高 2.匹配、生成节点的逻辑复杂,代码量大 3.代码可读性差,不利于维护。具体而言:

1.上手难度、学习成本较高


上手Babel插件开发之前需要深入了解AST规范,AST节点的类型和属性。参考 babel-typesbabel node type,200多个节点类型。babelrc的配置、babel plugin的编写方式是基础,除此之外还要了解visitor、scope、state、excit、enter等概念、babel-types、babel-traverse、builder等工具。

2.匹配、构造节点的逻辑复杂,代码量大


匹配节点,需要层层、逐个对比节点类型和属性,如果需要确定上下文信息会更加复杂。构造节点同样需要严格按照类型和结构来进行。需要在AST的操作上耗费大量时间,无法专注于分析与转换的核心逻辑。

  • 使用Babel匹配 self.doEdit('price')(this, '100') ,写法如下
MemberExpression(path) {
  if (path.node.object.name == 'self' && path.node.property.name == 'doEdit') {
    const firstCallExpression = path.findParent(path => path.isCallExpression());
    if (!firstCallExpression) {
      return;
    }
    if (!firstCallExpression.node.arguments[0]) {
      return;
    }
    let secondCallExpression = null
    if (firstCallExpression.node.arguments[0].type == 'StringLiteral'
        && firstCallExpression.node.arguments[0].value == 'price') {
      secondCallExpression = firstCallExpression.findParent(
        path => path.isCallExpression()
      )
    }
    if (!secondCallExpression) {
      return;
    }
    if (secondCallExpression.node.arguments.length != 2
        || secondCallExpression.node.arguments[0].type != 'ThisExpression') {
      return;
    }
    const pId = secondCallExpression.node.arguments[0].value;
  }
}
  • 使用Babel构造'var varName = require("moduleName")',写法如下
types.variableDeclaration('var', [
  types.variableDeclarator(
    //t.variableDeclarator(id, init)
    //id就是identifier
    //此处的init必须是一个Expression
    types.identifier('varName'),
    //t.callExpression(callee, arguments)
    types.callExpression(
      types.identifier('require'),
      [types.stringLiteral('moduleName')]
    )
  ),
]);

3.代码可读性差,不利于维护


看了上面两段例子,可以发现不仅代码量大,可读性也不够好,即使对AST和Babel非常熟悉,也需要仔细逐句进行理解。

jscodeshift的剖析


相比于Babel而言,jscodeshift的优势是匹配节点更简便一些,链式操作用起来更加顺手。
匹配self.doEdit('price')(this, '100'),写法如下

const callExpressions = root.find(j.CallExpression, {
  callee: {
    callee: {
      object: {
        name: 'self'
      },
      property: {
        name: 'doEdit'
      }
    },
    arguments: [{
      value: 'price'
    }]
  },
  arguments: [{
    type: 'ThisExpression'
  }, {
    value: '100'
  }]
})


转换和构造节点的方式与Babel写法类似,不再赘述。可以看出jscodeshift也没有很好的解决上文提到的三个问题。

于是在社区宝贵的经验之上,我们开发了新的工具GoGoCode。目的就是让开发者能够最高效率最低成本的完成代码分析转换。

另一种方案 GoGoCode

概述


GoGoCode是一个操作AST的工具,可以降低使用AST的门槛,帮助开发者从繁琐的AST操作中解放出来,更专注于代码分析转换逻辑的开发。简单的替换甚至不用学习AST,而初步学习了AST节点结构(可参考AST查看器)后就可以完成更复杂的分析转换。

思想


GoGoCode借鉴了JQuery的思想,我们的使命也是让代码转换像使用JQuery一样简单。JQuery在原生js的基础上大大便利了DOM操作的效率,没有复杂的配置流程,可以拿来即用,而且有很多优秀的设计思想值得借鉴:比如$()实例化、选择器思想、链式操作等。除此之外,我们将简单的replace的思想应用在AST中,效果也很不错。

$()实例化方法

使用$(),源代码和AST节点都可以被实例化为AST对象,可以链式调用实例上挂载的任意函数

$(code: string)

$('var a = 1')

$(node: ASTNode)

$({ type: 'Identifier', name: 'a' }).generate()

代码选择器

DOM树和AST树都是树结构,JQuery可以用各种选择器匹配节点,AST是不是也可以通过简单的选择器来匹配真实的节点呢?于是我们定义了“代码选择器”

无论你想找什么样的代码,都可以通过代码选择器直接匹配到

$(code).find('import a from "./a"')

$(code).find('function a(b, c) {}')

$(code).find('if (a && sth) { }')


如果你想匹配的代码包含不确定部分

那就把不确定部分由通配符替换,通配符用$_$表示 。祝大家万事如意,恭喜发财 o(*≧▽≦)ツ

$(code).find('import $_$ from "./a"')

$(code).find('function $_$(b, c) {}')

$(code).find('if ($_$ && sth) { }')

链式操作

GoGoCode提供的api大部分都能链式调用,让代码变得更加简洁,优雅。更加方便我们对整段代码进行多个转换规则的应用

$(sourceCode)
  .replace('const $_$1 = require($_$2)', 'import $_$1 from $_$2')
  .find('console.log()')
	.remove()
	.root()
	.generate()

方法重载:.attr()

既可以获取也可以修改节点属性,比手动遍历,层层判断来操作属性、节点友好很多

$(code).attr('id.name')  // 返回该节点id属性中的name属性值

$(code).attr('declarations.0.id.name', 'c') // 修改name属性值

简单的replace

比通过正则进行replace更简单、更强大、更好用。$_$n类似于正则中的捕获组,$$$类似于rest参数

$(code).replace('{ text: $_$1, value: $_$2, $$$ }', '{ name: $_$1, id: $_$2, $$$ }')

$(code).replace(`import { $$$ } from "@alifd/next"`, `import { $$$ } from "antd"`)

$(code).replace(`<View $$$1>$$$2</View>`,`<div $$$1>$$$2</div>`)

$(code).replace(`Page({ $$$1 })`, 
    `Page({
      init() { 
      	this.data = {} 
      },
      $$$1
     })`
)

核心API

基础api获取节点api操作节点
$().find().attr()
$.loadFile.parent().replace()
.generate().parents().replaceBy()
.siblings().after()
.next().before()
.nextAll().append()
.prev().prepend()
.prevAll().empty()
.root().remove()
.eq().clone()
.each()

与社区流行方案对比


前文的例子中,匹配 self.doEdit('price')(this, '100') 语句 ,使用GoGoCode写法如下

$(code).find(`self.doEdit('price')(this, '100')`)

构造'var varName = require("moduleName")',使用GoGoCode写法如下

$('var varName = require("moduleName")')


以一个完整的例子将GoGoCode和Babel插件进行对比:

对于以下这段代码,我们希望对不同的 console.log  做不同的处理

  1. console.log 的调用删除
  2. console.log() 作为变量初始值时转换为 void 0 
  3. console.log 作为变量初始值时转换为空方法

代码经转换的结果如下:
image.png
使用GoGoCode实现的代码如下:

$(code)
  .replace(`var $_$ = console.log()`, `var $_$ = void 0`)
  .replace(`var $_$ = console.log`, `var $_$ = function(){}`)
  .find(`console.log()`)
  .remove()
  .generate();


使用Babel实现的核心代码如下:

// 代码来源:https://zhuanlan.zhihu.com/p/32189701
module.exports = function({ types: t }) {
return {
    name: "transform-remove-console",
    visitor: {
    CallExpression(path, state) {
        const callee = path.get("callee");

        if (!callee.isMemberExpression()) return;

        if (isIncludedConsole(callee, state.opts.exclude)) {
        // console.log()
        if (path.parentPath.isExpressionStatement()) {
            path.remove();
        } else {
        //var a = console.log()
            path.replaceWith(createVoid0());
        }
        } else if (isIncludedConsoleBind(callee, state.opts.exclude)) {
        // console.log.bind()
            path.replaceWith(createNoop());
        }
    },
    MemberExpression: {
        exit(path, state) {
        if (
            isIncludedConsole(path, state.opts.exclude) &&
            !path.parentPath.isMemberExpression()
        ) {
        //console.log = func
            if (
            path.parentPath.isAssignmentExpression() &&
            path.parentKey === "left"
            ) {
            path.parentPath.get("right").replaceWith(createNoop());
            } else {
            //var a = console.log
            path.replaceWith(createNoop());
            }
        }
        }
    }
    }
};

其中 isIncludedConsole、isIncludedConsoleBind、createNoop 等方法还需额外开发引入


可以看出,与社区工具对比,GoGoCode的优势是:

  1. 上手难度低:不需要对所有AST节点规范了如指掌,不需要知道遍历、访问AST的各种阶段,不需要额外的工具,只需阅读简单的GoGoCode文档。GoGoCode是唯一面向开发者而不是面向AST结构的AST处理工具。
  2. 代码量非常少:让你专注分析与转换的核心逻辑,不在AST的操作上耗费大量时间。无论是匹配、修改还是构造节点都很简单,几行代码就能搞定。
  3. 可读性很强:对比可以看出,基于GoGoCode写出来的代码很直观,很容易看懂,也更便于长久的维护。
  4. 灵活性强:使用Babel插件和jscodeshift能做到的,GoGoCode都可以更便捷的做到。除了js之外,GoGoCode同时支持对html的处理,对vue的处理,这是社区其他流行工具所不具备的。

使用效果

基于GoGoCode初版本我们开发了妈妈自研框架Magix的升级套件,包含78个简单规则、30个复杂规则的转换,自动将Magix1代码(左)转换为Magix3代码(右),提升了框架升级效率

image.png
其中一个20行左右的转换逻辑我们曾尝试用Babel写,近200行代码才完成。

俗话说,磨刀不误砍柴工,在这里编写自动化转换规则是磨刀,实施转换是砍柴。如果磨刀的时间接近直接砍柴的时间,那大家会选择放弃磨刀。代码转换经常是解决我们团队、系统内的特定问题,多数情况下甚至是一次性的,(不能像ES6转ES5那样通过大规模的应用一套通用规则来分摊掉插件开发的成本)这就要求我们磨刀的效率必须高。

近期我们在进行支付宝小程序代码转PC框架代码的尝试,团队内对AST了解不多的同学经一小时就可以快速上手,不到200行代码就完成了80%js逻辑的转换。可见无论是上手难度降低、效率提升还是代码量减少都是很显著的。

总结

GoGoCode在代码量、可读性、灵活性方面都具有优势,我们会继续打磨,加强工具健壮性和易用性。希望通过GoGoCode人人都能理解并操纵抽象语法树,从而完成代码分析转换逻辑,更好的掌控代码,实现一码多端、更顺畅的框架升级......同时希望在相关领域让更多同学能够最低成本的参与进来贡献自己的力量,给业界生态提供更好的解决方案。

除了前文提到的语法检查、一码多端、框架升级之外,还有很多场景需要分析和转换代码

  • 分析页面或者视图与异步请求的关联
  • 分析模块复杂度
  • 分析模块依赖
  • 清理无用代码
  • 埋点代码自动生成
  • 单测插桩文件生成
  • 自动修正代码问题
  • ......


如果你需要分析、转换代码,如果你想快速实现Babel现有插件不能满足的需求,欢迎使用和共建GoGoCode。

如果你用 GoGoCode 不方便解决或者出了错,希望你能提给我们

QQ群:735216094 钉钉群:34266233


Github:github.com/thx/gogocod… 新项目求 star 支持 o(////▽////)q
官网:gogocode.io
playground:play.gogocode.io/


相关文章:
阿里妈妈出的新工具,给批量修改项目代码减轻了痛苦
「GoGoCode 实战」一口气学会 30 个 AST 代码替换小诀窍