跳到主要内容 语法降级与 Polyfill:解决低版本浏览器兼容问题 | 极客日志
JavaScript 大前端
语法降级与 Polyfill:解决低版本浏览器兼容问题 低版本浏览器常因不支持新语法或缺失 API 导致白屏。解决方案涉及语法降级与 Polyfill 注入。核心工具包括 Babel 编译链(@babel/preset-env)及运行时库(core-js)。useBuiltIns 策略可手动或按需注入 Polyfill,transform-runtime 方案能避免全局污染并优化体积。Vite 官方提供@vitejs/plugin-legacy 插件,通过双产物模式(Modern/Legacy)配合 SystemJS 加载器实现兼容旧版浏览器的开箱即用方案。掌握底层原理有助于深入理解插件机制。
提到前端编译工具链方面,可能大家最新想到的是诸如@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 中。
而运行时基础库是根据 ECMAScript 官方语言规范提供各种 Polyfill 实现代码,主要包括 core-js 和 regenerator-runtime 两个基础库,不过在 babel 中也会有一些上层的封装,包括:
@babel/polyfill
@babel/runtime
@babel/runtime-corejs2
@babel/runtime-corejs3
2.2 基本使用
了解了基本概念后,接下来我们来通过代码实操的方式来学习这些工具。可以通过以下命令初始化项目:
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 )
}
. (). ();
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Promise
resolve
finally
可以看到,示例代码中既包含了高级语法也包含现代浏览器的 API,正好可以针对语法降级和 Polyfill 注入两个功能进行测试。接下来,新建.babelrc.json 即 babel 的配置文件,内容如下:
{
"presets" : [
[
"@babel/preset-env" ,
{
"targets" : {
"ie" : "11"
} ,
"corejs" : 3 ,
"useBuiltIns" : "usage" ,
"modules" : false
}
]
]
}
其中,有两个比较关键的配置:targets 和 usage。我们可以通过 targets 参数指定要兼容的浏览器版本,你既可以填如上配置所示的一个对象。
{
"targets" : {
"ie" : "11"
}
}
{
"targets" : "ie >= 11, > 0.5%, not dead"
}
Browserslist 是一个帮助我们设置目标浏览器的工具,不光是 Babel 用到,其他的编译工具如 postcss-preset-env、autoprefix 中都有所应用。对于 Browserslist 的配置内容,你既可以放到 Babel 这种特定工具当中,也可以在 package.json 中通过 browserslist 声明:
{
"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 配置规定你必须在入口文件手动添加一行这样的代码:
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" : [
[
"@babel/plugin-transform-runtime" ,
{
"corejs" : 3
}
]
] ,
"presets" : [
[
"@babel/preset-env" ,
{
"targets" : {
"ie" : "11"
} ,
"corejs" : 3 ,
"useBuiltIns" : false ,
"modules" : false
}
]
]
}
npx babel src --out-dir dist
我们可以对比一下 @babel/preset-env 下的产物结果。
经过对比我们不难发现,transform-runtime 一方面能够让我们在代码中使用非全局版本的 Polyfill,这样就避免全局空间的污染,这也得益于 core-js 的 pure 版本产物特性;另一方面对于 asyncToGenerator 这类的工具函数,它也将其转换成了一段引入语句,不再将完整的实现放到文件中,节省了编译后文件的体积。
另外,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 配置文件中添加配置,如下:
import legacy from '@vitejs/plugin-legacy' ;
import { defineConfig } from 'vite'
export default defineConfig ({
plugins : [
legacy ({
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 >
<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 >
<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,
format : 'system' ,
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 ) {
}
}
由于场景是应用打包,这里直接使用 @babel/preset-env 的 useBuiltIns: 'usage'来进行全局 Polyfill 的收集是比较标准的做法。
回到 Vite 构建的主流程中,接下来会进入 generateChunk 钩子阶段,现在 Vite 会对之前收集到的 Polyfill 进行统一的打包,实现也比较精妙,核心代码主要逻辑集中在 buildPolyfillChunk 函数中。
async function buildPolyfillChunk (
name,
imports,
bundle,
facadeToChunkMap,
buildOptions,
externalSystemJS
) {
let { minify, assetsDir } = buildOptions
minify = minify ? 'terser' : false
const res = await build ({
root : __dirname,
write : false ,
plugins : [polyfillsPlugin (imports, externalSystemJS)],
build : {
rollupOptions : {
input : {
[name]: polyfillId
},
}
}
});
const _polyfillChunk = Array .isArray (res) ? res[0 ] : res
if (!('output' in _polyfillChunk)) return
const polyfillChunk = _polyfillChunk.output [0 ]
}
因此,你可以理解为这个函数的作用即通过 vite build 对 renderChunk 中收集到 polyfill 代码进行打包,生成一个单独的 chunk。
需要注意的是,polyfill chunk 中除了包含一些 core-js 和 regenerator-runtime 的相关代码,也包含了 SystemJS 的实现代码,你可以将其理解为 ESM 的加载器,实现了在旧版浏览器下的模块加载能力。
现在我们已经能够拿到 Legacy 模式的产物文件名及 Polyfill Chunk 的文件名,那么就可以通过 transformIndexHtml 钩子来将这些产物插入到 HTML 的结构中。
{
transformIndexHtml (html ) {
}
}
好了,Vite 官方的 legacy 插件的主要原理就介绍到这里,为便于理解,讲解的过程中忽略了一些与主流程关联不大的细节。
当插件参数中开启了 modernPolyfills 选项时,Vite 也会自动对 Modern 模式的产物进行 Polyfill 收集,并单独打包成 polyfills-modern.js 的 chunk,原理和 Legacy 模式下处理 Polyfill 一样。
Safari 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 来完成兼容方案的。