Babel is JavaScript compiler.

将 js 解析成 AST语法树,再通过可插拔的插件机制,让每个插件对 ast 语法树做不同的处理

  • Transform syntax(语法转换),一般是高级语言特性的降级
  • Polyfill(垫片/补丁)特性的实现和接入
  • Source code transformations (codemods),源码转换,比如 JSX 等
  • 。。。

polyfill(垫片),就是垫平同一段代码不同浏览器或者不同环境下的差异,让新的内置函数、实例方法等在低版本浏览器中也可以使用。

babel 的功能大多通过 plugin 插件实现,设计理念:

  • 可插拔(Pluggable):灵活的插件机制,召集第三方开发者力量,同时还需要方便接入各种工具;
  • 可调试(Debuggable):在编译过程中,要提供一套 Source Map,来帮助使用者在编译结果和编译前源码之间建立映射关系,方便调试
  • 基于协定(Compact):灵活的配置方式

微信截图_20210114215533.png
AST 是 Babel 转译的核心数据结构,后续的操作都依赖于 AST

  • 所有插件都是在 Transformer 阶段对 AST 进行操作

    Babel 架构

    微信截图_20210114222255.png
    CgqCHl_u4niAOtOmAAEw7EQpjEI596.png

    核心 -> @babel/core

  • 加载和处理配置(config)

  • 加载插件
  • 调用 Parser 进行语法分析,生成 AST
  • 调用 Traverser 遍历 AST ,并使用访问者模式应用‘插件’对 AST 进行转换
  • 生成代码,包括 SourceMap 转换和源代码生成

    核心周边支持

  • Parser(@babel/parser):将源代码解析为 AST

  • Traverser(@babel/traverse):实现访问者模式,遍历 AST , 插件通过它对节点进行操作
  • Generator(@babel/generator):将 AST 转换为源代码,支持 SourceMap

    插件

  • 语法类(@babel/plugin-syntax-*): 只是用于开启或配置 Parser 的某个功能特性

  • 转换插件:用于对 AST 进行转换(转为 ES5 代码、压缩、功能增强等)
    • @babel/plugin-transform-*:普通的转换插件
    • @babel/plugin-proposal:用于‘提议阶段’的语言特性转换
  • 预定义集合(@babel/presets-*):插件集合或分组,主要用于对插件进行管理和使用

    插件开发辅助

  • @babel/template:可以将字符串转换为 AST

  • @babel/type:一个用于 AST 节点的 Lodash 式工具库。主要用来操作AST节点,比如创建、校验、转变等。举例:判断某个节点是不是标识符(identifier)
  • @babel/helper-*:辅助器
  • @babel/helper:辅助代码,单纯的语法转换可能无法让代码运行起来,比如低版本浏览器无法识别class关键字,这时候需要添加辅助代码,对class进行模拟

    Visitors (访问者模式)

    访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法

  1. const MyVisitor = {
  2. Identifier() {
  3. console.log("Called!");
  4. }
  5. };
  6. // 你也可以先创建一个访问者对象,并在稍后给它添加方法。
  7. let visitor = {};
  8. visitor.MemberExpression = function() {};
  9. visitor.FunctionDeclaration = function() {}

遍历操作 AST 一般使用该模式

  • 进行统一的遍历操作
  • 提供节点的操作方法
  • 响应式维护节点之间的关系
  • 插件定义自己感兴趣的节点类型,当访问者访问到对应节点时,就调用插件的访问方法

    节点的遍历

  • 深度优先,递归遍历 AST

    当我们向下遍历这颗树的每一个分支时我们最终会走到尽头,于是我们需要往上遍历回去从而获取到下一个节点。 向下遍历这棵树我们进入每个节点,向上遍历回去时我们退出每个节点

所以当创建访问者时你实际上有两次机会来访问一个节点。

  1. const MyVisitor = {
  2. Identifier: {
  3. enter() {
  4. console.log("Entered!");
  5. },
  6. exit() {
  7. console.log("Exited!");
  8. }
  9. }
  10. };

微信截图_20210115142019.png

而 @babel/traverse 在访问 AST 树的节点时,在进入每个节点时触发 enter 钩子函数,退出每个节点时触发 exit 钩子函数。开发者可在钩子函数中对 AST 进行修改。

  1. import traverse from "@babel/traverse";
  2. traverse(ast, {
  3. enter(path) {
  4. // 进入 path 后触发
  5. },
  6. exit(path) {
  7. // 退出 path 前触发
  8. },
  9. });

