都 2020 年了,按理说我们不仅应该杜绝 IE 兼容这种尾大不掉的问题,更应该推动用户去做浏览器 升级/迁移,从而享受更好的网上冲浪的体验。但总有些你拒绝不掉也绕不过去的需求,比如你要做对公支付,用户必须使用网银转账完成交易,这时候几乎所有的网银插件都要求你必须有一个 IE 浏览器了。


已经 2020 了,如何更优雅地做 IE 兼容呢?


熟悉 babel 的同学应该都能马上想到  @babel/preset-env


@babel/preset-env 的作用这里就不再赘述了,各位翻下官方文档就能很快的了解一二。


我们使用 @babel/preset-env 时如果单单是配置一下 targets 往往是不够的,targets 只会确保我们的语法会被 transform 成兼容性的版本,但别忘了还有 polyfill。


@babel/preset-env 提供了useBuiltIns 配置来控制插件如何处理 polyfill。


useBuiltIns: 'entry'


useBuiltIns: 'entry' 的作用是将你在应用里 import 的 core-jsregenerator-runtime 等 polyfill 按照你指定的浏览器版本替换成其所需的补丁集,比如你在 entry js 中写了这样的代码:


import 'core-js/stable';


假设你配置的环境需要的补丁只有 String.padStartString.padEnd,那么这段代码将会被替换为:


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


这个打补丁的方式简单易理解,但是这个方式有几个问题:


  1. 并不是按需的。也就是说即使你的 code base 里压根就没用到某些需要被 polyfill 的特性,打出来的 bundle 里还是会包含这部分的垫片代码。
  2. 冗余。并不是所有的目标环境都需要全量的 polyfill,因为这种配置方式会以最低需要兼容的环境为准。比如你配置了 targets: { ie: 8, chrome: 70},那么即使在 chrome 70 以上的环境下,依旧会引入一堆 ie8 才需要的补丁。
  3. 这种方式的 polyfill 会污染全局变量。因为原理是直接复写了全局变量,从而支持一些可能某些环境下不存在的特性。通常直觉会告诉我们污染全局变量总是不好的,虽不致命但是会产生心理毛刺。


useBuiltIns: 'usage'


useBuiltIns: 'usage' 的作用是会在编译时根据 targets 配置及代码中的依赖来打出最小的特性集。也就是说只有你使用了某一个需要被 polyfill 的特性,相应的补丁才会打到 bundle 里,这个机制解了 entry 方式不是按需的问题,配合 @babel/plugin-transform-runtime 也能解决全局污染的问题(冗余的问题两个方式都解决不了)。看上去已经比 useBuiltIns: 'entry' 往前更进一步了,但它有些更棘手的问题:


  1. 需要将 babel-loader 设置成 node_modules include 的模式。因为你的依赖里也可能会使用一些需要打补丁的特性,这会大大增加应用的编译耗时。
  2. externals 方案在这个场景下可能不得不关掉。因为 externals 的包不会被 babel-loader 处理,这就意味着 externals 包里的一些需要兼容的语法会成为漏网之鱼。同样的还有一些不通过 import 引入的一些三方包(比如直接通过 script 标签引入的)。
  3. 各种奇怪的问题。比如因为问题 1 的原因开启了 babel-loader 针对 node_modules 的处理,导致各种 loader 或 webpack plugin 的代码也会被 transform 一遍,从而出现各种奇怪的不知所云的报错或异常。


useBuiltIns: 'usage' 看上去很美,但是实际操作起来会发现比 useBuiltIns: 'entry' 的问题还要多。而且如果你「有幸」基于 usage 配置成功打出一个 bundle,去跟 entry 方式的 bundle 对比一下发现,可能并不会省多少体积(很多 polyfill 都是存在间接依赖的)。


方案选择


useBuiltIns: 'usage' 虽然理论上方案完美,但实际使用中并不如 useBuiltIns: 'entry' 简单粗暴,两害相权取其轻,生产场景中,useBuiltIns: 'entry' 会是工程上更好的选择。


既然决定了基于 useBuiltIns: 'entry' 方式打补丁,那么我们是否可以尝试解决一些它的一些问题?


比如冗余的问题:通常我们只需要在 IE 环境下加载这些 polyfill,高版本 chrome 环境则不需要。但是我们知道 polyfill 是跟其他代码打到一起的,如何把 polyfill 从 bundle 中拆出来呢?


