Polyfill 方案的过去、现在和未来

任何一个小知识点,深挖下去,也是非常有意思的。

什么是补丁?

A polyfill, or polyfiller, is a piece of code (or plugin) that provides the technology that you, the developer, expect the browser to provide natively. Flattening the API landscape if you will.

我们希望浏览器提供一些特性,但是没有,然后我们自己写一段代码来实现他,那这段代码就是补丁。

比如 IE11 不支持 Promise,而我们又需要在项目里用到,写了这样的代码:

<script>
  Promise.resolve('bar')
    .then(function(foo) {
      document.write(foo);
    });
</script>

这时在 IE 下运行就会报错了,

然后在此之前加上补丁,

<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script>
  Promise.resolve('bar')
    .then(function(foo) {
      document.write(foo);
    });
</script>

刷新浏览器,就可以正常运行了,

过去

shim + sham

如果你是一个 3 年陈 + 的前端,应该会有听说过 shim、sham、es5-shimes6-shim 等等现在看起来很古老的补丁方式。

那么,shim 和 sham 是啥?又有什么区别?

  • shim 是能用的补丁
  • sham 顾名思义,是假的意思,所以 sham 是一些假的方法,只能使用保证不出错,但不能用。至于为啥会有 sham,因为有些方法的低端浏览器里根本实现不了

babel-polyfill.js

在 shim 和 sham 之后,还有一种补丁方式是引入包含所有语言层补丁的 babel-polyfill.js。比如:

<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.js"></script>

然后就 es6、es7 特性随便写了。

但缺点是,babel-polyfill 包含所有补丁,不管浏览器是否支持,也不管你的项目是否有用到,都全量引了,所以如果你的用户全都不差流量和带宽(比如内部应用),尽可以用这种方式。

现在

现在还没有银弹,各种方案百花齐放。

@babel/preset-env + useBuiltins: entry + targets

babel-polyfill 包含所有补丁,那我只需要支持某些浏览器的某些版本,是否有办法只包含这些浏览器的补丁?这就是 @babel/preset-env + useBuiltins: entry + targets 配置的方案。

我们先在入口文件里引入 @babel/polyfill

import '@babel/polyfill';

然后配置 .babelrc,添加 preset @babel/preset-env,并设置 useBuiltInstargets

{
  "presets": [
    ["@babel/env", {
      useBuiltIns: 'entry',
      targets: { chrome: 62 }
    }]
  ]
}

useBuiltIns: entry 的含义是找到入口文件里引入的 @babel/polyfill,并替换为 targets 浏览器/环境需要的补丁列表。

替换后的内容,比如:

import "core-js/modules/es7.string.pad-start";
import "core-js/modules/es7.string.pad-end";
...

这样就只会引入 chrome@62 及以上所需要的补丁,什么 Promise 之类的都不会再打包引入。

是不是很好用?

😄

有什么问题?

🤔

细细想想,其实还有不少问题,

  1. 特性列表是按浏览器整理的,那怎么知道哪些特性我用了,哪些没有用到,没有用到的部分也引入了是不是也是冗余?@babel/preset-env 有提供 exclude 的配置,如果我配置了 exclude,后面是否得小心翼翼地确保不要用到 exclude 掉的特性
  2. 补丁是打包到静态文件的,如果我配置 targets 为 chrome: 62, ie: 9,那意味着 chrome 62 也得载入 ie 9 相关的补丁,这也是一份冗余
  3. 我们是基于 core-js 打的补丁,所以只会包含 ecmascript 规范里的内容,其他比如说 dom 里的补丁,就不在此列,应该如何处理?

手动引入

传统的手动打补丁方案虽然低效,但直观有用。有些非常在乎性能的场景,比如我们公司的部分无线 H5 业务,他们宁可牺牲效率也要追求性能。所以他们的补丁方案是手动引入 core-js/modules 下的文件,缺啥加啥就好。

注意:

  1. core-js 目前用的是 v2 版本,不是 v3-beta
  2. 补丁用的是 core-js/modules,而不是 core-js/library。为啥?二者又有啥区别呢?