节点的上下文

就是 traverse 访问节点时传入的 Path 对象

A Path is an object representation of the link between two nodes.

微信截图_20210115143703.png

在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。 当你调用一个修改树的方法后,路径信息也会被更新。 Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。 当你有一个 Identifier() 成员方法的访问者时,你实际上是在访问路径而非节点。 通过这种方式,你操作的就是节点的响应式表示(译注:即路径)而非节点本身。

  1. const MyVisitor = {
  2. Identifier(path) {
  3. console.log("Visiting: " + path.node.name);
  4. }
  5. };

副作用处理

AST 的转换过程中出现的副作用,比如有些插件会替换一些节点,那么就没必要继续向下遍历,直接访问新的节点

微信截图_20210115144709.png

作用域的处理

Path 对象 的 Scope 属性

  1. {
  2. path: NodePath;
  3. block: Node; // 所属的词法区块节点, 例如函数节点、条件语句节点
  4. parentBlock: Node; // 所属的父级词法区块节点
  5. parent: Scope; // 指向父作用域
  6. bindings: { [name: string]: Binding; }; // 该作用域下面的所有绑定(即该作用域创建的标识符)
  7. }

当你创建一个新的作用域时,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内收集所有的引用(“绑定”)。
一旦引用收集完毕,你就可以在作用域(Scopes)上使用各种方法。
通过 bindings 属性可以获取当前作用域下的所有绑定(即标识符)

所有引用属于特定的作用域,引用和作用域的这种关系被称作:绑定(binding)

  1. export class Binding {
  2. identifier: t.Identifier;
  3. scope: Scope;
  4. path: NodePath;
  5. kind: "var" | "let" | "const" | "module";
  6. referenced: boolean;
  7. references: number; // 被引用的数量
  8. referencePaths: NodePath[]; // 获取所有应用该标识符的节点路径
  9. constant: boolean; // 是否是常量
  10. constantViolations: NodePath[];
  11. }

有了这些信息你就可以查找一个绑定的所有引用,并且知道这是什么类型的绑定(参数,定义等等),查找它所属的作用域,或者拷贝它的标识符。 你甚至可以知道它是不是常量,如果不是,那么是哪个路径修改了它。

plugins

原始代码 —> [Babel Plugin] —> 转换后的代码

使用 transform 或 proposal 的时候,如果需要进行语法的转换,正常情况下会自动启用相应的 syntax 插件,不需要我们自己配置。

.babelrc 中配置 plugins

  1. "plugins": [
  2. "@babel/plugin-syntax-dynamic-import",
  3. "@babel/plugin-transform-async-to-generator",
  4. "@babel/plugin-proposal-object-rest-spread",
  5. ["import", {
  6. "libraryName": "antd",
  7. "libraryDirectory": "es",
  8. "style": "css" // `style: true` 会加载 less 文件
  9. }]
  10. ]

plugins 是一个数组:

  1. 纯字符串,用来标识一个plugin
  2. 另外一个数组,这个数组的第一个元素是字符串,用来标识一个plugin,第二个元素,是一个对象字面量,可以往plugin传递options配置

    plugin 的启用顺序

  3. 先执行完所有 Plugin,再执行 Preset

  4. 多个 Plugin,按照声明次序顺序执行
  5. 多个 Preset,按照声明次序逆序执行

babel-core 最后传递给访问器的数据结构大概长这样

  1. {
  2. Identifier: {
  3. enter: [plugin-xx, plugin-yy,] // 数组形式
  4. }
  5. }

Preset

Babel 插件一般尽可能拆成小的力度,开发者可以按需引进。比如对 ES6 转 ES5 的功能,Babel 官方拆成了20+个插件。
可以 Babel Preset 视为 Babel Plugin 的集合。比如 babel-preset-es2015就包含了所有跟ES6转换有关的插件。
官方推荐的 preset :

  • @babel/preset-env
  • @babel/preset-flow
  • @babel/preset-react
  • @babel/preset-typescript

preset 在 .babelrc 文件中的配置方式和 plugin 基本没差

@babel/preset-env

@babel/preset-env is a smart preset that allows you to use the latest JavaScript without needing to micromanage which syntax transforms (and optionally, browser polyfills) are needed by your target environment(s). This both makes your life easier and JavaScript bundles smaller!

在用到还处于 proposal 阶段的 ES 新特性时, preset-env 不一定会提供转码支持,需要自己配置 plugins

options

For more information on setting options for a preset

写几个觉得比较有用的 options

  1. "presets": [
  2. [
  3. "@babel/preset-env",
  4. {
  5. "targets": {
  6. ios: 8,
  7. android: 4.1
  8. },
  9. useBuiltIns: "usage",
  10. corejs: {version: 3, proposals: true},
  11. debug: true
  12. }
  13. ],
  14. "@babel/preset-react"
  15. ]

targets

和 .browserslist 文件作用相同,用来确认目标运行环境

debug

Outputs to console.log the polyfills and transform plugins enabled by preset-env and, if applicable, which one of your targets that needed it.

就是开启转码的调试

useBuiltIns

"usage" | "entry" | false, defaults to false.

使用 usage 或 entry 后会导入 core-js 做为 polyfill, 需要单独安装 core-js v3 到 dependences

  1. npm install core-js@3 --save

主要使用 usage ,会根据每个文件里面,用到了哪些 es 的新特性,然后根据我们设置的 targets 判断,是否需要polyfill,如果 targets 的最低环境不支持某个 es 特性,则这个 es 特性的 core-js 的对应 module 会被注入。搭配 corejs: { version: 3, proposals: true } 就可以根据需要注入 proposals 的 polyfill

browserslist

在 .browserslist 文件中配置,用来确认目标运行环境。也可以使用 preset-env 的 target option 直接配置相应的目标环境,就不用单独配置 .browserslist 文件

  1. last 3 versions
  2. Safari >= 8
  3. iOS >= 8

core-js

core-js 是完全模块化的 javascript 标准库。 包含了 ECMAScript 2020 在内的多项特性的 polyfills,以及 ECMAScript 在 proposals 阶段的特性、WHATWG/W3C 新特性等。 它可以直接全部注入到全局环境里面,帮助开发者模拟一个包含众多新特性的运行环境,这样开发者仅需简单引入 core-js,仍然使用最新特性的 ES 写法编码即可;也可以不直接注入到全局对象里面,这样对全局对象不会造成污染,但是需要开发者单独引入 core-js 的相关module,并可能还需要通过手工调用 module 完成编码,没法直接使用最新 ES 的写法。它是一个完全模块化的库,所有的 polyfill 实现,都有一个单独的 module 文件,既可以一劳永逸地把所有 polyfill 全部引入,也可以根据需要,在自己项目的每个文件,单独引入需要的 core-js 的 modules 文件。

工程预览

  • core-js:实现的基础垫片能力,是整个 core-js 的逻辑核心
  • core-js-pure:提供了不污染全局变量的垫片能力

    1. import _from from 'core-js-pure/features/array/from';
    2. import _flat from 'core-js-pure/features/array/flat';
  • core-js-compact:维护了按照browserslist规范的垫片需求数据,来帮助我们找到“符合目标环境”的 polyfills 需求集合

    1. const {
    2. list, // array of required modules
    3. targets, // object with targets for each module
    4. } = require('core-js-compat')({
    5. targets: '> 2.5%'
    6. });
  • core-js-builder:可以结合 core-js-compact 以及 core-js,并利用 webpack 能力,根据需求打包出 core-js 代码,根据需要构建出不同场景的垫片包

    1. require('core-js-builder')({
    2. targets: '> 0.5%',
    3. filename: './my-core-js-bundle.js',
    4. }).then(code => {}).catch(error => {});

    core-js 中如何复用一个 Polyfill 实现

  1. core-js 需要会以污染原型的方式来扩展方法
  2. core-js-pure 单独维护了一份 export 镜像 ../internals/export
  3. core-js-pure 包中的 Override 文件,在构建阶段,复制 packages/core-js/ 内的核心逻辑,提供复写这些核心 polyfills 逻辑的能力,通过构建流程,进行 core-js-pure/override 替换覆盖

    transform-runtime

    @babel/plugin-transform-runtime 的作用是将 helper 和 polyfill 都改为从一个统一的地方引入,并且引入的对象和全局变量是完全隔离的,可以解决下面两个问题