记住打补丁通常有一个基本的原则,即 code base 本身是不应该关注 polyfill 的,polyfill 应该是在需要的时候通过外部来引入。这个原则不仅适用于一些 library 类型的库,同样适用于业务代码库。


首先我们不应该在业务代码中手动 import 'core-js/stable',这个应该是由 webpack 来帮你注入的。


我们可以通过在 webpack entry 里新加入 core-js/stable 这个包来实现改造:


// src/index.js
- `import 'core-js/stable'

// webpack.config.js
module.exports = {
-  entry: ['src/index.js'],
+  entry: ['core-js/stable', 'src/index.js'],
}


但是这种方式还是会把 polyfill 打到业务代码的 bundle 里,我们需要把 polyfill 单独拆开:


// webpack.config.js
module.exports = {
-  entry: ['core-js/stable', 'src/index.js'],
+  entry: {
+    polyfill: ['core-js/stable', 'whatwg-fetch'],
+    main: 'src/index.js',
+  }
}


只是把 polyfill 单独变成一个 entry 还不够,因为这样 webpack 在打 common chunk 时把两个 entry 做合并分析,从而把一些共用的包打到 polyfill.bundle.js 或 main.bundle.js 里。我们需要在打 chunk 时忽略 polyfill entry:


// webpack.config.js
module.exports = {
+  optimization: {
+    splitChunks: {
+      chunks(chunk) {
+        return chunk.name !== 'polyfill';
+      },
+    }
+  }
}


这样我们就能打出一个 polyfill 跟业务代码完全分离的两个 bundle 了。


如何按需的使用这个打出来的 polyfill 呢,比如只有在 IE 环境下才需要加载这个 polyfill。


可以直接基于 html-webpack-plugin 的自定义模板来实现这样一个能力:


// webpack.config.js
module.exports = {
  plugins: [
+    new HtmlWebpackPlugin({
+      filename: 'index.html',
+      template: './src/index.ejs',
+      minify: {
+        removeComments: true,
+        collapseWhitespace: true,
+      },
+      inject: false,
+    }),
  ],
}


src/index.ejs 里这样写:


<html>
  <head>
+    <!-- IE 场景下引入 polyfills,检查方法参考 https://github.com/angular/angular.js/blob/master/src/Angular.js#L165-L170 -->
+    <script>
+      if (window.document.documentMode) {
+        document.writeln('<script src="<%= htmlWebpackPlugin.files.chunks['polyfill'].entry %>" crossorigin="anonymous"><\/script>');
+      }
+    </script>
+    <% } %>
  </head>
  <body>
+    <!-- 引入非 polyfill chunks -->
+    <% for (var chunk in htmlWebpackPlugin.files.chunks) { if (chunk !== 'polyfill') { %>
+    <script src="<%= htmlWebpackPlugin.files.chunks[chunk].entry %>" crossorigin="anonymous"></script>
+    <% }} %>
  </body>
</html>


通过环境检测及 document.writeln 这种古老的技艺,就能实现按需的载入 polyfill 的能力了。


拓展一下


我们知道,理论上最为完美的补丁方案应该是 useBuiltIns: 'usage' +  polyfill.io 按需特性收集+在线补丁 这种大杀器。但很明显这种大杀器并不是每个人都能用的上(更准确说这种方式目前还不够简单可靠),那么在这之前,我们是否能寻求一种工程上足够可靠、使用上足够简单,但可见的容许一些 体积、性能 上有瑕疵的方案呢。


我的想法是,或许我们可以在一定范围(场景)下收集一些较为常见的兼容诉求,统计出几个典型的浏览器断点,构建出一个「响应式」的 polyfill 集,然后在 bigfish 这类框架打包时直接生成相应的 「响应式」代码。比如这样:


// 伪代码
switch (browserVersionRange) {
  case 'version <= IE8':
    load('//alipaycdn.js/ie8.polyfill.js');
    break;
  
  case 'IE11 > version > IE8':
    load('//alipaycdn.js/ie8~ie11.polyfill.js');
    break;
    
  case 'version < chrome50':
    load('//alipaycdn.js/chrome50-polyfill.js');
    break;
}


这个方案在「理论完美」程度上自然比不上上面提到的大杀器方案,但比简单的 useBuiltIns: 'entry' 方案会更「精致」、「先进」,作为大杀器方案的备胎,或许可以尝试一下?


关联阅读: