💡 引言
在本地开发时,我们在最新的 Chrome 浏览器中跑得顺风顺水,各种优雅的箭头函数、Promise.allSettled、异步语法信手拈来。然而一旦发布上线,测试却发来了一张老旧平板或低版本浏览器下的"纯白网页"截图。
打开控制台一看:Uncaught SyntaxError: Unexpected token ')' 或者 Promise is not defined。
这就是经典的老旧浏览器兼容性翻车现场。为了不让这些高级语法成为线上事故的导火索,语法降级 与 Polyfill 注入成为了前端工程化中不可或缺的兜底手段。作为下一代构建工具的代表,Vite 是如何在这场"新老交替"的残局中做到完美兼容的?今天我们就来扒一扒它的底层底牌。
一、 基础设施底座:语法降级与 Polyfill 注入
在解决问题之前,我们必须先厘清两个经常被混淆的核心概念:
| 核心概念 | 本质定义 | 典型案例 | 解决手段 |
|---|---|---|---|
| 语法降级 | 将高版本的 ES 语法翻译为低版本浏览器能识别的纯语法结构。 (语言层面的翻译) | 箭头函数 () => {} 降级为 function() {};解构赋值降级为普通赋值。 |
依赖编译器(如 Babel)将 AST(抽象语法树)进行转换。 |
| Polyfill 注入 | 为低版本浏览器全局挂载或补充缺失的运行时 API。 (API层面的功能补齐,俗称"打补丁") | 浏览器缺少全局 Promise 对象、Object.entries 或 Array.prototype.includes 方法。 |
依赖运行时基础库(如 core-js 、regenerator-runtime)提供垫片代码。 |
📌 构建工具的角色 :Vite 作为一个构建工具,其本身并不生产这些底层的降级代码。Vite 考虑的仅仅是如何将这些底层基础设施(Babel / core-js)优雅地接入到整体构建流水线中。
1. 传统编译时工具链
传统的解决方案完全依赖于 Babel 生态及运行时基础库:
- 编译时工具 :在代码编译阶段进行语法降级,并自动添加 Polyfill 代码的引用语句。代表工具有
@babel/preset-env和@babel/plugin-transform-runtime。 - 运行时基础库 :根据 ECMAScript 官方规范提供具体的 Polyfill 实现代码。代表库包括
core-js和regenerator-runtime(用于兼容 async/await)。
2. 传统配置示例与核心痛点
在传统的 Webpack 环境中,我们通常会新建一个 .babelrc.json 配置文件:
JSON
{
"presets": [
[
"@babel/preset-env",
{
"targets": { "ie": "11" }, // 指定要兼容的目标浏览器版本
"corejs": 3, // 指定核心基础库 core-js 的大版本
"useBuiltIns": "usage", // Polyfill 注入策略:按需导入
"modules": false // 保持 ESM 模块语法,交给打包工具处理
}
]
]
}
useBuiltIns策略对比:
false:默认值,不添加任何 Polyfill。entry:根据目标浏览器的配置,一口气引入目标环境下缺失的所有 Polyfill,无法做到按需,产物体积臃肿。usage:根据目标浏览器的配置,并结合代码中实际用到的 API 进行按需导入,推荐使用。
💡 更优的 Polyfill 注入方案:@babel/plugin-transform-runtime
传统的 @babel/preset-env 在注入 Polyfill 时有一个致命弱点:它会在每个需要补丁的文件里重复注入辅助函数,造成代码文件体积冗余;更严重的是,它会直接通过类似 window.Promise = ... 的方式污染全局作用域。
而引入 transform-runtime 插件后,它会从一个公共的沙箱 helper 库中引入这些方法,既不会重复注入,也不会污染全局作用域,非常适合开发第三方公共库。
二、 Vite 的现代破局方案:@vitejs/plugin-legacy
在 Vite 的世界里,如果你不想再为了复杂的 Babel 链条薅秃头发,官方提供了一个全家桶式、开箱即用的完美方案: @vitejs/plugin-legacy。
1. 插件引入与工程化配置
我们只需要在 vite.config.ts 中一键配置,即可同时接管语法降级与生产环境压缩:
TypeScript
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';
import vue from '@vitejs/plugin-vue'; // 以 Vue 项目为例
export default defineConfig({
plugins: [
vue(),
legacy({
// 1. 目标浏览器配置(完美支持 browserslist 语法)
targets: ['ie >= 11', 'chrome < 60'],
// 2. 自定义 polyfill(默认已包含 core-js 核心 API,可在此处补充特殊需求)
additionalLegacyPolyfills: ['regenerator-runtime/runtime'], // 完美兼容 async/await
// 3. 压缩 legacy 产物(生产环境建议开启)
renderLegacyChunks: true,
// 4. 禁用现代模式下的 polyfill 收集(仅供本地特殊调试用,生产不建议关闭)
// modernPolyfills: false
})
],
build: {
// 生产环境构建优化策略
target: 'es2015', // 基础语法目标,与 legacy 插件形成高低搭配
minify: 'terser', // terser 相比 esbuild 混淆,能提供更彻底、更安全的低版本兼容压缩
terserOptions: {
compress: {
drop_console: true // 生产环境自动移除 console.log
}
}
}
});
2. 独创的"双模产物"分流机制
通过官方的 legacy 插件,Vite 在打包时会玩一出"一树开双花"的魔法,分别生成 Modern(现代)模式 和 Legacy(传统)模式两套产物,并同时插入到同一个 HTML 之中:
HTML
<!-- 1. 现代浏览器:直接加载现代模式产物 -->
<script type="module" src="/assets/main-modern.js"></script>
<!-- 2. 低版本浏览器兜底:加载传统模式产物 -->
<script nomodule src="/assets/polyfills-legacy.js"></script>
<script nomodule src="/assets/main-legacy.js"></script>
- Modern 产物 :包裹在
<script type="module">中。现代浏览器识别该标签,直接按原生 ESM 高效加载执行,不携带任何冗余的降级补丁,体积小、性能极优。 - Legacy 产物 :包裹在
<script nomodule>中。现代浏览器会自动忽略此标签;而低版本浏览器不认识type="module",会直接跳过现代产物,转而加载并运行带有完整语法降级和 Polyfill 的 Legacy 产物。 - 双向奔赴:通过利用浏览器的原生特性,两套代码天然隔离,绝不重复执行。

三、 深度硬核:@vitejs/plugin-legacy 的执行原理
这个插件是如何在 Vite 底层将 Rollup 的打包链路玩转得如此丝滑的?它在生命周期里主要干了四件事:

1. configResolved 阶段:强行开启"第二战线"
在 Vite 解析完最终配置的 configResolved 钩子中,插件会悄悄复制一份当前的 output 配置,并针对低版本环境进行修改。这相当于给 Vite 底层的打包引擎 Rollup 下达了双重指令:在打完标准的现代包后,必须开辟第二战线,克隆并另外打包出一份专为低版本优化的 Legacy 模式产物。
2. renderChunk 阶段:单文件转译与 Polyfill 收集
当进入到代码块渲染的 renderChunk 阶段时,插件开始对 Legacy 模式下的 Chunk 进行真正的格式手术:
- 核心工具 :插件内部会调用
@babel/standalone(或者内部整合的 Babel 核心转换器)对当前的 Chunk 代码进行全量的语法降级。 - 只收集,不注入 :值得注意的是,Babel 在这里发现需要挂载
Promise垫片时,并不会立刻把 Polyfill 代码塞进当前的业务文件里 。因为 Vite 崇尚模块化隔离,如果立刻注入会导致各个 Chunk 之间出现严重的重复冗余。它在这里仅仅是充当一个"记账本",把所有业务代码里用到的 Polyfill 标识给记录并收集起来。
3. generateBundle 阶段:全量聚拢与统一写盘
当所有的代码块全部处理完毕、进入 generateBundle 打包终局阶段时,插件会翻开之前的"记账本",将前面所有 Chunk 漏掉的、收集到的 Polyfill 依赖聚拢到一起。随后,Vite 会调用 esbuild/Babel 将这些零散的垫片统一打包合并,生成一个独立的 polyfills-legacy.js 文件。
4. transformIndexHtml 阶段:HTML 最终兵器插值
万事俱备,只欠东风。在最后一个 transformIndexHtml 钩子中,插件将接管最终的 HTML 页面渲染。
- 它会把现代模式的
<script type="module">路径、Legacy 模式的<script nomodule>路径按照规范填入 HTML 中。 - 补充底层细节 :由于低版本浏览器根本无法识别 ESM 的
import/export链路,插件还会贴心地在 Legacy 脚本之前,注入一个轻量级的SystemJS运行时脚本(system.js) 。Legacy 的业务产物会被自动包装成System.register格式,从而让低版本浏览器也能通过 SystemJS 的沙箱机制,实现异步模块的完美加载。
📌 总结
Vite 的高级之处,在于它没有走回 Webpack 时代"为了照顾老浏览器而全量重写、拖慢整体开发体验"的老路。它通过 @vitejs/plugin-legacy 的精妙设计,在开发阶段依然享受极致的 ESM 秒级启动;在生产环境通过双模打包,让现代浏览器享受轻量级的高性能,让老旧浏览器拥有完美的兜底方案。 这种针对具体生命周期的多钩子组合拳,正是前端工程化美学的极致体现。