乐趣区

关于前端:语法降级与Polyfill消灭低版本浏览器兼容问题

提到前端编译工具链方面,可能大家最新想到的是诸如 @babel/preset-env、core-js、regenerator-runtime 等工具。不过,咱们明天要讲的是官网的 Vite 插件 @vitejs/plugin-legacy,以及如何将这些底层的工具链接入到 Vite 中,并实现开箱即用的解决方案。

一、浏览器兼容问题

首先咱们来复现一下问题场景,上面两张图代表了之前我在线上环境实在遇到的报错案例。

某些低版本浏览器并没有提供 Promise 语法环境以及对象和数组的各种 API,甚至不反对箭头函数语法,代码间接报错,从而导致线上白屏事变的产生,尤其是在 IE 11、iOS 9 以及 Android 4.4 等场景中很容易遇到。

旧版浏览器的语法兼容问题次要分两类: 语法降级问题和 Polyfill 缺失问题。前者比拟好了解,比方某些浏览器不反对箭头函数,咱们就须要将其转换为 function(){}语法;而对后者来说,Polyfill 自身能够翻译为垫片,也就是为浏览器提前注入一些 API 的实现代码,如 Object.entries 办法的实现,这样能够保障产物能够失常应用这些 API,避免报错。

这两类问题实质上是通过前端的编译工具链 (如 Babel) 及 JS 的根底 Polyfill 库 (如 corejs) 来解决的,不会跟具体的构建工具所绑定。也就是说,对于这些实质的解决方案,在其它的构建工具 (如 Webpack) 能应用,在 Vite 当中也齐全能够应用。

构建工具思考的仅仅是如何将这些底层基础设施接入到构建过程的问题,本人并不需要提供底层的解决方案,正所谓术业有专攻,把业余的事件交给业余的工具去做。接下来,咱们就一起相熟这些所谓业余的工具,以及如何应用它们。

二、底层工具链

2.1 工具概览

解决上述提到的两类语法兼容问题,次要须要用到两方面的工具,别离包含:

  • 编译时工具:代表工具有 @babel/preset-env 和 @babel/plugin-transform-runtime。
  • 运行时根底库:代表库包含 core-js 和 regenerator-runtime。

编译时工具的作用是在代码编译阶段进行语法降级及增加 polyfill 代码的援用语句,如下。

import "core-js/modules/es6.set.js"

因为这些工具只是编译阶段用到,运行时并不需要,咱们须要将其放入 package.json 中的 devDependencies 中。

而运行时根底库是依据 ESMAScript 官方语言标准提供各种 Polyfill 实现代码,次要包含 core-js 和 regenerator-runtime 两个根底库,不过在 babel 中也会有一些下层的封装,包含:

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

2.2 根本应用

理解了基本概念后,接下来咱们来通过代码实操的形式来学习这些工具,代码我也曾经放到了 github 仓库中,能够对照学习。如果你没拉取仓库的代码,也能够先依照如下的命令初始化我的项目:

mkdir babel-test
npm init -y

而后装置一些必要的依赖:

pnpm i @babel/cli @babel/core @babel/preset-env

上面是各个依赖的作用:

  • @babel/cli: 为 babel 官网的脚手架工具,很适宜咱们练习用。
  • @babel/core: babel 外围编译库。
  • @babel/preset-env: babel 的预设工具集,根本为 babel 必装的库。

接着,新建 src 目录,在目录下减少 index.js 文件。

const func = async () => {console.log(12123)
}


Promise.resolve().finally();

能够看到,示例代码中既蕴含了高级语法也蕴含古代浏览器的 API,正好能够针对语法降级和 Polyfill 注入两个性能进行测试。接下来,新建.babelrc.json 即 babel 的配置文件,内容如下:

{
  "presets": [
    [
      "@babel/preset-env", 
      {
        // 指定兼容的浏览器版本
        "targets": {"ie": "11"},
        // 根底库 core-js 的版本,个别指定为最新的大版本
        "corejs": 3,
        // Polyfill 注入策略,后文具体介绍
        "useBuiltIns": "usage",
        // 不将 ES 模块语法转换为其余模块语法
        "modules": false
      }
    ]
  ]
}

其中,有两个比拟要害的配置: targets 和 usage。咱们能够通过 targets 参数指定要兼容的浏览器版本,你既能够填如上配置所示的一个对象。

{
  "targets": {"ie": "11"}
}

也能够用 Browserslist 配置语法:

{ 
  // ie 不低于 11 版本,寰球超过 0.5% 应用,且还在保护更新的浏览器
  "targets": "ie >= 11, > 0.5%, not dead"
}

Browserslist 是一个帮忙咱们设置指标浏览器的工具,不光是 Babel 用到,其余的编译工具如 postcss-preset-env、autoprefix 中都有所利用。对于 Browserslist 的配置内容,你既能够放到 Babel 这种特定工具当中,也能够在 package.json 中通过 browserslist 申明:

// package.json
{"browserslist": "ie >= 11"}

或者通过.browserslistrc 进行申明:

// .browserslistrc
ie >= 11

在理论的我的项目中,个别咱们能够将应用上面这些最佳实际汇合来形容不同的浏览器类型,加重配置累赘:

// 古代浏览器
last 2 versions and since 2018 and > 0.5%
// 兼容低版本 PC 浏览器
IE >= 11, > 0.5%, not dead
// 兼容低版本挪动端浏览器
iOS >= 9, Android >= 4.4, last 2 versions, > 0.2%, not dead

对于这些配置对应的具体浏览器列表,大家能够去 browserslist.dev 站点查看。

在阐明了指标浏览器的配置之后,接下来咱们来看另外一个重要的配置——useBuiltIns,它决定了增加 Polyfill 策略,默认是 false,即不增加任何的 Polyfill。你能够手动将 useBuiltIns 配置为 entry 或者 usage,接下来咱们看看这两个配置到底有什么区别。

首先,咱们能够将这个字段配置为 entry,须要留神的是,entry 配置规定你必须在入口文件手动增加一行这样的代码:

// index.js 结尾加上
import 'core-js';

接着在终端执行上面的命令进行 Babel 编译:

npx babel src --out-dir dist

产物输入在 dist 目录中,你能够去察看一下产物的代码:

Babel 曾经依据指标浏览器的配置为咱们增加了大量的 Polyfill 代码,index.js 文件简略的几行代码被编译成近 300 行。实际上,Babel 所做的事件就是将你的 import “core-js” 代码替换成了产物中的这些具体模块的导入代码。

但这个配置有一个问题,即无奈做到按需导入,下面的产物代码其实有大部分的 Polyfill 的代码咱们并没有用到。接下来咱们试试 useBuiltIns: usage 这个按需导入的配置,改变配置后执行编译命令。

npx babel src --out-dir dist

同样能够看到,产物输入在了 dist/index.js 中,内容如下所示:

能够发现 Polyfill 的代码精简了许多,真正地实现了按需 Polyfill 导入。因而,在理论的应用当中,还是举荐大家尽量应用 useBuiltIns: “usage”,进行按需的 Polyfill 注入。

 

咱们来梳理一下,下面咱们利用 @babel/preset-env 进行了指标浏览器语法的降级和 Polyfill 注入,同时用到了 core-js 和 regenerator-runtime 两个外围的运行时库。但 @babel/preset-env 的计划也存在肯定局限性:

  • 如果应用新个性,往往是通过根底库 (如 core-js) 往全局环境增加 Polyfill,如果是开发利用没有任何问题,如果是开发第三方工具库,则很可能会对全局空间造成净化。
  • 很多工具函数的实现代码(如下面示例中的_defineProperty 办法),会在许多文件中重现呈现,造成文件体积冗余。

2.3 transform-runtime

