# 兼容性问题
通过 Vite 构建我们完全可以兼容各种低版本浏览器,打包出既支持现代(Modern
)浏览器又支持旧版(Legacy
)浏览器的产物。接下来分析为什么在 Vite 中能够彻底解决低版本浏览器的兼容性问题,以及通过什么手段解决,需要借助哪些 JS 的工具和生态?
某些低版本浏览器并没有提供 Promise
语法环境以及对象和数组的各种 API,甚至不支持箭头函数语法,代码直接报错,从而导致线上白屏事故的发生,尤其是需要兼容到IE 11
、iOS 9
以及Android 4.4
的场景中很容易会遇到。
旧版浏览器的语法兼容问题主要分两类: 语法降级问题和 Polyfill 缺失问题。前者比较好理解,比如某些浏览器不支持箭头函数,我们就需要将其转换为function(){}
语法;而对后者来说,Polyfill
本身可以翻译为垫片
,也就是为浏览器提前注入一些 API 的实现代码,如Object.entries
方法的实现,这样可以保证产物可以正常使用这些 API,防止报错。
这两类问题本质上是通过前端的编译工具链(如Babel
)及 JS 的基础 Polyfill 库(如corejs
)来解决的,不会跟具体的构建工具所绑定。也就是说,对于这些本质的解决方案,在其它的构建工具(如 Webpack)能使用,在 Vite 当中也完全可以使用。
# 底层工具链
# 工具
解决上述提到的两类语法兼容问题,主要需要用到两方面的工具,分别包括:
- 编译时工具。代表工具有
@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 (opens new window)
- @babel/runtime (opens new window)
- @babel/runtime-corejs2 (opens new window)
- @babel/runtime-corejs3 (opens new window) 看似各种运行时库眼花缭乱,其实都是
core-js
和regenerator-runtime
不同版本的封装罢了(@babel/runtime
是个特例,不包含 core-js 的 Polyfill)。这类库是项目运行时必须要使用到的,因此一定要放到package.json
中的dependencies
中。
# 实践
@babel/cli
: 为 babel 官方的脚手架工具,很适合我们练习用。@babel/core
: babel 核心编译库。@babel/preset-env
: babel 的预设工具集,基本为 babel 必装的库。
pnpm i @babel/cli @babel/core @babel/preset-env
// .babelrc.json
{
"presets": [
[
"@babel/preset-env",
{
// 指定兼容的浏览器版本
"targets": {
"ie": "11"
},
// 基础库 core-js 的版本,一般指定为最新的大版本
"corejs": 3,
// Polyfill 注入策略,后文详细介绍
"useBuiltIns": "usage",
// 不将 ES 模块语法转换为其他模块语法
"modules": false
}
]
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Browserslist 是一个帮助我们设置目标浏览器的工具,不光是 Babel 用到,其他的编译工具如postcss-preset-env
、autoprefix
中都有所应用。对于Browserslist
的配置内容,你既可以放到 Babel 这种特定工具当中,也可以在package.json
中通过browserslist
声明:
// package.json
{
"browserslist": "ie >= 11"
}
2
3
4
或者通过.browserslistrc
进行声明:
// .browserslistrc
ie >= 11
2
在实际的项目中,一般我们可以将使用下面这些最佳实践集合来描述不同的浏览器类型,减轻配置负担:
// 现代浏览器
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
2
3
4
5
6
对于这些配置对应的具体浏览器列表,大家可以去 browserslist.dev (opens new window) 站点查看。
Babel 已经根据目标浏览器
的配置为我们添加了大量的 Polyfill 代码,index.js
文件简单的几行代码被编译成近 300 行。实际上,Babel 所做的事情就是将你的import "core-js"
代码替换成了产物中的这些具体模块的导入代码。
但这个配置有一个问题,即无法做到按需导入,上面的产物代码其实有大部分的 Polyfill 的代码我们并没有用到。Polyfill 代码主要来自 corejs
和 regenerator-runtime
,因此如果要运行起来,必须要装这两个库。可以发现 Polyfill 的代码精简了许多,真正地实现了按需 Polyfill 导入。因此,在实际的使用当中,还是推荐大家尽量使用useBuiltIns: "usage"
,进行按需的 Polyfill 注入。
我们来梳理一下,上面我们利用@babel/preset-env
进行了目标浏览器语法的降级和Polyfill
注入,同时用到了core-js
和regenerator-runtime
两个核心的运行时库。但@babel/preset-env
的方案也存在一定局限性:
- 如果使用新特性,往往是通过基础库(如 core-js)往全局环境添加 Polyfill,如果是开发应用没有任何问题,如果是开发第三方工具库,则很可能会对全局空间造成污染。
- 很多工具函数的实现代码(如上面示例中的
_defineProperty
方法),会在许多文件中重现出现,造成文件体积冗余。
# 更优的 Polyfill 注入方案: 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
2
我解释一下这两个依赖的作用: 前者是编译时工具,用来转换语法和添加 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
}
]
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
经过对比我们不难发现,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 注入,因此我觉得对于上文所介绍的底层工具链的掌握是必要的,否则无法理解插件内部所做的事情,真正遇到问题时往往会不知所措。
首先让我们来安装一下官方的插件:
pnpm i @vitejs/plugin-legacy -D
随后在项目中使用它:
// vite.config.ts
import legacy from '@vitejs/plugin-legacy';
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
// 省略其它插件
legacy({
// 设置目标浏览器,browserslist 配置语法
targets: ['ie >= 11'],
})
]
})
2
3
4
5
6
7
8
9
10
11
12
13
我们同样可以通过targets
指定目标浏览器,这个参数在插件内部会透传给@babel/preset-env
。相比一般的打包过程,多出了index-legacy.js
、vendor-legacy.js
以及polyfills-legacy.js
三份产物文件。
通过官方的legacy
插件, Vite 会分别打包出Modern
模式和Legacy
模式的产物,然后将两种产物插入同一个 HTML 里面,Modern
产物被放到 type="module"
的 script 标签中,而Legacy
产物则被放到带有 nomodule (opens new window) 的 script 标签中。浏览器的加载策略如下图所示:
这样产物便就能够同时放到现代浏览器和不支持type="module"
的低版本浏览器当中执行。当然,在具体的代码语法层面,插件还需要考虑语法降级和 Polyfill 按需注入的问题,接下来我们就来分析一下 Vite 的官方legacy
插件是如何解决这些问题的。
# 插件执行原理
官方的 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 || {}]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
接着,在renderChunk
阶段,插件会对 Legacy 模式产物进行语法转译和 Polyfill 收集,值得注意的是,这里并不会真正注入Polyfill
,而仅仅只是收集Polyfill
,:
{
renderChunk(raw, chunk, opts) {
// 1. 使用 babel + @babel/preset-env 进行语法转换与 Polyfill 注入
// 2. 由于此时已经打包后的 Chunk 已经生成
// 这里需要去掉 babel 注入的 import 语句,并记录所需的 Polyfill
// 3. 最后的 Polyfill 代码将会在 generateBundle 阶段生成
}
}
2
3
4
5
6
7
8
由于场景是应用打包,这里直接使用 @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,保证产物写到磁盘中
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
因此,你可以理解为这个函数的作用即通过 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> 标签
}
}
2
3
4
5
6