我的补丁我说了算(Polyfill方案)

784 阅读10分钟

背景

          Polyfill你可以理解为“腻子”,就是装修的时候,可以把缺损的地方填充抹平。

举个例子,html5的storage(session,local), 不同浏览器,不同版本,有些支持,有些不支持。

polyfill是一个很重要的概念,它让你可以无顾虑地使用更新的JavaScript特性,而不需要关注浏览器兼容性。

一般来说,我们会用core-js来做polyfill,常见的玩法有:

  1. 整个引入polyfill,这会包含一个非常大的文件,比如最新的3.7.0版本一共158KB,在gzip后45.9KB。
  2. 让babel去处理,使用@babel/preset-env加上useBuiltins配置来裁剪polyfill。

一般来说,对于一个独立的产品,使用babel处理后,将core-js按需拿出来打包到最终的产出里就能解决大部分问题。但如果在特殊场合下,这种方式也会有一些问题:

  1. 有多个浏览器兼容性一致的项目,它们可以共享一个polyfill,但因为被打包到了最终产物中,这个共享并不容易实现。
  2. 当在代码中用到不同特性的时候,babel会导入不同的polyfill内容,这导致某些希望稳定不变的产物(如vendors)的hash变化,缓存失效。

core-js 与垫片

       core-js 是一个 JavaScript 标准库,它包含了 ECMAScript 2020 在内的多项特性的 polyfills,以及 ECMAScript 在 proposals 阶段的特性、WHATWG/W3C 新特性等。因此它是一个现代化前端项目的“标准套件”。

除了 core-js 本身的重要性,它的实现理念、设计方式都值得我们学习。事实上,core-js 是一扇大门:

  • 通过 core-js,我们可以窥见前端工程化的方方面面;

  • core-js 又和 Babel 深度绑定,因此学习 core-js,也能帮助开发者更好地理解 babel 生态,进而加深对前端生态的理解; 

  • 通过对 core-js 的解析,我们正好可以梳理前端一个极具特色的概念——polyfill(垫片/补丁)。 

core-js 

core-js 是一个由 Lerna 搭建的 Monorepo 风格的项目,在它的 packages 中,我们能看到五个相关包:

core-js 实现的基础垫片能力,是整个 core-js 的逻辑核心。 比如我们可以按照如下代码引入全局 polyfills:

import 'core-js';

或者按照: 

import 'core-js/features/array/from';

 的方式,按需在业务项目的入口引入某些 polyfills。 core-js 为什么有这么多的 packages 呢?实际上,它们各司其职,又紧密配合,接下来我们来具体分析。

core-js-pure 提供了不污染全局变量的垫片能力,比如我们可以按照:  

import _from from 'core-js-pure/features/array/from';

import _flat from 'core-js-pure/features/array/flat';

的方式,来实现独立的导出命名空间,进而避免全局变量的污染。

core-js-compact 维护了按照browserslist规范的垫片需求数据,来帮助我们找到“符合目标环境”的 polyfills 需求集合,比如以下代码:

const {

  list, // array of required modules

  targets, // object with targets for each module

} = require('core-js-compat')({

  targets: '> 2.5%'

});

就可以筛选出全球使用份额大于 2.5% 的浏览器范围,并提供在这个范围下需要支持的垫片能力。

core-js-builder 可以结合 core-js-compact 以及 core-js,并利用 webpack 能力,根据需求打包出 core-js 代码。比如:

require('core-js-builder')({

  targets: '> 0.5%',

  filename: './my-core-js-bundle.js',

}).then(code => {}).catch(error => {});

将会把符合需求的 core-js 垫片打包到my-core-js-bundle.js文件当中。整个流程可以用代码演示为:

require('./packages/core-js-builder')({ filename: './packages/core-js-bundle/index.js' }).then(done).catch(error => {

  // eslint-disable-next-line no-console

  console.error(error);

  process.exit(1);

});

总之,根据分包的设计,我们能发现,core-js 将自身能力充分解耦,提供出的多个包都可以被其他项目所依赖。比如:

  • core-js-compact 可以被 Babel 生态使用,由 Babel 分析出根据环境需要按需加载的垫片;

  • core-js-builder 可以被 Node.js 服务使用,构建出不同场景的垫片包。

宏观上的设计,体现了工程复用能力。下面我们通过一个微观 polyfill 案例,从一个具体的垫片实现,进一步加深理解。

shim和polyfill有什么区别

         在JavaScript的世界里,有两个词经常被提到,shim和polyfill.它们指的都是什么,又有什么区别?

         一个shim是一个库,它将一个新的API引入到一个旧的环境中,而且仅靠旧环境中已有的手段实现。

         一个polyfill就是一个用在浏览器API上的shim.我们通常的做法是先检查当前浏览器是否支持某个API,如果不支持的话就加载对应的polyfill.然后新旧浏览器就都可以使用这个API了.术语polyfill来自于一个家装产品Polyfilla:

          Polyfilla是一个英国产品,在美国称之为Spackling Paste(译者注:刮墙的,在中国称为腻子).记住这一点就行:把旧的浏览器想象成为一面有了裂缝的墙.这些[polyfills]会帮助我们把这面墙的裂缝抹平,还我们一个更好的光滑的墙壁(浏览器)

         Paul Irish发布过一个Polyfills的总结页面“HTML5 Cross Browser Polyfills”.es5-shim是一个shim(而不是polyfill)的例子,它在ECMAScript 3的引擎上实现了ECMAScript 5的新特性,而且在Node.js上和在浏览器上有完全相同的表现(译者注:因为它能在Node.js上使用,不光浏览器上,所以它不是polyfill).

polyfill方案

     html5各个特性支持的Polyfill,你需要哪个,就引入哪个。当然,你也可以自己写 :参考