接下来,咱们要介绍的 transform-runtime 计划,就是为了解决 @babel/preset-env 的种种局限性。须要提前阐明的是,transform-runtime 计划能够作为 @babel/preset-env 中 useBuiltIns 配置的替代品,也就是说,一旦应用 transform-runtime 计划,你应该把 useBuiltIns 属性设为 false。

首先,装置一些必要的依赖:

pnpm i @babel/plugin-transform-runtime -D
pnpm i @babel/runtime-corejs3 -S

我解释一下这两个依赖的作用:前者是编译时工具,用来转换语法和增加 Polyfill,后者是运行时根底库,封装了 core-js、regenerator-runtime 和各种语法转换用到的工具函数。

事实上,core-js 有三种产物,别离是 core-js、core-js-pure 和 core-js-bundle。第一种是全局 Polyfill 的做法,@babel/preset-env 就是用的这种产物;第二种不会把 Polyfill 注入到全局环境,能够按需引入;第三种是打包好的版本,蕴含所有的 Polyfill,不太罕用。@babel/runtime-corejs3 应用的是第二种产物。

接着,咱们对.babelrc.json 作如下的配置:

{
  "plugins": [
    // 增加 transform-runtime 插件
    [
      "@babel/plugin-transform-runtime", 
      {"corejs": 3}
    ]
  ],
  "presets": [
    [
      "@babel/preset-env", 
      {
        "targets": {"ie": "11"},
        "corejs": 3,
        // 敞开 @babel/preset-env 默认的 Polyfill 注入
        "useBuiltIns": false,
        "modules": false
      }
    ]
  ]
}

而后,执行终端命令:

npx babel src --out-dir dist

咱们能够比照一下 @babel/preset-env 下的产物后果,如下图。

通过比照咱们不难发现,transform-runtime 一方面可能让咱们在代码中应用非全局版本的 Polyfill,这样就防止全局空间的净化,这也得益于 core-js 的 pure 版本产物个性;另一方面对于 asyncToGeneator 这类的工具函数,它也将其转换成了一段引入语句,不再将残缺的实现放到文件中,节俭了编译后文件的体积。

另外,transform-runtime 计划援用的根底库也产生了变动,不再是间接引入 core-js 和 regenerator-runtime,而是引入 @babel/runtime-corejs3。

三、Vite 语法降级与 Polyfill 注入

其实,Vite 官网曾经为咱们封装好了一个开箱即用的计划: @vitejs/plugin-legacy,咱们能够基于它来解决我的项目语法的浏览器兼容问题。这个插件外部同样应用 @babel/preset-env 以及 core-js 等一系列根底库来进行语法降级和 Polyfill 注入,因而我感觉对于上文所介绍的底层工具链的把握是必要的,否则无奈了解插件外部所做的事件,真正遇到问题时往往会手足无措。

3.1 插件应用

首先,在我的项目装置一下插件,命令如下:

npm i @vitejs/plugin-legacy -D

而后,在我的项目的 vite.config.ts 配置文件中增加配置,如下:

// vite.config.ts
import legacy from '@vitejs/plugin-legacy';
import {defineConfig} from 'vite'


export default defineConfig({
  plugins: [
    // 省略其它插件
    legacy({
      // 设置指标浏览器,browserslist 配置语法
      targets: ['ie >= 11'],
    })
  ]
})

咱们同样能够通过 targets 指定指标浏览器,这个参数在插件外部会透传给 @babel/preset-env。在引入插件后,咱们能够尝试执行 npm run build 对我的项目进行打包,能够看到如下的产物信息:

能够看到,打出的包多出了 index-legacy.js、vendor-legacy.js 以及 polyfills-legacy.js 三份产物文件。让咱们持续察看一下 index.html 的产物内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/assets/favicon.17e50649.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
    <!-- 1. Modern 模式产物 -->
    <script type="module" crossorigin src="/assets/index.c1383506.js"></script>
    <link rel="modulepreload" href="/assets/vendor.0f99bfcc.js">
    <link rel="stylesheet" href="/assets/index.91183920.css">
  </head>
  <body>
    <div id="root"></div>
    <!-- 2. Legacy 模式产物 -->
    <script nomodule> 兼容 iOS nomodule 个性的 polyfill,省略具体代码 </script>
    <script nomodule id="vite-legacy-polyfill" src="/assets/polyfills-legacy.36fe2f9e.js"></script>
    <script nomodule id="vite-legacy-entry" data-src="/assets/index-legacy.c3d3f501.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
  </body>
</html>

通过官网的 legacy 插件,Vite 会别离打包出 Modern 模式和 Legacy 模式的产物,而后将两种产物插入同一个 HTML 外面,Modern 产物被放到 type=”module” 的 script 标签中,而 Legacy 产物则被放到带有 nomodule 的 script 标签中。浏览器的加载策略如下图所示:

这样产物便就可能同时放到古代浏览器和不反对 type=”module” 的低版本浏览器当中执行。当然,在具体的代码语法层面,插件还须要思考语法降级和 Polyfill 按需注入的问题,接下来咱们就来剖析一下 Vite 的官网 legacy 插件是如何解决这些问题的。

3.2 插件执行原理

官网的 legacy 插件是一个绝对复杂度比拟高的插件,间接看源码可能会很难了解,这里我梳理了画了一张简化后的流程图。

能够看到,首先是在 configResolved 钩子中调整了 output 属性,这么做的目标是让 Vite 底层应用的打包引擎 Rollup 能另外打包出一份 Legacy 模式的产物,实现代码如下:

const createLegacyOutput = (options = {}) => {
  return {
    ...options,
    // system 格局产物
    format: 'system',
    // 转换成果: index.[hash].js -> index-legacy.[hash].js
    entryFileNames: getLegacyOutputFileName(options.entryFileNames),
    chunkFileNames: getLegacyOutputFileName(options.chunkFileNames)
  }
}


const {rollupOptions} = config.build
const {output} = rollupOptions
if (Array.isArray(output)) {rollupOptions.output = [...output.map(createLegacyOutput), ...output]
} else {rollupOptions.output = [createLegacyOutput(output), output || {}]
}

接着,在 renderChunk 阶段,插件会对 Legacy 模式产物进行语法转译和 Polyfill 收集。值得注意的是,这里并不会真正注入 Polyfill,而仅仅只是收集 Polyfill。

{renderChunk(raw, chunk, opts) {
    // 1. 应用 babel + @babel/preset-env 进行语法转换与 Polyfill 注入
    // 2. 因为此时曾经打包后的 Chunk 曾经生成
    //   这里须要去掉 babel 注入的 import 语句,并记录所需的 Polyfill
    // 3. 最初的 Polyfill 代码将会在 generateBundle 阶段生成
  }
}

因为场景是利用打包,这里间接应用 @babel/preset-env 的 useBuiltIns: ‘usage’ 来进行全局 Polyfill 的收集是比拟规范的做法。

回到 Vite 构建的主流程中,接下来会进入 generateChunk 钩子阶段,当初 Vite 会对之前收集到的 Polyfill 进行对立的打包,实现也比拟精妙,外围代码次要逻辑集中在 buildPolyfillChunk 函数中。

