【 进阶第18期 】babel原理

1,142 阅读10分钟

前言

Babel是一个转译器,感觉相对于编译器compiler,叫转译器transpiler更准确,因为它只是把同种语言的高版本规则翻译成低版本规则,而不像编译器那样,输出的是另一种更低级的语言代码。

babel 的微内核架构

Babel 和 Webpack 为了适应复杂的定制需求和频繁的功能变化,都使用了微内核 的架构风格。也就是说它们的核心非常小,大部分功能都是通过插件扩展实现的

image.png

@babel/* 原理

babel 是一个 MonoRepo 项目, 不过组织非常清晰,下面就源码上我们能看到的模块进行一下分类, 配合上面的架构图让你对babel有个大概的认识:

babel 核心 一一 @babel/core

|工具|功能|描述| |---|---|---|| |@babel/parser|解析|接受源码,进行词法分析、语法分析,生成AST。内置支持很多语法,JSX、Typescript、Flow、以及最新的ECMAScript规范,但不支持扩展| |@babel/traverse|转换|接受源码,进行词法分析、语法分析,生成AST| |@babel/generator|生成|接受最终生成的AST,并将其转换为代码字符串,同时此过程也可以创建source map|

转译处理流程

image.png

相关逻辑

  • 1、加载和处理配置(config)
  • 2、加载插件 @babel/babel-plugin-*
  • 3、调用 @babel/parser 进行语法解析,生成 AST
  • 4、调用 @babel/traverser 遍历AST,并使用访问者模式应用插件@babel/babel-plugin-*对 AST 进行转换
  • 5、调用@babel/generator生成代码,包括SourceMap转换和源代码生成

着重讲解和编译器类似Babel的转译过程分为三个阶段是: 解析(parse),转换(transform),生成(generate).

解析 解析步骤接收代码并输出 AST。 这个步骤分为两个阶段:

  • 词法分析(Lexical Analysis) :词法分析阶段把字符串形式的代码转换为 令牌(tokens) 流
  • 语法分析(Syntactic Analysis):语法分析阶段会把一个令牌流转换成 AST 的形式

转换 转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作,这个过程需要各种插件进行配合操作。

生成 代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。.

代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串

以ES6代码转译为ES5代码为例,babel转译的具体过程如下: ES6代码输入 ==》 babel进行解析 ==》 得到AST ==》 通过@babel/traverse调用对AST树进行遍历转译 ==》 得到新的AST树 ==》 用@babel/generator通过AST树生成ES5代码

babel 插件

语法插件

  • @babel/plugin-syntax-*:由于@babel/parser内置支持了很多 JavaScript 语法特性,Parser也不支持扩展.因此通过 因此plugin-syntax-*开启或配置某个功能特性

转换插件

  • @babel/plugin-transform-*:普通的转换插件
  • @babel/plugin-proposal-*: 还在'提议阶段'(非正式)的语言特性

预定义集合

  • @babel/presets-*

babel 工具

工具描述备注
@babel/cli@babel/cli依赖@babel/core,允许使用CLI命令行方式编译文件npx babel src --out-dir lib --plugins=@babel/plugin-transform-arrow-functions,@babel/plugin-transform-classes
@babel/node依赖@babel/core & @babel/preset-env,替代 CLI 中的 node 命令,可以直接运行采用 ES6 语法编写的代码(实际上是先编译再执行node脚本)npx babel-node ./src/index.js --presets @babel/preset-env
@babel/register实际上为require加了一个钩子(hook),之后所有被 node 引用的 .es6、.es、.jsx 以及 .js 文件都会先被 Babel 转码
@babel/helper辅助代码,单纯的语法转换可能无法让代码运行起来,比如低版本浏览器无法识别class关键字,这时候需要添加辅助代码,对class进行模拟

@babel/register@babel/node 都非常好用,但是由于二者都是实时转码,因而性能上会有一定影响。官方建议将二者仅置于开发环境下使用。而在正式生产环境中部署时,预先编译代码是值得推荐的做法.

babel 使用

安装babel 命令行工具@babel/cli, 就可以直接在终端执行编译

npm install -D @babel/cli @babel/core
三种编译方式
// 方式一: 通过命令行编译解析
./node_modules/.bin/babel src --out-dir lib

// 方式二: 通过package.json 中 scripts 配置

{
    scripts: {
        build:"babel src --out-dir lib"
    }
}

// 方式三:用`npx babel`来代替`./node_modules/.bin/babel`  npx 其实是`npm`的包运行器
npx babel src --out-dir lib
// 源代码
const fn = () => 1; // 箭头函数, 返回值为1 
console.log(fn());

// 输出代码 (原样输出)
const fn = () => 1; // 箭头函数, 返回值为1 
console.log(fn());
使用指定babel 插件 一一 @babel/plugin-transform-*
// 1、安装插件
npm i --save-dev @babel/plugin-transform-arrow-functions

// 2、 源代码
const fn = () => 1; // 箭头函数, 返回值为1 
console.log(fn());

// 3、编译
npx babel src --out-dir lib --plugins=@babel/plugin-transform-arrow-functions

// 4、输出结果
const fn = function () {
  return 1;
}; 
console.log(fn());

如果是一两个插件,简单使用这个命令行工具就解决了。但是ES6、ES7新增了很多功能,所以如果有一些预设配置就好了。于是有了

使用预定义的集合 一一 @babel/preset-env
// 1、安装
npm i --save-dev @babel/preset-env

// 2、 源代码
const fn = () => 1; // ES6 箭头函数, 返回值为1
let num = 3 ** 2; // ES7 求幂运算符
let foo = function(a, b, c, ) { // ES7 参数支持尾部逗号
    console.log('a:', a)
    console.log('b:', b)
    console.log('c:', c)
}
foo(1, 3, 4)
console.log(fn());
console.log(num);

// 3、编译
npx babel src --out-dir lib --presets=@babel/preset-env


// 4、输出结果
"use strict";

var fn = function fn() {
  return 1;
};


var num = Math.pow(3, 2);

var foo = function foo(a, b, c) {
  console.log('a:', a);
  console.log('b:', b);
  console.log('c:', c);
};

foo(1, 3, 4);
console.log(fn());
console.log(num);

相信到此你对Babel的功能有一定了解了吧, 但是真正使用起来我们不可能都是靠命令行+参数的形式吧, 没错, 接下来我要将这些功能做成配置项.

使用babel 配置文件 一一 babel.config.js

执行命令时,@babel/core 默认会去寻找根目录下的一个名为babel.config.js或者babelrc.js的文件或者packages的babel属性读取配置信息

// babel.config.js
const presets = [
	[
    "@babel/env",
    {
      targets: {
        edge: "17",
        chrome: "64",
        firefox: "60",
        safari: "11.1"
      }
    }
  ]	
]

module.exports = { presets };
// package.json
{ 
   "scripts": { 
        "build": "babel src -d lib" 
   }
}

实战

想要转译 ECMAScript的新语法 + 新API,可以有以下两套方案:

方案一:@babel/preset-env + @babel/polyfill

方案二:@babel/preset-env + @babel/plugin-transform-runtime + @babel/runtime-corejs2

在解释方案之前,我们得先理解 babel 在转译的时候,会将源代码分成 syntax 和 api 两部分来处理:

  • syntax:类似于展开对象、optional chain、let、const 等语法
  • api:类似于 [1,2,3].includes 等函数、方法

const 这种语法为 syntax,includes 这种方法为 api。可以看到,syntax 很轻松就转好了,但是 api 默认并没有做任何处理。babel 转译后的代码如果在不支持 includes 这个方法的浏览器里运行,就会报错。

@babel/preset-env 提供转译 ES 新语法,剩下的事情(即 ES 的新 API,例如:Proxy 转译)才是 @babel/polifill 或 @babel/plugin-transform-runtime 需要去解决的事情

使用@babel/polyfill转换

根据useBuiltIns 参数配置的不同,其处理结果也不一样:

useBuiltIns功能描述优缺点
false对api 不处理即代码转译后不会处理类似includes这些新标准的API ,原样输出。在js代码第一行引入 import '@babel/polyfill'最安全,但打包体积会大一些,一般不选用
entry入口处引入@babel/polyfill需要在源代码的最上方手动引入@babel/polyfill 这个库(该库一共分为两部分,第一部分是 core-js,第二部分是 regenerator-runtime基本覆盖,足够安全且代码体积不是特别大
usage按需加载不需要手动引入 @babel/polyfill代码书写规范,且信任第三方包的时候,可以使用!

entry 全局引入实现api处理

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "entry", // 入口处引入@babel/polyfill
        "debug": true
      }
    ]
  ]
}

转换前后对比:

image.png

这种模式下,babel 会将所有的 polyfill 全部引入,这样会导致结果的包大小非常大,而我们这里仅仅需要 includes 一个方法而已。

usage实现按需加载

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage", 
        "debug": true
      }
    ]
  ]
}

image.png

总结:babel 在转译的过程中,对 syntax 的处理可能会使用到 helper 函数,对 api 的处理会引入对应的垫片(polyfill)。

再来看下一个更复杂情况,使用到class、async generator这些语法时:

image.png

转译过程中像 classasync这类语法中,babel 自定义了 _classCallCheck这个helper函数来辅助,类似的helper函数在每个被转译后的文件里都会被定义了一遍,这样显然不合理.

使用@babel/plugin-transform-runtime转换

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage", 
        "debug": true
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3 // 指定 runtime-corejs 的版本,目前有 2 3 两个版本
      }
    ]
  ]
}

以下图结果来看,通过@babel/plugin-transform-runtime & @babel/runtime-corejs3 就可以解决上面两个问题:

  • api 从之前的直接修改原型改为了从一个统一的模块中引入,避免了对全局变量及其原型的污染,解决了第一个问题
  • helpers 从之前的原地定义改为了从一个统一的模块中引入,使得打包的结果中每个 helper 只会存在一个,解决了第二个问题

image.png

根据corejs配置的不同

corejs optionInstall command描述
falsenpm install --save @babel/runtime提供转换之后,运行时需要的的helper函数 和regenerator-runtime
2npm install --save @babel/runtime-corejs2依赖core-js@2regenerator-runtime
3npm install --save @babel/runtime-corejs3依赖core-js@3regenerator-runtime

结论

在实际生产开发中验证,二者的配置、原理总结如下:

方案优点缺点使用场景
@babel/polyfill依赖core-js@2regenerator-runtime,完整模拟 ES2015+ 环境打包体积过大、且污染全局对象和内置的对象原型。开发非第三方包项目可以一劳永逸开发第三方包时不建议使用
@babel/plugin-transform-runtime不污染原型链,且可以实现按需引入, 打包体积小。不能兼容实例方法建议开发第三方包时使用

@babel/polyfill vs @babel/runtime-corejs2 vs @babel/runtime-corejs3

@babel/polyfill 和@babel/runtime-corejs2 都使用了 core-js(v2)这个库来进行 api 的处理。core-js(v2)这个库有两个核心的文件夹,分别是 library 和 modules。@babel/runtime-corejs2 使用 library 这个文件夹,@babel/polyfill 使用 modules 这个文件夹。

- library 使用 helper 的方式,局部实现某个 api,不会污染全局变量;
- modules 以污染全局变量的方法来实现 api;
- library 和 modules 包含的文件基本相同,最大的不同是_export.js 这个文件:

关于@babel/runtime-corejs2 vs @babel/runtime-corejs3 区别类似 core-js@2 vs core-js@3

详细分析过程 可参考这里

core-js@2 vs core-js@3

core-js@2一个最常见的问题就是包的体积太大(~2M),并且有很多重复的文件被引用。基于这个原因,core-js@3使用Monorepo对包进行拆分管理,三个核心的包分别是

  • core-js:定义全局的polyfill(~500k, 40k minified and gzipped)
  • core-js-pure:提供不污染全局环境的polyfill,等价于core-js@2/library(~440k)
  • core-js-compat:包含了core-js模块和API必要的数据,通过browserslist来生成所需要的core-js模块的列表

写在最后??

作业:手动实现一个babel插件

拓展