github.com/Modernizr/M…

手动:不推荐

缺点:

非官方实现,很容易出现于规范不一致导致的问题,一些polyfill实现起来也很麻烦,仅用于临时一些修复。不是一种工程化的解决方式,方案原始而难以维护,同时对于 polyfill 的实现要求较高。

举例:以 ES6 的 object#assign 为例 ,即使在 IE 11 上,仍会报错;我们需要打上相应的补丁。可以用第三方成熟的 package ,也可以使用 MDN 提供的模板进行打补丁

Object.assign = require('object-assign')
// or

// Refer: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
if (typeof Object.assign != 'function') {
    // Must be writable: true, enumerable: false, configurable: true
    Object.defineProperty(Object, 'assign', {
        value: function assign(target, varArgs) {
            // .length of function is 2
            'use strict'
            if (target == null) {
                // TypeError if undefined or null
                throw new TypeError('Cannot convert undefined or null to object')
            }

            var to = Object(target)

            for (var index = 1; index < arguments.length; index++) {
                var nextSource = arguments[index]

                if (nextSource != null) {
                    // Skip over if undefined or null
                    for (var nextKey in nextSource) {
                        // Avoid bugs when hasOwnProperty is shadowed
                        if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
                            to[nextKey] = nextSource[nextKey]
                        }
                    }
                }
            }
            return to
        },
        writable: true,
        configurable: true,
    })
}

问题是解决了,但优势和劣势也相当明显:优势是保持最小化引入,不会有额外的冗余代码开销,保证了应用的性能。劣势是手动导入不易管理和维护,对于多样化的 polyfill 和变化多端的 Web 应用维护成本比较大

根据覆盖率自动打补丁

    在黑魔法 Webpack 的加持下,我们可以更现代化的方式打补丁。这其中相关的依赖有: @babel/preset-env@babel/plugin-transform-runtimecore-js@babel/polyfill 。先逐一介绍它们:

  1. @babel/preset-env - 按需编译和按需打补丁

  2. core-js JavaScript 标准库

    core-js 是实现 JavaScript 标准运行库之一,它提供了从 ES3 ~ ES7+ 以及还处在提案阶段的 JavaScript 的实现。

  3. @babel/plugin-transform-runtime - 重利用 Babel helper 方法的 babel 插件@babel/plugin-transform-runtime 是对 Babel 编译过程中产生的 helper 方法进行重新利用(聚合),以达到减少打包体积的目的。此外还有个作用是为了避免全局补丁污染,对打包过的 bunler 提供"沙箱"式的补丁。

  4. @babel/polyfill - core-js 和 regenerator-runtime 补丁的实现库。

@babel/polyfill 通过定制 polyfillregenerator,提供了一个 ES2015+ 环境 polyfill的库。因为它是由其他两个库实现的,直接引入其他两个库即可,所以已被**废弃**。

babel-polyfill 结合 @babel/preset-env + useBuiltins(entry) + preset-env targets 的方案如今更为流行,@babel/preset-env 定义了 Babel 所需插件预设,同时由 Babel 根据 preset-env targets 配置的支持环境,自动按需加载 polyfills,使用方式如下:

@babel/preset-env 会根据目标环境来进行编译和打补丁。具体来讲,是根据参数 targets 来确定目标环境,默认情况下它编译为 ES2015,可以根据项目需求进行配置:

 {

    "presets": [
    ["@babel/env", {

            useBuiltIns: 'entry',

            targets: {
                chrome: 44
            }
    }]

  ]
}

这样我们在工程代码入口处的:

import '@babel/polyfill';

会被编译为:

import "core-js/XXXX/XXXX";
import "core-js/XXXX/XXXXX";

这样的方式省力省心。也是 core-js 和 Babel 深度绑定并结合的典型案例。

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

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

根据浏览器特性,动态打补丁

UA检测 polyfill

我们可以根据我们使用的新特性传输给服务器,然后服务器检测浏览器的UA,就可以决定真正返回的polyfill了,这个思路实现了真正的环境按需polyfill,不像browserslist一样会取所有兼容环境的并集。

优点:

  • 能够真正按照运行环境进行polyfill

缺点:

  • 多一次http请求

  • UA标志混乱,很可能导致Polyfill缺失

  • 增加服务区成本

Polyfill.io 就是实现这个方案的服务,它会根据浏览器的 UA 不同,返回不一样的补丁。如想要 Promise 补丁,在页面引入:

<script src="https://polyfill.io/v3/polyfill.js?features=Promise"></script>

这种方案以 Polyfill.io 为代表,它提供了 CDN 服务,使用者可以按照所需环境,生成打包链接

在高版本浏览器上,可能会返回空内容,因为该浏览器已经支持了 ES2015 特性。如果在低版本浏览器上,将会得到真实的 polyfills bundle

从工程化的角度来说,一个趋于完美的 polyfill 设计应该满足的核心原则是按需加载补丁,这个按需加载主要包括两方面:

  • 按照用户终端环境

  • 按照业务代码使用情况

因为按需加载补丁,意味着更小的 bundle size,直接决定了应用的性能。

阿里fork了它的开源服务,做了如下定制

将polyfill包进行拆分,加入了polyfill.io不认可但是工程需要的polyfill比如说RegeneratorRuntime

编写了标准的UA识别逻辑,大幅大幅降低了响应时间 通常响应时间在 30ms 以内

利用CDN提提供了高可用性的服务

**阿里polyfill:**polyfill.alicdn.com/polyfill.mi…

总结:

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

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

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

在线补丁可以用类似前面介绍的 polyfill.io/ 提供的方案,让浏览器只下载必要的补丁,通常大公司用的话会部署一份到自己的 cdn 上。