从 Babel 工具链看前端工程

Published on
Table of Contents

Babel 在前端工程中占有举足轻重的历史地位,几乎所有大型前端项目都离不开 Babel 的支持。发展至今,Babel 不仅仅是一个代码转换工具,更是形成了工具链(toolchain)生态,是前端基建不可或缺的一环。

下面会通过介绍 Babel 工作流程、Babel 架构等,来帮助我们了解 Babel 生态。

Babel 是什么? 做了什么?

借用官方的一句话介绍:Babel 是一个 JavaScript 的编译器。

Babel is a JavaScript compiler.

Babel 做了哪些事情?

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性
  • 源码转换

Babel 存在的背景及意义:

  1. 支持新语言特性。前端框架层出不穷,借助 Babel 编译,才能使 React/TypeScript 等库开发的应用,将其语言特性进行转换,在浏览器或其他宿主环境运行起来。
  2. 语言特性降级处理。前端语言特性和宿主环境(Nodejs)在高速发展,宿主环境对新语言特性的支持无法做到即时,而开发者又需要兼容各种宿主环境,因此语言特性的降级处理成为刚需。(polyfill)

Babel 工作流程

babel-workflow-example

上图以一条语句为例,简要呈现了 Babel 工作的过程,其工作流程可以抽象概述为以下三个步骤:

  1. 解析 (parse)
  2. 转换 (transform)
  3. 生成 (generate)

为了更好的理解 Babel 的工作流程,下面我们将对其中部分过程展开作详细描述。

1. 解析 Parse

源码解析步骤接收代码并输出 AST 结构。通常这个步骤包含两个过程:词法解析和语法解析

词法解析 Lexical Analysis

词法分析阶段,词法解析器(Tokenizer)将字符串形式的代码转换为令牌(Token)流。

Token 可以视作是一个语法片段数组,每个 Token 中包含了语法片段、位置信息、以及一些类型信息,这些信息有助于后续的语法分析。形如:['console', 'log', '(', 'a', ')']

通常我们对这一阶段关注的较少,更多的是关注后面的 AST。

语法解析 Syntactic Analysis

语法分析阶段,语法解析器(Parser)会把 Tokens 转换成抽象语法树(AST)。

抽象语法树(Abstract Syntax Tree, AST)是由一些具有特定结构的节点组成的树状结构,用于描述程序。每个节点类型都是一个有意义的语法单元,它记录了一些属性来描述节点信息,包括:语法片段,位置信息等。有关 AST 中核心节点类型的定义可以查看 Babylon 手册

实际上,目前 Babel 已经内置了包括 ESNext, JSX, Flow, TypeScript 在内的语法解析插件,随着支持语法特性、内置的语法种类增多,未来节点类型数量还会不断的增加。你可以想象一下 Babel 解析后的节点类型有多少~

至于你想通过可视化的方式了解 AST,可以在 AST explorer 审查解析后的结构,这也是 Babel 插件开发者的辅助工具。

通过上述两个解析阶段,源代码最终被解析成 AST 树,AST 是 Babel 转译的核心数据类型,后续的操作都基于 AST 实现。

至于 AST 是什么?怎么操作?这些会在后边单独开一个章节介绍。

2. 转换 Transform

代码转换步骤接收 AST 并对其进行遍历,在此过程中对 AST 节点进行添加、更新及移除等操作。这是 Babel 工作过程中最复杂的过程,同时也是插件接入工作的部分。

3. 生成 Generate

代码生成步骤把最终的 AST 转换成字符串形式的代码,同时还会生成源码映射(SourceMap)。

工作流程总结

通过上述解释,应该都了解 Babel 的工作流程,再回顾一下各个阶段的作用:

  • 解析阶段,将源代码转换为 AST
  • 转换阶段,遍历 AST,利用各种插件进行代码转换
  • 生成阶段,将转换后的 AST 生成目标环境支持的代码

总的来说,Babel 帮我们完成了从开发源代码到生产可运行代码的转换。也正是由于 Babel 这样优秀的工具存在,开发者能在开发中编写各式各样规则的代码(jsx, ts, vue, css in js...),甚至可以按照自己喜欢的方式定义代码格式,而不用担心程序最终运行在哪里,当然前提是你的代码能够被转换成浏览器熟悉的代码。

快速认识 Babel,架构解读

熟悉 Webpack 的同学应该知道,它为了适应复杂的定制化需求,使用了微内核架构(插件架构)风格,同样的设计也在 Babel 这里得到应用。所谓微内核,也就是说它们的核心非常小,大部分功能都是通过插件扩展实现的。

了解 Babel 的架构设计,有利于理解其功能原理和用法,对于后续的 babel 插件开发也有很大的帮助。

babel-ecosystem-diagram

Babel 代码仓库采用个 monorepo 设计,组织非常清晰。下面的内容会结合其中几个常见常用的包,介绍它们在 Babel 工具链中发挥的作用。