babel 在转译的过程中,对 syntax 的处理可能会使用到 helper 函数,对 api 的处理会引入 polyfill。
默认情况下,babel 在每个需要使用 helper 的地方都会定义一个 helper,导致最终的产物里有大量重复的 helper;引入 polyfill 时会直接修改全局变量及其原型,造成原型污染。

正常的 babel 插件

  1. export default function (babel) {
  2. const { types: t } = babel;
  3. return {
  4. visitor: {
  5. Identifier(path) {
  6. // 判断变量名为str
  7. if (path.isIdentifier({ name: "str" })) {
  8. path.node.name = "transformStr";
  9. }
  10. }
  11. }
  12. };
  13. }

编写自己的插件

Babel 在编译的时候会深度遍历 AST 对象的每一个节点,采用访问者的模式,每个节点都会去访问插件定义的方法,如果类型和方法中定义的类型匹配上了,就进入该方法修改节点中对应属性。在节点遍历完成后,新的 AST 对象也就生成了

  1. Parse the code to AST format
  2. Traverse the AST and find nodes adjacent to the nodes we want to add
  3. Insert the new nodes
  4. Generate the new code from our AST

转换代码为 AST

parse(sourceCode) => AST

  1. const fs = require('fs');
  2. const parser = require('@babel/parser').parse;
  3. const fileLocation = './reducers.js';
  4. const file = fs.readFileSync(fileLocation).toString();
  5. // since we are using ES6 modules, we need to let the parser know with {sourceType: ‘module’}.
  6. const ast = parser(file, {sourceType: 'module'});

遍历 AST 节点获取自己想要的属性

借助 AST explorer 这个站点可以清晰的看到相应代码的 AST 结构树

  1. const traverse = require('@babel/traverse').default;
  2. // Traverse the AST to find the nodes we need.
  3. traverse(ast, {
  4. // ImportDeclaration可以拿到import进行的东西
  5. ImportDeclaration(path) {
  6. lastImport = path;
  7. },
  8. ObjectExpression(path) {
  9. properties = path.parent.declaration.properties
  10. },
  11. })

往相应的节点属性进行 CURD

可以借助 Babel Types 模块(一个用于 AST 节点的 Lodash 式工具库)

transform(AST, BabelPlugins) => newAST

  1. const t = require("@babel/types");
  2. // add import to AST
  3. const importCode = `import ${REDUCER_NAME} from './${REDUCER_NAME}'`;
  4. lastImport.insertAfter(parser(importCode, {sourceType: 'module'}));
  5. // add export to AST
  6. const id = t.identifier(REDUCER_NAME)
  7. properties.push(t.objectProperty(id, id, false, true))

将 AST 转回代码

generate(newAST) => newSourceCode

  1. const generate = require('@babel/generator').default;
  2. const prettier = require('prettier');
  3. const newCode = generate(ast).code;
  4. const prettifiedCode = prettier.format(newCode, { parser: 'babylon' })
  5. fs.writeFile('transformed.js', prettifiedCode, (err) => {
  6. if (err) throw new Error(`addToReducerIndex.js write error: ${err}`)
  7. });

编写插件的最佳实践

尽量避免遍历 AST

及时合并访问者对象

  1. path.traverse({
  2. Identifier(path) {
  3. // ...
  4. }
  5. });
  6. path.traverse({
  7. BinaryExpression(path) {
  8. // ...
  9. }
  10. });
  11. // 优化
  12. path.traverse({
  13. Identifier(path) {
  14. // ...
  15. },
  16. BinaryExpression(path) {
  17. // ...
  18. }
  19. });

可以手动查找就不要遍历

  1. const visitorOne = {
  2. Identifier(path) {
  3. // ...
  4. }
  5. };
  6. const MyVisitor = {
  7. FunctionDeclaration(path) {
  8. path.get('params').traverse(visitorOne);
  9. }
  10. };
  11. // 优化,避免代价更高的遍历
  12. const MyVisitor = {
  13. FunctionDeclaration(path) {
  14. path.node.params.forEach(function() {
  15. // ...
  16. });
  17. }
  18. };

学习资料

  1. 官方文档
  2. 一老哥写的babel文章
  3. 前端基础建设与架构30讲
  4. 深入浅出 Babel 上篇:架构和原理 + 实战
  5. babel 插件手册