金数据技术博客 · №1

一次 polyfill 对 babel 的思考🤔

· silverWolf818 · frontend

一、起因

正在写 bug🐛 的我,突然被叫去看一个问题,页面突然白屏了。于是本能的反应是快速打开控制台,发现 js 有报错,报错信息是 .flat() is not a function,原来是浏览器版本不支持 flat API,做一下 polyfill 就好了。那么我们如何 polyfill 呢?🤔 接下来就来详细分析一下 polyfill 的过程。

二、Babel 的作用

首先在进行 polyfill 之前,我们复习一下 Babel 这个编译器。官方文档

Babel 主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。 作用如下:

换而言之就是 Babel 能让我们现在就使用新的语法,无需等待浏览器的支持。

看上去很美好,但还是有一些原生 API 方法, Babel 并没用做转换处理,例如:flatflatMapincludes 等。这时我们就需要为 Babel 编译器做一些配置,也就是 polyfill。

三、@babel/polyfill

正常我们使用 Babel 是不需要安装 @babel/polyfill,但是如果我们需要兼容版本较低的浏览器,就可以使用该库。

@babel/polyfill 它会模拟完整的 ES2015+ 环境用于我们的程序,这样我们使用的 PromiseWeakMap 这样的新内置函数,Array.fromObject.assign 这样的静态方法,Array.prototype.includes 这样的实例方法等,会被 polyfill 添加到全局范围。

使用方法也相对简单,只需要安装 @babel/polyfill

yarn add @babel/polyfill

然后在我们的入口文件引入:

import "@babel/polyfill"

来看一个示例:

我们编写一个 index.js 文件如下:

import '@babel/polyfill'

const a = [1, 2, 3, 4].flat()
const b = [1, 2, 3, 4].flatMap()
const c = [1, 2, 3, 4].includes(1)

然后使用 webpack 工具编译,输出后的结果如下:

截图中红色区域就是我们引入的 @babel/polyfill

@babel/polyfill 的入口文件,实质上是把 ES2015+ 的 polyfill 和一个 runtime 转译生成器函数引入。

这样无疑增加了我们打包的体积,我只使用了 flatflatMapincludes,却引入了整个 polyfill

在新的 Babel 版本(7.4.0)中 @babel/polyfill 已经被废弃,不在推荐使用。这里简单了解一下即可。

源码地址

四、被遗忘的 core-js

这是什么鬼 👻 ,日常我们更多的可能是使用 react 等库进行前端开发,很少会关注底层的一些依赖,了解它对于我们 polyfill 很重要。

core-js 是一个 JavaScript 的模块化标准库。包括 ECMAScript 到 2021 年的 polyfill。 官方文档

core-js 的几个核心概念:

core-js 目前有 2 个版本,2 和 3 ,由于 3 版本进行了一些破坏性变更导致无法兼容 2 版本,现在 2 版本被废弃使用。

core-js@3 的重要改变:

由于 core-js@2 版本包的体积太大(~2M),并且有很多重复的文件被引用。core-js@3 对包进行拆分,三个核心的包分别是:

对于 core-js@3 的入口文件,我们可以这样使用:

// polyfill all `core-js` features:
import "core-js";
// polyfill only stable `core-js` features - ES and web standards:
import "core-js/stable";
// polyfill only stable ES features:
import "core-js/es";

// if you want to polyfill `Set`:
// all `Set`-related features, with ES proposals:
import "core-js/features/set";
// stable required for `Set` ES features and features from web standards
// (DOM collections iterator in this case):
import "core-js/stable/set";
// only stable ES features required for `Set`:
import "core-js/es/set";
// the same without global namespace pollution:
import Set from "core-js-pure/features/set";
import Set from "core-js-pure/stable/set";
import Set from "core-js-pure/es/set";

// if you want to polyfill just required methods:
import "core-js/features/set/intersection";
import "core-js/stable/queue-microtask";
import "core-js/es/array/from";

我们还可以使用 core-js-compat 用来提供不用目标引擎所需要的core-js的模块信息:

const {
  list,              // array of required modules
  targets,           // object with targets for each module
} = require('core-js-compat')({
  targets: '> 2.5%', // browserslist query
  filter: 'es.',     // optional filter - string-prefix, regexp or list of modules
});

console.log(targets);

五、了解 browserslist

我们使用 React 开发,在 package.json 中有这么一段配置:

"browserslist": {
  "production": [
    ">0.2%",
    "not dead",
    "not op_mini all"
  ],
  "development": [
    "last 1 chrome version",
    "last 1 firefox version",
    "last 1 safari version"
  ]
}

或在根目录存在一个 .browserslistrc 的配置文件。

那么这个 browserslist 有什么用呢?

