最近笔者的团队迁移了webpack2,在迁移过程中,笔者发现webpack2中有相当多的兼容代码,虽然外界有很多声音一直在质疑作者为什么要破坏性更新,其实大家也都知道webpack1那种过于“灵活”的配置方式是有待商榷的,所以作者才会在webpack2上进行了很多规范,但是,笔者却隐隐的觉得,等到webpack3的时候,估计会有更多的破坏性更新,不然也不会有这个webpack2了。于是心中有关webpack的话题便也搁置了,且等它更稳定一些,再谈不迟,今天先来讲讲在剧烈的版本变化中,不变的部分。
大家都知道,webpack是做模块绑定用的,那么就不得不牵涉到语法解析的内容,而且其极高的扩展性,也往往需要依赖于语法解析,而在webpack内部使用acorn做语法解析,类似的还有babel使用的babylon,今天就带来两者的简要分析。
官方给两者的定义都叫做JavaScript parser,内部也一致的使用了AST(Abstract syntax tree,即抽象语法树)的概念。如果对这个概念不明白的同学可以参考WIKIAST的解释
因为babylon引用了flow,eslint等一些checker,所以整个项目结构相当的规范,笔者仅已7.0.0为例:
文件夹目录如下:
index.js //程序入口,会调用parser进行初始化 types.js //定义了基本类型和接口 options.js //定义获取配置的方法以及配置的缺省值 parser //所有的parser都在此
index.js //parser入口类,继承自 StatementParser 即 ./statement.js
statement.js //声明StatementParser 继承自 ExpressionParser 即 ./expression.js
expression.js //声明ExpressionParser 继承自 LValParser 即 ./lval.js
lval.js //声明 LValParser 继承自 NodeUtils 即 ./node.js
node.js //声明 NodeUtils 继承自 UtilParser 即 ./util.js, 同时还实现了上一级目录中types.js 的nodebase接口为Node类
util.js //声明 UtilParser 继承自 Tokenizer 即 ../tokenizer/index.js
location.js //声明 LocationParser 主要用于抛出异常 继承自 CommentsParser 即./comments.js
comments.js //声明 CommentsParser 继承自 BaseParser 即./base.js
base.js //所有parser的基类
plugins
tokenizer
index.js //定义了 Token类 继承自上级目录parser的LocationParser 即 ../parser/location.js
util
大概流程是这样的:
1、首先调用index.js的parse;
2、实例化一个parser对象,调用parser对象的parse方法,开始转换;
3、初始化node开始构造ast;
1) node.js 初始化node
2) tokenizer.js 初始化token
3) statement.js 调用 parseBlockBody,开始解析。这个阶段会构造File根节点和program节点,并在parse完成之后闭合
4) 执行parseStatement, 将已经合法的节点插入到body中。这个阶段会产生各种*Statement type的节点
5)分解statement, parseExpression。这个阶段除了产生各种expression的节点以外,还将将产生type为Identifier的节点
6) 将上步骤中生成的原子表达式,调用toAssignable ,将其参数归类
4、迭代过程完成后,封闭节点,完成body闭合
不过在笔者看来,babylon的parser实现似乎并不能称得上是一个很好的实现,而实现中往往还会使用的forward declaration(类似虚函数的概念),如下图
一个“+”在方法前面的感觉就像是要以前的IIFE一样。。
有点扯远了,总的来说依然是传统语法分析的几个步骤,不过笔者在读源码的时候一直觉得蛮奇怪的为何他们内部要使用继承来实现parser,parser的场景更像是mixin或者高阶函数的场景,不过后者在具体处理中确实没有继承那样清晰的结构。
说了这么多,babylon最后会生成什么呢?以es2016的幂运算“3 ** 2”为例:
{"type": "File","start": 0,"end": 7,"loc": {"start": {"line": 1,"column": 0},"end": {"line": 1,"column": 7}},"program": {"type": "Program","start": 0,"end": 7,"loc": {"start": {"line": 1,"column": 0},"end": {"line": 1,"column": 7}},"sourceType": "script","body": [{"type": "ExpressionStatement","start": 0,"end": 7,"loc": {"start": {"line": 1,"column": 0},"end": {"line": 1,"column": 7}},"expression": {"type": "BinaryExpression","start": 0,"end": 6,"loc": {"start": {"line": 1,"column": 0},"end": {"line": 1,"column": 6}},"left": {"type": "NumericLiteral","start": 0,"end": 1,"loc": {"start": {"line": 1,"column": 0},"end": {"line": 1,"column": 1}},"extra": {"rawValue": 3,"raw": "3"},"value": 3},"operator": "**","right": {"type": "NumericLiteral","start": 5,"end": 6,"loc": {"start": {"line": 1,"column": 5},"end": {"line": 1,"column": 6}},"extra": {"rawValue": 2,"raw": "2"},"value": 2}}}],"directives": []} }
完整的列表看着未免有些可怕,笔者将有关location信息的去除之后,构造了以下这个对象:
{"type": "File","program": {"type": "Program","sourceType": "script","body": [{"type": "ExpressionStatement","expression": {"type": "BinaryExpression","left": {"type": "NumericLiteral","value": 3},"operator": "**","right": {"type": "NumericLiteral","value": 2}}}]} }
可以看出,这个类AST的的对象是内部,大部分内容是其实是有关位置的信息,因为很大程度上,需要以这些信息去描述这个node的具体作用。
然后让我们再来看看webpack使用的acorn:
也许是acorn的作者和笔者有类似阅读babylon的经历,觉得这种实现不太友好。。于是,acorn的作者用了更为简单直接的实现:
index.js //程序入口 引用了 ./state.js 的Parser类 state.js //构造Parser类 parseutil.js //向Parser类 添加有关 UtilParser 的方法 statement.js //向Parser类 添加有关 StatementParser 的方法 lval.js //向Parser类 添加有关 LValParser 的方法 expression.js //向Parser类 添加有关 ExpressionParser 的方法 location.js //向Parser类 添加有关 LocationParser 的方法 scope.js //向Parser类 添加处理scope的方法 identifier.js locutil.js node.js options.js tokencontext.js tokenize.js tokentype.js util.js whitespace.js
虽然内部实现基本是类似的,有很多连方法名都是一致的(注释中使用的类名在acorn中并没有实现,只是表示具有某种功能的方法的集合),但是在具体实现上,acorn不可谓不暴力,连多余的目录都没有,所有文件全在src目录下,其中值得一提的是它并没有使用继承的方式,而是使用了对象扩展的方式来实现的Parser类,如下图:
在具体的文件中,直接扩展Paser的prototype
没想到笔者之前戏谈的mixin的方式真的就这样被使用了,然而mixin的可读性一定程度上还要差,经历过类似ReactComponentWithPureRenderMixin的同学想必印象尤深。
不过话说回来,acorn内部实现与babylon并无二致,连调用的方法名都是类似的,不过acorn多实现了一个scope的概念,用于限制作用域。
紧接着我们来看一下acorn生成的结果,以“x ** y”为例:
{type: "Program",body: [{type: "ExpressionStatement",expression: {type: "BinaryExpression",left: {type: "Identifier",name: "x",loc: {start: {line: 1,column: 0},end: {line: 1,column: 1}}},operator: "**",right: {type: "Identifier",name: "y",loc: {start: {line: 1,column: 5},end: {line: 1,column: 6}}},loc: {start: {line: 1,column: 0},end: {line: 1,column: 6}}},loc: {start: {line: 1,column: 0},end: {line: 1,column: 6}}}],loc: {start: {line: 1,column: 0},end: {line: 1,column: 6}} }, {ecmaVersion: 7,locations: true }
可以看出,大部分内容依然是位置信息,我们照例去掉它们:
{type: "Program",body: [{type: "ExpressionStatement",expression: {type: "BinaryExpression",left: {type: "Identifier",name: "x",},operator: "**",right: {type: "Identifier",name: "y",}}}] }
除去一些参数上的不同,最大的区别可能就是最外层babylon还有一个File节点,而acorn的根节点就是program了,毕竟babel和webpack的工作场景还是略有区别的。
也许,仅听笔者讲述一切都那么简单,然而这只是理想情况,现实的复杂远超我们的想象,简单的举个印象比较深的例子,在两个parser都有有关whitespace的抽象,主要是用于提供一些匹配换行符的正则,通常都想到的是:
/\r\n?|\n/
但实际上完整的却是
/\r\n?|\n|\u2028|\u2029/
而且考虑到ASCII码的情况,还需要很纠结的枚举出非空格的情况
/[\u1680\u180e\u2000-\u200a\u202f\u205f\u3000\ufeff]/
因为parse处理的是我们实际开发中自己coding的代码,不同的人不同的风格,会有怎么样的奇怪的方式其实是非常考验完备性思维的一项工作,而且这往往比我们日常的业务工作的场景更为复杂,它很多时候甚至是接近一个可能性的全集,而并非“大概率可能”的一个集合。虽然我们日常工作这种parser几乎是透明的,我们在init的前端项目时基本已经部署好了开发环境,但是对于某些情况下的实际问题定位,却又有非凡的意义,而且,这还在一定时间内是一个常态,虽然可能在不久的未来,就会有更加智能更加强大的前端IDE。
有关ast的实验,可以试一下这个网站:https://astexplorer.net/