前端编译工具的原理
虽然这本文档主要是讲 Node.js 工具链,但还是很有必要学一些编译知识的。
因为很多 Node 工具要做代码分析、代码修改,这都需要用到编译。
比如 nest cli 精准修改代码:
这些工具都需要对代码做分析和修改。
这节系统来学一下编译知识和常用的编译工具。
首先,编译的含义是从一种语言的代码转成另一种语言的代码。
而 js 编译比较特殊,源代码是 js、ts,编译完之后是 js。
源代码和目标代码的语言一样的时候,也叫做转译(transpile)。
编译的流程是这样的:
源码首先会被解析成 AST( Abstract Syntax Tree 抽象语法树),然后对 AST 做转换,也就是对这棵树的节点做增删改之后,递归打印,生成新的代码。
并且还会生成 sourcemap,也就是编译后的代码和编译前的代码的映射关系,可以通过这个映射回源码,调试的时候就是用 sourcemap 来直接调试源码的。
也就是 parse、transform、generate 这三个阶段。
常用的各大编译工具 babel、swc、typescript、eslint、postcss、prettier、terser 等,都是这个编译流程。
可以在 astexplorer.net/ 看到它们的 AST:
右边这个对象树就是 AST,也就是抽象语法树。
它的每个节点就对应了代码的不同部分。
比如 VariableDeclaration 就是变量声明语句:
FunctionDeclaration 就是函数声明语句:
当然,并不是每种编译工具的 AST 都一样,你可以点这里切换不同的 parser:
比如 @babel/parser、espree(eslint 的内置 parser)、swc、typescript 等。
你会发现 swc 的 AST 和 babel 的 AST 一模一样,因为 SWC 本来就是 rust 版的 babel,甚至连配置、api 这些都一模一样:
它只是语言是用 rust 写的,但使用方式和 babel 一样。
而 typescript 也就是 tsc 的 AST 就和 babel 的有些不一样了:
比如 tsc 的 AST 里变量声明语句叫 VariableStatement,而在 babel 的 AST 里叫 VariableDeclaration
那 AST 有标准么?
有。
在 nodejs 出现之后,前端可以用 nodejs 来做一些工程化的事情,工程化需要对代码做编译、压缩、lint 等处理,也就有了对 js parser 的需求。
Mozilla 在 MDN 上公布了火狐浏览器的 JS 引擎 SpiderMonkey(c++ 写的 js 引擎)的 parser api 和 AST 标准,所以当时最早的 JS parser ---- esprima 就是基于 SpiderMonkey 的 AST 标准来实现的,后来形成了 estree 标准。
babel parser 就是对 estree 标准的实现。
但并不是所有的 js parser 都是 estree 标准的。
如图:
estree 是基于 SpiderMonkey 浏览器引擎的 AST 制定的标准。
esprima 是最早的实现,后来的 acorn 也是 estree 标准的实现。
babel parser 和 eslint 的 espree 的 parser 都是对 acron 的扩展,自然也实现了 estree 标准。
但有些 parser 没有实现这个标准,比如 terser(压缩工具)、typescript 等。
刚才说的这些 parser 在 astexplorer.net/ 都可以看到:
大家知道 AST 有个 estree 的标准就行。
重点还是应用。
既然都是基于 AST,编译流程也都一样,那 babel、tsc、eslint、terser 等都是怎么实现的呢?
首先是 babel:
babel 的插件就是作用在 transform 阶段,对 AST 做自定义的增删改。
比如之前写的自动国际化的插件:
修改完再用 generate 递归打印 AST,生成目标代码和 sourcemap 就好了。
eslint 也是用 AST 来做的错误修复和格式修复:
把 parser 切换成 espree,然后开启 tokens
可以看到,除了 AST 外,多了 token 的信息:
也就是每个关键字、分隔符等的行列号。
比如这段代码:
if (print)
{
const num = a + b;
console.log(num);
}大括号格式不对,用 eslint 是能自动修复的。
怎么实现的呢?
看下 AST:
只要拿到块语句 BlockStatement 的 AST,然后拿到它的第一个 token,也就是 { 的 token,判断下行列号就知道格式是否错误了。
然后拿到他前面的 token,判断下是否是空格,来判断是否换行了。
也就是这样写:
通过 eslint 插件的 getFirstToken、getBeforeToken 的 api,拿到空格、大括号的 token。
判断下行列号,就知道格式是否有错误。
在 fixer 里通过字符串替换的方式来修复错误。
当你执行 eslint --fix 的时候,就会应用 fixer 来修复错误。
这就是 eslint 实现格式检查和修复的原理。
也是基于 AST,只不过多了 token 的信息。
再来看下 terser,它是一个压缩工具:
平时的 js 代码都是需要用 terser 压缩过后才会部署到线上。
那它是怎么实现的呢?
也是基于 AST。
看下之前想对标 terser 的 babel/minify 就知道了:
它的原理就是一系列 babel 插件来分析和转换代码,实现压缩的目的:
比如有的 babel 插件做这种等价替换:
因为 void 0 比 undefined 字符更少嘛。
还有的 babel 插件会替换 true、false 为 !0、!1
总之,通过一系列的代码转换,就达到了压缩代码的目的。
而 terser 也是这样做的。
tsc 也是这样的编译流程,只不过它会基于 AST 实现类型检查。
那如果不做类型检查,用 babel、swc 等都可以编译 ts 代码。
其实现在很多都是这样做的,用 babel、swc 或者其他工具编译 ts 代码,但不做类型检查。
单独跑 tsc --noEmit 来实现类型检查。
虽然现在 babel、swc、esbuild 等很多工具都可以编译 ts 代码,但类型检查只能通过 tsc。
回过头来看下,你会发现 babel、eslint、terser 等都是基于 AST 来做的,那能不能统一为一个工具呢?
自然是可以的。
现在也有人在做这样的事情,但是理论上可行,实现难度比较大。
再来看 css 的编译:
和 babel 一样,postcss 也是 parse、transform、generate 这三个阶段
它也同样是在插件里通过对 AST 做增删改来实现代码的修改
之后打印成新的代码。
js 有 eslint,那基于 postcss 同样可以实现 css 的lint,也就是 stylelint 工具,也同样可以实现压缩等等。
prettier 是做代码格式化的,它怎么知道哪里的格式应该怎么改呢?
也是通过 AST 做的。
综上,所有的前端编译工具,都是 parse、transform、generate 这三个阶段,只不过在 transform 阶段做的事情不同。
总结
这节梳理了下前端工具的编译流程,都是 parse、transform、generate 三个阶段,只不过在 transform 阶段做的事情不同。
babel 是在 transform 阶段做语法降级,比如 aync、await 换成等价实现,最后 AST 打印成新的代码。
eslint 是 AST 结合 parse 出的 token,来实现代码的逻辑错误、格式错误等检查,然后通过字符串替换实现了 fix。
terser 是在 transform 阶段做一系列以压缩为目的的代码转换,比如 undefined 换成 void 0,最终打印成紧凑的难以阅读的等价 js 代码。
postcss 也是类似 babel 的工具,只不过是针对 css 的,基于它也有 stylelint 等工具。
pretter 实现格式化同样也需要基于 AST 来做。
tsc 也是基于 AST 来实现了类型检查,很多工具比如 babel、swc、esbuild 也可以编译 ts 代码,但它们只是编译不做类型检查,目前类型检查只能通过 tsc。
AST 同样也是有规范的,也就是基于 SpiderMonkey 的 JS 引擎扩展的 estree 规范,但并不是所有 parser 都遵循这个规范。
总之,所有的前端编译工具都是围绕 parse、transform、generate 这三个阶段来的,只不过做了不同的事情,就是不同的工具。