browserslist 能在不同前端工具之间共享目标浏览器和 Node.js 版本的配置。

browserslist 使用 caniuse-litecaniuse 进行查询。

那么我们如何知道这个配置都兼容是什么,官方也提供了查询。

例如我们有如下配置:

"browserslist": [
  ">0.2%",
  "not dead",
  "not op_mini all"
]

然后运行:

npx browserslist

输出:

这个配置的大概意思:

这里不做过多的展开,有兴趣的可以查看。 官网文档

六、了解 @babel/preset-env

前面花了这么多篇幅介绍相关的工具和库,都是为了介绍 @babel/preset-env 做铺垫,@babel/preset-env 可以说是我们做 polyfill 的核心包。

@babel/preset-env 是什么呢?

官网文档

首先,介绍历史背景,对了解和学习 @babel/preset-env 有帮助。

最初,为了让开发者能够尽早用上新的 ES 特性,babel 团队开发了 babel-preset-latest。这个 preset 比较特殊,它是多个 preset 的集合(es2015+),并且随着 ECMA 规范的更新更增加它的内容。

比如(2018.06.02),它包含的 preset 包括:es2017、es1016、es2015。

到了明年,可能它包含的 preset 就包括:es2020、es2019、es2018、es2017、es2016、es2015。

随着时间的推移,babel-preset-latest 包含的插件越来越多,这带来了如下问题:

  1. 加载的插件越来越多,编译速度会越来越慢;
  2. 随着用户浏览器的升级,ECMA 规范的支持逐步完善,编译至低版本规范的必要性在减少(比如ES6 -> ES5),多余的转换不单降低执行效率,还浪费带宽。

因为上述问题的存在,babel 官方推出了 @babel/preset-env 插件。它可以根据开发者的配置,按需加载插件。配置项大致包括:

  1. 需要支持的平台:比如node、浏览器等。
  2. 需要支持的平台的版本:比如支持 node@6.1等。

一句话解释:

@babel/preset-env 可以根据配置的目标浏览器或者运行环境来自动将 ES2015+ 的代码转换为 es5。

其实它和 Babel 做的事情有部分是重叠,由于 Babel 还有编译等核心功能,所有对于 ECMA 规范的预设就交给了 @babel/preset-env 专门处理。

@babel/preset-env 与 core-js 和 browserslist 搭配,共同完成 ECMA 语法规范的预设。

接下来到我们的实践环节。

在开始我们的实践之前,先声明在 babel7.4.0 的版本中已经支持 core-js@3 的版本。core-js@3 的出现,也迫使我们放弃 @babel/polyfill (只支持 core-js2)。

polyfill 的第一种方式

先准备环境,安装 babel 核心库和 @babel/preset-env

yarn add @babel/core @babel/cli @babel/preset-env --save-dev

由于我们废弃了 @babel/polyfill,需安装 core-js@3 和 regenerator-runtime

yarn add core-js@3 regenerator-runtime

添加 babel.config.json 配置文件:

{
  "presets": [
    [
      "@babel/reset-env",
      {
        "useBuiltIns": "entry",
        "corejs": 3
      }
    ]
  ]
}

package.json 中添加 browserslist 配置:

"browserslist": [
    "> 0.02%",
    "not dead"
  ]

添加我们的测试代码:

// a.js
export function flatFn(array) {
    return array.flat()
}

export function flatMap(array) {
    return array.flatMap(x => [x * 2])
}

// b.js
function wait(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

export default wait

// index.js
import 'core-js/stable'
import 'regenerator-runtime/runtime'
import {flatFn, flatMap} from './a'
import wait from './b'

const array = [1, 2, 3, 4, 5]

flatFn(array)
flatMap(array)
wait().then(r => r)

这里需要注意我们在入口 index.js 中,引入了 core-js/stableregenerator-runtime/runtime,因为我们废弃了 @babel/polyfill

然后运行脚本:

yarn build

编译之后它把所有的 polyfill 注入到我们的入口文件中,截取部分代码如图所示:

接下来,重点讲一下 babel 配置文件中的 useBuiltIns 这个参数:

"usage" | "entry" | false, defaults to false.
// app.js
import 'core-js/stable';
import 'regenerator-runtime/runtime'

// babel-loader options
{
  "presets": [
    [
      "@babel/env",
      {
        "useBuiltIns": "entry",
        "corejs": 3,
        "targets": {
          "browsers": [
            "last 2 versions",
            "not ie <= 10"
          ]
        }
      }
    ]
  ]
}

注意! 使用 usage 时,不需要手动导入 corejs | regenerator-runtime,否则会提示错误

修改一下我们之前的 babel 配置文件,如下:

{
  "presets": [
    [
      "@babel/env",
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ]
}

他会为我们每一个文件添加 polyfill。

修改我们的 webpack 配置:

rules: [
    {
        test: /\.js$/,
         // useBuiltIns: 'usage' 时设置
         // 这里排除了 core-js,webpack/buildin ,否则会报错,因为babel默认会将corejs自身也进行编译
         // https://github.com/zloirock/core-js/issues/743
        exclude: [
            /\bcore-js\b/,
            /\bwebpack\/buildin\b/
        ],
        use: {
            loader: 'babel-loader'
        }
    },
],

然后我们编译一下,现在按需加载,体积减少很多。截取部分代码如图所示:

修改一下我们 babel 配置文件,如下:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": false
      }
    ]
  ]
}

编译结果如下:

false 还是进行了一个全量的引入。

源码地址

polyfill 的第二种方式

引入了 corejs、regenerator-runtime 这样的 polyfill 会有一定的副作用,比如:

在应用开发中,上述行为问题不大,基本可控。但如果在库、工具的开发中引入 polyfill,则会带来潜在的问题。

举个例子,我在项目中定义了跟规范不一致的 Array.from() 函数,同时引入了一个库(注入了 polyfill),此时,这个库可能覆盖了自定义的 Array.from() 函数,导致出错。

那么接下来就是 @babel/plugin-transform-runtime 的出场机会了。它将开发者依赖的全局内置对象等,抽取成单独的模块,并通过模块导入的方式引入,避免了对全局作用域的修改(污染)。官方文档

runtime 这里简单介绍一下

babel 的 runtime,包含两个部分。 @babel/runtime@babel/plugin-transform-runtime

@babel/runtime:它其实是一个 babel 内部的库,使用在生产环境的 dependency,在 transform-runtime 作用的过程中,都会使用 @babel/runtime 内部的模块,来代替重复的 helper 函数、对全局空间有污染的 core-js 和 regenerator-runtime 相关变量。

@babel/plugin-transform-runtime:这个插件主要有两个方面的用途。

  1. babel 在转码过程中,会加入很多 babe l自己的 helper 函数,这些 helper 函数,在每个文件里可能都会重复存在,transform-runtime 插件可以把这些重复的 helper 函数,转换成公共的、单独的依赖引入,从而节省转码后的文件大小。

  2. transform-runtime 可以帮助这种项目创建一个沙盒环境,即使在代码里用到了新的 ES 特性,它能将这些特性对应的全局变量,转换为对 core-js 和 regenerator-runtime 非全局变量版本的引用。这其实也应该看作是一种给代码提供 polyfill 的方式。

接下来进入我们熟悉的实践环节。

安装开发环境:

yarn add @babel/runtime @babel/runtime-corejs3
yarn add @babel/plugin-transform-runtime --save-dev

注意 @babel/runtime 要做为项目的 dependencies

然后修改我们的 babel 配置文件:

{
  "presets": [
    [
      "@babel/preset-env"
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ]
}

编译运行:

与第一种方式的 polyfill 对比可以发现,runtime 将新的 API 进行了重命名,它是不会重写覆盖原生方法的。

需要注意 @babel/plugin-transform-runtime 的默认配置中,是不会注入对提案的 polyfill 代码。如果想要支持提案中的 API,只需要增加和 @babel/preset-env 类似的配置项。

"corejs": {
    "version": 3,
    "proposals": true
}

源码地址

七、小结

这里可能有人好奇,如果我们 @babel/plugin-transform-runtime 开启 corejs 并且 @babel/preset-env 也开启 useBuiltIns 会怎么样。
结论是:被使用到的 API polyfill 将会采用 runtime 的不污染全局方案(注意:@babel/preset-env targets 设置将会失效),而不被使用到的将会采用污染全局的。

对于 @babel/preset-env 我们可以设置 debug 参数,来查看那些 API 被 polyfill,配置如下:

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

效果如下:

对于 polyfill 的使用场景:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": 58
        },
        "useBuiltIns": "usage",
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime"
    ]
  ]
}
{
  "presets": [
    [
      "@babel/preset-env",
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ]
}

可能这里又有新的困惑:

为什么 @babel/preset-env 不能使用不污染全局的 polyfill。(不污染全局的 polyfill 必须由 @babel/plugin-transform-runtime 引入)

为什么要使用不污染全局的 polyfill 就必须要使用 @babel/plugin-transform-runtime,而与此同时我必须妥协掉 @babel/preset-env targets 带来的体积优势。

没有解决方案?

抱歉在现有的 babel 体系下还真的没有好办法来解决这个问题,当然 babel 也意识到了这个问题,于是有了 babel-polyfills(不是 @babel/polyfills)这里不在详细展开,有兴趣的可以自行查看。

对没错,我们还遗漏了 react 工程中如何 polyfill。🙂