一个核心插件

要说 Babel 的核心插件是哪个,开发中常用的插件是哪个,那一定是 @babel/core,它可以说是整个 Babel 生态系统的核心。这里的知识点比较多且杂,不过不需要急于去研究它。

对个 Babel 来说,它的职责几乎覆盖了整个工作流程,其功能包括但不限于:

  • 加载和处理配置
  • 加载插件
  • 调用 Parser 解析源代码,生成 AST
  • 调用 Traverser 遍历 AST,并使用访问者模式应用插件对 AST 进行转换
  • 调用 Generator 生成代码,包括 SourceMap 转换和源代码生成

三个能力支撑插件

Babel 的工作流程,通常认为是解析、转换、生成三个步骤,实际上每个步骤都是由一个独立的插件支撑功能实现,它们分别是:

@babel/parser:JavaScript Parser(原 Babylon),作用是负责将源代码解析为 AST。该插件内置支持了 ESNext, JSX, TypeScript, Flow 语法,目前为了执行效率,暂不支持插件拓展,且内置的功能全部是由官方维护。

@babel/traverse:实现了访问者模式,对 AST 进行遍历,维护 AST 树的状态,负责节点的增删改等操作。自定义的插件在 @babel/core 中被加载进来,在这个阶段起作用,转换插件会通过它获取感兴趣的 AST 节点,继而对节点进行自定义操作,从而实现语法转换功能。

@babel/generator:将 AST 转换成目标环境可运行的脚本,支持 Source Map。

实际上,核心插件 @babel/core 囊括了这三个插件,负责它们为 Babel 提供核心能力支撑。Babel 在设计架构的时候,虽然是将各个功能分开维护,但最终把 API 的时候还是提供了一个能够涵盖所有能力的库,这让开发者使用起来很方便,即使不够了解 Babel 依然能够使用其中的某些 API 定制需求。

插件生态

要是单说 babel 的插件,那就太多了,但他们又都比较容易归类。

第一类 语法插件

这类插件通常是以 @babel/plugin-syntax-* 为前缀的格式命名。由于 Parser 内置了 ESNext, JSX, TypeScript, Flow 语法支持,且不支持拓展,因此功能维护都控制在官方手里,此类插件实际上只是用于开启或配置 Parser 的某个功能特性,支持源码解析成特定的 AST。

第二类 转换插件

这类插件通常是以 @babel/plugin-transform-* , @babel/plugin-propsal-* 为前缀的格式命名。他们之间的区别是,前者是普通的转换插件,后者是还处于提议阶段(非正式)的语言特性。转换插件会启用相应的语法插件,因此不需要同时声明这两种插件。

第三类 预设集合

看 Babel 仓库列表已经支持了那么多插件,如果项目中用到很多插件,一个个的配置是不是相当繁琐啊?!解决方案就是官方提供了预设,通过引入一个或多个插件集的方式,批量配置 babel 插件,从而简化开发人员的使用成本。

这类插件通常是以 @babel/preset-* 格式命名。我们可以理解为 preset 就是一组插件的集合,为的是方便开发者对插件进行管理和使用,甚至某些入门开发场景中能达到开箱即用。比如 preset-env 预设允许你使用最新的 JavaScript 语法。我们也可以根据实际需要定制属于我们项目的 preset 插件集。

@babel/preset-env 为例,该插件集包括支持现代 JavaScript(ES6+) 的所有插件。

那么它是如何工作的呢?

实际上,Babel 借助了 browserslist, compat-table 等开源项目,这些库都提供了目标环境与其已支持能力的映射,那么 babel 在运行时就很容易判断出目标环境还有哪些能力没有被支持,从而根据目标环境生成一份插件列表,在后面的转换中 core-js polyfill 做代码转化,使目标环境能最大程度的运行我们使用的 ESNext 新语法。

Babel 编译 typescript 与 tsc 编译 typescript 有什么区别?

值得一提的是,typescript 最初只能是 tsc(typescript compiler) 编译,编译过程通常是先由 tsc 编译成 javascript,然后再由 babel 编程成目标环境可运行的 javascript,这就造成了极大的编译消耗。于是 typescript 团队找到 babel 合作,将 typescript 的编译过程内置到 babel 中,于是就有了现在 babel 支持 typescript 这一说。

tsc 编译 typescript 文件大体过程与 babel 编译过程类似,只不过是在转换 ast 之前增加了一步 checker 过程,即类型推导和检查。由于 babel 是单文件编译,因此不支持全局类型检查,这也是 babel 编译 typescript 文件的缺陷。