在线补丁,比如:polyfill.io

前面的手动引入解决的是特性列表的问题,有了特性列表,要做到按需下载,就需要用到在线的补丁服务了。目前最流行的应该就是 polyfill.io,提供的是 cdn 服务,有些站点在用,例如 https://spectrum.chat/。另外,polyfill.io 还开源了 polyfill-service 供我们自己搭建使用。

使用上,比如:

<script src="https://polyfill.io/v3/polyfill.min.js?features=default%2CPromise"></script>

然后在 Chrome@71 下的输出是:

/* Disable minification (remove `.min` from URL path) for more info */

啥都没有,因为 Promsie 特性 Chrome@71 已经支持了。

未来

关于补丁方案的未来,我觉得按需特性探测 + 在线补丁才是终极方案。

按需特性探测保证特性的最小集;在线补丁做按需下载。

按需特性探测可以用 @babel/preset-env 配上 targets 以及试验阶段的 useBuiltIns: usage,保障特性集的最小化。之所以说是未来,因为 JavaScript 的动态性,语法探测不太可能探测出所有特性,但上了 TypeScript 之后可能会好一些。另外,要注意一个前提是 node_modules 也需要走 babel 编译,不然 node_modules 下用到的特性会探测不出来。

在线补丁可以用类似前面介绍的 https://polyfill.io/ 提供的方案,让浏览器只下载必要的补丁,通常大公司用的话会部署一份到自己的 cdn 上。(阿里好像有团队部署了,但一时间想不起地址了。)

FAQ

组件应该包含补丁吗?比如 dva 里用了 Promise,是否应该把 Promise 打在 dva 的产出里?

不应该。 比如项目了依赖了 a 和 b,a 和 b 都包含 Promise 的补丁,就会有冗余。所以组件不应该包含补丁,补丁应该由项目决定。

组件不包含补丁?那需要处理啥?

通常不需要做特殊处理,但是有些语言特性的实现会需要引入额外的 helper 方法。

比如:

console.log({ ...a });

编译后是:

function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; }

function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

console.log(_objectSpread({}, a));

然后我们会有很多文件,每个文件都引入一遍 helper 方法,会有很多冗余。所以我们通常会使用 @babel/plugin-transform-runtime 来复用这些 helper 方法。

.babelrc 里配置:

{
  "plugins": [
    "@babel/transform-runtime"
  ]
}

编译后是:

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _objectSpread2 = _interopRequireDefault(require("@babel/runtime/helpers/objectSpread"));

console.log((0, _objectSpread2.default)({}, a));

所以,组件编译只要确保没有冗余的 helper 方法就好了。

core-js/library or core-js/modules?

core-js 提供了两种补丁方式。

  1. core-js/library,通过 helper 方法的方式提供
  2. core-js/module,通过覆盖全局变量的方式提供

举个例子,

import '@babel/polyfill';
Promise.resolve('foo');

.babelrc 配:

{
  "presets": [
    ["@babel/env", {
    	"useBuiltIns": "entry",
      "targets": {
        "ie": 9
      }
    }]
  ]
}

编译结果是:

require("core-js/modules/es6.promise");
require("core-js/modules/es7.promise.finally");
// 此处省略数十个其他补丁...

Promise.resolve('foo');

然后把文件内容换成:

// import '@babel/polyfill';
Promise.resolve('foo');

.babelrc 配:

{
  "plugins": [
    ["@babel/transform-runtime", {
      "corejs": 2
    }]
  ]
}

编译结果是:

var _interopRequireDefault = require("@babel/runtime-corejs2/helpers/interopRequireDefault");
var _promise = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/promise"));

_promise.default.resolve('foo');

然后 @babel/runtime-corejs2/core-js/promise 的内容是:

module.exports = require("core-js/library/fn/promise");

目前推荐是用 core-js/modules,因为 node_modules 不走 babel 编译,所以 core-js/library 的方式无法为依赖库提供补丁。

非 core-js 里的特性,如何打补丁?

手动引入,比如 Intl.js、URL 等。但是得小心有些规范后续加入 ecmascript 之后可能的冗余,比如 URL

参考