// 打包 Polyfill 代码
async function buildPolyfillChunk(
  name,
  imports
  bundle,
  facadeToChunkMap,
  buildOptions,
  externalSystemJS
) {let { minify, assetsDir} = buildOptions
  minify = minify ? 'terser' : false
  // 调用 Vite 的 build API 进行打包
  const res = await build({
    // 根门路设置为插件所在目录
    // 因为插件的依赖蕴含 `core-js`、`regenerator-runtime` 这些运行时根底库
    // 因而这里 Vite 能够失常解析到根底 Polyfill 库的门路
    root: __dirname,
    write: false,
    // 这里的插件实现了一个虚构模块
    // Vite 对于 polyfillId 会返回所有 Polyfill 的引入语句
    plugins: [polyfillsPlugin(imports, externalSystemJS)],
    build: {
      rollupOptions: {
        // 拜访 polyfillId
        input: {
          // name 暂可视作 `polyfills-legacy`
          // pofyfillId 为一个虚构模块,通过插件解决后会拿到所有 Polyfill 的引入语句
          [name]: polyfillId
        },
      }
    }
  });
  // 拿到 polyfill 产物 chunk
  const _polyfillChunk = Array.isArray(res) ? res[0] : res
  if (!('output' in _polyfillChunk)) return
  const polyfillChunk = _polyfillChunk.output[0]
  // 后续做两件事件:
  // 1. 记录 polyfill chunk 的文件名,不便后续插入到 Modern 模式产物的 HTML 中;// 2. 在 bundle 对象上手动增加 polyfill 的 chunk,保障产物写到磁盘中
}

因而,你能够了解为这个函数的作用即通过 vite build 对 renderChunk 中收集到 polyfill 代码进行打包,生成一个独自的 chunk。

须要留神的是,polyfill chunk 中除了蕴含一些 core-js 和 regenerator-runtime 的相干代码,也蕴含了 SystemJS 的实现代码,你能够将其了解为 ESM 的加载器,实现了在旧版浏览器下的模块加载能力。

当初咱们曾经可能拿到 Legacy 模式的产物文件名及 Polyfill Chunk 的文件名,那么就能够通过 transformIndexHtml 钩子来将这些产物插入到 HTML 的构造中。

{transformIndexHtml(html) {
    // 1. 插入 Polyfill chunk 对应的 <script nomodule> 标签
    // 2. 插入 Legacy 产物入口文件对应的 <script nomodule> 标签
  }
}

好了,Vite 官网的 legacy 插件的次要原理就介绍到这里,为了不便大家了解,解说的过程中疏忽了一些与主流程关联不大的细节。

  • 当插件参数中开启了 modernPolyfills 选项时,Vite 也会主动对 Modern 模式的产物进行 Polyfill 收集,并独自打包成 polyfills-modern.js 的 chunk,原理和 Legacy 模式下解决 Polyfill 一样。
  • Sarari 10.1 版本不反对 nomodule,为此须要独自引入一些补丁代码,点击查看。
  • 局部低版本 Edge 浏览器尽管反对 type=”module”,但不反对动静 import,为此也须要插入一些补丁代码,针对这种状况降落级应用 Legacy 模式的产物。

四、小结

本节次要解说了 Vite 中语法降级与 Polyfill 相干的内容,波及的概念比拟多,篇幅也比拟长,你须要重点把握以下内容:

  • @babel/preset-env 的应用。
  • useBuiltIns 与 transformRuntime 两种 Polyfill 计划的区别。
  • Vite 降级插件 @vitejs/plugin-legacy 的应用及原理。

首先,咱们复现了线上的低版本浏览器语法报错情景,次要分为 语法报错 和 Polyfill 缺失 的问题,由此引出了底层的解决方案——应用 Babel 编译工具链 和 JS 运行时根底库来实现。接着具体介绍了 @babel/preset-env 的应用,通过理论的代码案例让你体验了它的语法降级和主动 Polyfill 注入的能力,接着,我又给你介绍了一个更优的 Polyfill 计划——transform-runtime 计划,并与 @babel/preset-env 的 useBuiltIns 计划进行了比照,剖析了 transform-runtime 计划的两个优化点: 不影响全局空间和优化文件体积。

在介绍了底层的解决方案之后,咱们开始学习在 Vite 中的解决方案——@vitejs/plugin-legacy,剖析了它如何让产物可能同时兼容古代浏览器和不反对 type=”module” 的低版本浏览器,接着深刻地解说了这个插件的实现原理,你能够发现底层也是通过 @babel/preset-env 来实现兼容计划的。

退出移动版