虽然不能完全支持 typescript 语法功能,但现代前端工程,使用 babel 编译 typescript 确实是更好的选择。与 tsc 相比,其优势如下:

  • 产物体积小:tsconfig compilerOptions.target 支持到具体的目标语言版本,而 babel 结合 compat-data, browserslist 等开源库,能确定目标环境具体支持的语法,以更精确的 polyfill 达到更小体积的目标。
  • 支持语言特性:相比 tsc,babel 以插件的形式支持了提案阶段的语言特性,形如:@babel/proposal-xxx
  • 编译速度:相比 tsc,babel 没有 checker 这一耗时的步骤,因此在编译速度上更快。

总的来说,babel 在产物体积、语言特性、编译速度这几项重要指标下,都优于 tsc,这也是现代大多数项目采用 babel 编译 typescript 的原因。但如果想要支持类型检查,可以执行 tsc --noEmit 代替。

在很久以前(Before Babel 7),你可能还能看到形如 @babel/preset-stage-* 格式命名的插件,这类插件是处于 ECMA 提案阶段的功能,现在已经被官方移除。目前官方插件仅有上述图中的四种。

开发常用插件

@babel/polyfill 是一个为目标环境提供未支持的新特性的插件,是对执行环境能力的补充。

  • 目标环境不一定需要所有的 polyfill 功能,代码全量引入势必会带来大量(89K)无效代码,因此我们在配置时,通常是结合 preset-env 配置 { useBuiltIns: "entry", corejs: "3" } 达到只包含需要的 polyfill 目的。
  • 仅支持 TC39 Stage4 阶段提案的功能。
  • 由于插件是在生产环境中使用,因此安装时注意要安装到 dependencies 依赖列表中。
  • 在 Babel 7.4.0 之后的版本,这个插件将被废弃,取而代之的是直接引入 core-js/stable 和 regenerator-runtime/runtime 代替。

@babel/plugin-transform-runtime 是一个可以重复使用 Babel 注入的帮助程序,以节省代码大小的插件。通常需要和 @babel/runtime 配合使用,也就是经常看到的两个插件要同时安装。

@babel/runtime 是一个类似 polyfill 的库,区别是这个插件不会污染全局变量,而 @babel/polyfill 会污染全局。

@babel/cli 从命令行使用 Babel 编译文件的简单方法。实际上,这是我们最常见的运行 babel 的方式,CLI 依赖 @babel/core 提供的的能力,通过提供 babel.config.js 等配置文件或直接向命令行注入参数,实现编译项目代码的功能。

# 编译一个文件
$ babel input.js --out-file output.js
# 编译一个目录
$ babel src --out-dir lib

@babel/node 是一个类似 nodejs 命令的 CLI,在程序运行之前使用 Babel 配置进行编译。

# use node
$ node index.js
# use babel-node
$ babel-node index.js

@babel/register 允许我们在程序运行中编译某个文件。

@babel/types 定义了大量的 AST 节点信息,插件开发中常用。

@babel/template 通过 @babel/types 创建的节点需要一个个的组装,如果节点很多这将是一个很大的工作量,babel 提供了 template 包来批量创建。

后 Babel 时代的前端编译工具

大多数前端项目开发都会用到 webpack 做资源打包,但是 webpack 在编译大型项目时,编译速度就有点跟不上思想了,动辄几分钟几十分钟。通过分析 webpack 在 CompilerCompilation 的工作流程的耗时分析不难发现:

  • 对于 Compiler 实例而言,耗时最长的是生成编译过程实例后的 make 阶段,在这个阶段里,会执行模块编译到优化的完整过程。
  • 对于 Compilation 实例的工作流程来说,不同的项目和配置各有不同,但总体而言,编译模块和后续优化阶段的生成产物并压缩代码的过程都是比较耗时的。

不同项目的构建,在整个流程的前期初始化阶段与最后的产物生成阶段的构建时间区别不大。真正影响整个构建效率的还是 Compilation 实例的处理过程,这一过程又可分为两个阶段:编译模块和优化处理。 其中对于 JavaScript 而言,优化处理是后续 Tarser 处理,编译模块就对应 Babel 编译,因此 babel 成为 webpack 打包构建过程中最耗时的步骤之一。

babel vs. swc

相较 swc,babel 编译慢基本可以排除是自身架构问题,真正还是语言劣势,本身用 js 写的 babel 无法使用多核 CPU 优化编译任务处理。而 swc 是编译成二进制在 node 执行,速度具备天然优势。

读了 swc 的文档,整体的感受如下:

  • swc 产品对标 babel,而不是 webpack,是一款 js/ts 编译器
  • 部分功能设计是站在 babel 的肩膀上,从提供的迁移文档来看,基本是以 babel 为基础开发的 swc
  • swc 速度更快,支持多核并行编译,优势更明显,给前端编译提供了新的方案 👍
  • 目前从 babel 迁移到 swc 成本不低,插件完整性、插件自身获取的信息较少、生态不够完善等,目前市面上也缺少一款 babel-to-swc 的插件

结论:swc 暂时还不具备在大型项目中使用的条件,swc 很好,也还不够好。产品趋于稳定后可考虑大项目迁移到 swc。


资料参考: