ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vite 兼容 IE11:plugin-legacy 与 Polyfill 实战

Vite 兼容 IE11:plugin-legacy 与 Polyfill 实战

1. 先定位真正的断点:IE11 在 Vite 这条链路上究竟卡在哪一步

Vite 项目要兼容 IE11,绕不开两件事:用 Legacy 插件把现代产物再降级出一份,用 Polyfill 策略把缺失的 API 补齐。听起来像是加个插件、写几行配置的事,真动手的时候,十有八九是这样一个顺序:先白屏,控制台丢出一行SCRIPT1002: 语法错误,解决完语法问题又冒出SCRIPT5009: “Promise” 未定义,API 补完了发现页面能出来但是样式全塌,最后打包体积翻了一倍,老机器打开要等五六秒。

这篇内容面向的是手上有存量 Vue/React + Vite 项目、业务上确实还有 IE11 访问量、需要在不上大改架构的前提下把兼容做掉的开发者。如果你只是听说“Vite 不支持 IE”,想搞清楚它到底不支持在哪、能不能救、救的代价多大,这篇也够用。我会把配置、原理、产物结构、排错链路和踩过的坑一次讲透,配置可以直接抄。

先明确一个前提:IE11 的问题从来不是“一个”问题,而是三层同时断裂。语法层,IE11 只认到 ES5,箭头函数、let/const、模板字符串、解构、展开运算符、class、async/await、可选链,统统解析失败,而且是解析阶段的语法错误,整份脚本一行都不会执行。API 层,就算语法降级到 ES5,Promise、Symbol、Map/Set、Object.assign、Array.from、fetch、URLSearchParams这些运行时对象在 IE11 里根本不存在或者残缺。模块层,Vite 构建产物默认是原生 ESM,用<script type="module">加载,IE11 连这个标签类型都不认识,直接跳过。

1.1 语法层:ESM 与 ES2015+ 的双重断裂

Vite 的立身之本是原生 ESM,开发期浏览器直接请求.js模块,构建期 Rollup 打包后仍然以type="module"注入 HTML。IE11 对type="module"的处理方式是“不认识就忽略”,所以它压根不会去加载主入口。这一步就已经是死局,跟代码里写没写箭头函数没关系。

更麻烦的是构建目标。Vite 的build.target默认值是'modules',大致对应es2020加一组浏览器底线(chrome87 / edge88 / firefox78 / safari14 这个量级),esbuild 按这个目标做语法降级。想让它继续往下压,你会遇到一个硬限制:esbuild 不支持输出 ES5。它能把大部分 ES2015+ 语法往下转,但遇到async/await、生成器、部分解构场景时,无法生成 ES5 等价代码。所以哪怕你把build.target改成'es2015',IE11 依然会在async function那一行直接语法报错。

这就是为什么必须引入 Babel 体系。@vitejs/plugin-legacy的核心动作,就是在正常构建之外,再跑一遍基于@babel/preset-env的降级构建,把整包代码压到 ES5。这不是“锦上添花的兼容层”,是唯一能走通的路。

1.2 API 层:core-js 管不到的那部分

很多人对 Polyfill 有个误解,以为@babel/preset-env加上useBuiltIns: 'usage'就万事大吉。语法降级和 API 补齐是两条完全独立的线:Babel 把箭头函数翻译成function,但它不会凭空造出一个Promise,那是 core-js 的活儿;core-js 补了Promise,也不会帮你去补fetch,因为 fetch 属于 Web 平台 API,不在 ECMAScript 规范范围里,core-js 主线不负责。

还有一类更容易被忽略:DOM 相关的扩展方法。Element.prototype.closest、NodeList.prototype.forEach、Element.prototype.remove、CustomEvent构造函数、dataset的部分行为,这些在 IE11 里要么没有,要么实现有偏差。core-js 的web.dom-collections.for-each能解决NodeList.forEach,但closest得单独找 shim,或者干脆在业务层写个工具函数绕开。

真正的坑在于:这些缺失往往不是你的代码触发的,而是第三方 SDK 触发的。图表库、埋点脚本、客服组件、支付 SDK,它们在初始化时如果摸不到Promise或者fetch,报错栈会指向一个压缩过的、你根本读不懂的文件,排查成本极高。

1.3 构建层:esbuild 的 target 与 browserslist 是两套体系

Vite 里同时存在两套“目标浏览器”的表达方式,很多配置冲突就出在这里。一套是build.target(还有配套的build.cssTarget),交给 esbuild,管构建期的语法降级和 CSS 压缩策略;另一套是browserslist(写在package.json或.browserslistrc里),主要服务于 Autoprefixer、Babel 这类工具。

@vitejs/plugin-legacy默认会读取browserslist配置(ignoreBrowserslistConfig默认为false),而build.target完全是另一条线。两者不一致时会出现诡异现象:比如你在 browserslist 里写了ie 11,Autoprefixer 老老实实加了-ms-前缀,但build.target还是默认值,esbuild 压缩 CSS 时把rgba(255, 0, 0, 0.5)转成了 8 位十六进制#ff000080,IE11 不认识这种写法,颜色直接失效。所以build.cssTarget必须单独往低压。

把这三层理清楚,后面的配置才有依据。单纯堆插件、堆 polyfill 而不理解断点在哪,最后只会得到一份体积巨大、仍然跑不起来的产物。

2. plugin-legacy 的工作机制:它到底替你做了哪些脏活

搞清楚插件在干什么,比记住配置项重要得多。因为一旦出问题,你得知道去哪份产物、哪段注入脚本里找线索。@vitejs/plugin-legacy做的事情可以拆成三块:产出双份 bundle、在 HTML 里做分流、为 legacy 那份注入 polyfill 并换掉模块加载器。

2.1 双份产物与 nomodule 分流

构建结束后,dist/assets目录里会出现两组文件。现代组是常规的index-abc123.js加上若干 chunk,legacy 组则统一带-legacy后缀,比如index-abc123-legacy.js。两组文件的内容差异很大:现代组基本保持原样,legacy 组是经过 Babel 完整降级、再用 terser 压缩过的 ES5 代码。

HTML 里的注入逻辑是这样的:现代组用<script type="module" crossorigin>加载,legacy 组用<script nomodule>加载。这个nomodule属性是个非常巧妙的设计——支持 ESM 的浏览器看到nomodule就跳过,不认识 ESM 的 IE11 把nomodule当成一个未知的自定义属性,直接忽略,于是老老实实执行 legacy 脚本。两边各取所需,互不干扰。

但只靠nomodule还不够。有些浏览器(尤其是早期版本的 Safari)对nomodule的支持有缺陷,会同时执行两份脚本,导致应用初始化两遍、事件绑定重复、状态错乱。插件的处理办法是在 HTML 里额外注入一段极短的语法探针放在模块脚本中,里面包含import.meta、动态import()、async function*这类只有现代浏览器才能解析的语法,同时设置一个全局标志位;nomodule脚本开头再检查这个标志位,如果发现“我其实是个现代浏览器”,就主动放弃加载 legacy chunk。这段逻辑你平时看不到,但它在你手动改 HTML 模板或者用自定义transformIndexHtml时极容易被破坏。

2.2 SystemJS 承载 legacy chunk 的加载链路

legacy 组不能再用原生 ESM 加载了,因为 IE11 没有import。插件内置了一份精简版 SystemJS 运行时,把 legacy bundle 注册成 System 模块,主入口通过System.import()拉起,代码分割产生的异步 chunk 也走System.register注册。

这意味着两件事。第一,import()动态导入在 legacy 下依然可用,但走的是 SystemJS 的实现,行为细节和原生有差别,比如预加载策略、循环依赖处理。第二,import.meta在 legacy 环境下是无效的——Babel 没法把它翻译成 ES5 的等价物,如果你在业务代码里用了import.meta.url或者import.meta.env.XXX(后者构建期会被替换掉,没问题,前者不会),legacy 包会直接报错或者拿到undefined。这是我在实际项目里踩过的最隐蔽的坑之一。

还有顶层await(top-level await)。现代构建里 esbuild 和 Rollup 都能处理,legacy 那条 Babel 链路处理不了,插件会给出警告但构建不一定失败,结果就是 IE11 打开一片空白。见到这个警告必须当成错误处理。

2.3 polyfill 注入的时机与 usage 检测

插件默认的polyfills: true不是“把所有 polyfill 都塞进去”,而是启用基于 core-js 的按需检测:它用@babel/preset-env的useBuiltIns: 'usage'逻辑扫一遍代码,看实际用到了哪些 API,再结合目标浏览器列表算出需要补哪些 core-js 模块,打成一个独立的 polyfill chunk,用<script nomodule>在主入口之前注入。

顺序非常关键。polyfill chunk 必须在业务代码之前执行完毕,否则业务代码里第一句new Promise(...)就会炸。插件生成的 HTML 里这个顺序是固定的,但如果你为了性能优化手动调整了脚本位置,或者用了某些 HTML 压缩插件,顺序被打乱,排查起来会非常痛苦——因为报错点看起来毫无道理。

modernPolyfills默认是false,意思是现代浏览器那份产物一个 polyfill 都不带。这在多数场景下没问题,但如果你的modernTargets里包含了 Safari 12 这类“支持 ESM 但某些 API 缺失”的浏览器,就需要把它开成true,或者显式列几个模块名。判断依据很简单:只要目标浏览器里存在“支持模块但缺某个 ES 新 API”的版本,modernPolyfills 就得管。

选项默认值作用与常见改法
targets'defaults'legacy 构建的降级目标,IE11 场景写'ie >= 11'
polyfillstrue按需注入 legacy polyfill,也可传数组精确控制
modernPolyfillsfalse现代产物的 polyfill,Safari 12 之类的场景要开
additionalLegacyPolyfills[]额外补的包,如whatwg-fetch、regenerator-runtime/runtime
renderModernChunkstrue关掉就只产出 legacy 一份,体积换兼容
ignoreBrowserslistConfigfalse是否忽略项目里的 browserslist 配置

3. 一份可以直接落地的配置:从依赖清单到 vite.config

配置这东西,网上抄来的片段往往只覆盖了一半,剩下的坑要自己踩。我把一份经过实际项目验证的完整配置拆开讲,每一项都说明为什么这么写。

3.1 依赖清单与版本克制

需要装的包其实不多,但版本对应关系必须严格:@vitejs/plugin-legacy的大版本要和 Vite 的大版本对齐,Vite 5 配插件 5.x,Vite 4 配插件 4.x,混用会出现构建期 API 不存在的报错。另外terser必须装上,因为 esbuild 压不出 ES5,legacy chunk 的压缩只能交给 terser,插件不会自动帮你装。

npm i -D @vitejs/plugin-legacy terser npm i core-js regenerator-runtime whatwg-fetch

core-js和regenerator-runtime建议放dependencies而不是devDependencies,因为 polyfill chunk 在运行时会引用它们(具体取决于插件版本是否把它们内联进产物),放错位置在某些 CI 环境下会出现“本地能构建、线上缺模块”的问题。whatwg-fetch是给fetch兜底的,sendBeacon、AbortController这些按需再加。

轻量 shim 类的按需补:

npm i element-closest resize-observer-polyfill intersection-observer

element-closest解决Element.prototype.closest,两个 Observer 在现代项目里几乎必用,IE11 全都没有。这些不能靠 core-js 解决,必须显式引入。

3.2 vite.config 逐项拆解

先看完整配置,再逐行解释。

import { defineConfig } from 'vite' import legacy from '@vitejs/plugin-legacy' export default defineConfig({ plugins: [ legacy({ targets: ['ie >= 11'], additionalLegacyPolyfills: ['regenerator-runtime/runtime'], modernPolyfills: true, renderLegacyChunks: true, }), ], build: { target: 'es2015', cssTarget: 'chrome61', minify: 'terser', polyfillModulePreload: false, rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router'], }, }, }, }, })

build.target: 'es2015'这一行值得说说。它不影响 IE11 的最终结果(IE11 靠 legacy 那份),但把现代产物的语法底线压到 ES2015,能减少“现代浏览器版本不够新”导致的兼容问题。有些企业内网环境的浏览器版本比想象中旧,ES2020 的??、?.在 Chrome 79 上就跑不了,而 legacy 分流是按“是否支持 ESM”来判定的,Chrome 79 支持 ESM,会被分到现代组,结果就炸了。压到 ES2015 是个稳妥的折中。

build.cssTarget: 'chrome61'是为了防止 esbuild 的 CSS 压缩器输出 IE11 不认的现代色彩语法(8 位十六进制、rgb()空格分隔写法)。这个值不用太精确,取一个不支持这些新语法的版本号就行。

build.minify: 'terser'配合前面装的 terser,让 legacy 那份能正常压缩。polyfillModulePreload: false关掉 modulepreload 的 polyfill——对 legacy 用户来说这个 polyfill 没意义,反而增加体积;新版 Vite 里对应的是build.modulePreload.polyfill。

manualChunks不是必须的,但在同时产出两份 bundle 的情况下,把稳定的第三方依赖单独拆出来能明显改善缓存命中率:代码改动时只有业务 chunk 变化,vendor 和 legacy vendor 都还在缓存里。

如果项目是“现代浏览器为主、IE11 只占极小比例”,可以考虑renderModernChunks: false只留 legacy 一份,体积最少,但所有用户都得吃 ES5 的亏,性能损失不小。我的建议是除非 IE11 占比超过三成,否则别这么干。

3.3 browserslist 该写在哪、写成什么样

放在package.json里最省事,也最不容易被漏掉:

{ "browserslist": { "production": ["> 0.5%", "last 2 versions", "ie >= 11", "not dead"], "development": ["last 1 chrome version", "last 1 firefox version"] } }

分production和development两套是关键。开发期只针对最新浏览器,Autoprefixer 少干活、构建快;生产环境带ie >= 11,前缀和降级才生效。很多人只写一套全局配置,结果开发期每次样式改动都跑一遍 IE 前缀,慢得让人怀疑人生。

注意ie >= 11这个写法,虽然 IE 已经停止支持,但 browserslist 的语法依然认。不要写成ie 11(只有这一个版本,虽然结果一样),也不要用> 0.5% in CN这类会随时间漂移的写法——browserslist 数据会更新,某天你的目标列表里突然多出几个版本,构建产物就变了,而你没改过任何代码。生产环境的兼容目标要写死,别依赖动态统计。

3.4 scripts 与构建产物的自检方法

脚本层面不用大改,但建议加一个产物检查动作:

{ "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview --port 4173", "legacy:check": "node scripts/check-legacy.mjs" } }

check-legacy这个脚本干的事情很简单:读dist/index.html,确认里面存在type="module"的现代入口、存在nomodule的 legacy 入口、存在 polyfill chunk 且位置在主入口之前,再扫一遍dist/assets/*-legacy.js里有没有残留的箭头函数或const(用正则粗筛就行,误报可以接受)。这个检查放在 CI 里跑,能挡住“改配置时不小心把插件去掉”这类回归。

构建完先别看浏览器,先看代码块:打开dist/index.html,你应该能看到类似这样的结构(chunk 名会不同):

<script type="module" crossorigin src="/assets/index-a1b2c3.js"></script> <script nomodule>!function(){/* 语法探针 + 标志位检查 */}()</script> <link rel="modulepreload" href="/assets/vendor-d4e5f6.js"> <script nomodule src="/assets/polyfills-legacy-7g8h9i.js"></script> <script nomodule src="/assets/index-legacy-j1k2l3.js"></script>

如果 polyfill 那行的位置跑到了主入口后面,或者nomodule属性丢了,直接就是配置或构建插件冲突的问题,不用去浏览器里试了。

4. 开发期怎么办:dev server 在 IE11 上必然跑不起来

这是新人最常问的问题:“我配置好了,npm run dev打开 IE11 还是白屏。”答案很直接:Vite 开发服务器永远不可能在 IE11 上跑起来,这不是配置问题,是原理问题。开发期 Vite 不做打包,浏览器按需请求 ES 模块,源码几乎是原样送出去的,@vitejs/plugin-legacy在这条链路上不生效,它只作用于构建产物。

4.1 为什么 dev 一定不行

开发服务器返回的每个模块都带着import/export语句,IE11 解析第一行就语法错误。就算你把build.target压到es2015也没用,dev 期根本不走 esbuild 的产物压缩(只做单文件转译),更没有 Babel 降级环节。插件的文档里写得很清楚:legacy 只在 build 阶段工作。

接受这个现实之后,工作流要重新设计。核心思路是把“写代码”和“在 IE11 里验证”拆成两个循环:日常开发用 Chrome 快速迭代,验证 IE11 时切到构建 + 预览的模式。

4.2 build --watch 加 preview 的联调闭环

最实用的做法是开两个终端,一个跑vite build --watch,一个跑vite preview。前者监听源码变化重新构建,后者托管dist目录并提供代理。改完代码等两三秒构建完,刷新 IE11 就能看到最新结果。

# 终端 A npx vite build --watch # 终端 B npx vite preview --port 4173

vite preview会继承server.proxy的配置(preview.proxy默认从server.proxy继承),所以接口转发不用重复写。如果后端在别的端口,直接在vite.config里配好server.proxy即可。

这套流程的体验肯定不如 HMR,但对于只需要在收尾阶段验证兼容性的项目完全够用。如果团队里有人需要长时间在 IE11 下调样式,可以用npm-run-all或concurrently把两个命令合成一个:

{ "scripts": { "dev:legacy": "concurrently \"vite build --watch\" \"vite preview --port 4173\"" } }

顺带提一句,构建速度在这种模式下会被放大成体验问题。如果全量构建要 40 秒,那基本没法用。能优化的点:关掉 sourcemap(build.sourcemap: false)、legacy 构建里排除不需要的大依赖、把manualChunks拆细一点让增量构建命中缓存。这些优化对生产构建同样有意义。

4.3 IE11 下的排错手法

IE11 的开发者工具非常原始,但有几个技巧能省很多时间。

第一,先看有没有语法错误。IE11 的“脚本”面板里,语法错误的行号一般准确,报SCRIPT1002就是 ES6+ 语法没降级干净。此时去dist/assets/*-legacy.js里搜一下对应的语法特征(箭头函数=>、const、?.、??),能快速定位是哪个依赖没被降级。

第二,用debugger断点代替 console。IE11 的console.log对对象支持很差,打出来全是[object Object],而且控制台在页面刷新后不保留。直接在被怀疑的代码前插debugger,看调用栈更有效。

第三,给 polyfill 加载装一个探针。在index.html的nomodule段里加一行,把加载进度写到document.title上:

<script nomodule> document.title = 'legacy-start' </script>

如果 IE11 打开后标题停在legacy-start,说明 legacy 脚本执行了但后面挂了;如果标题完全没变,说明这段压根没执行,问题在 HTML 层面(nomodule被去掉、脚本路径 404、被网关拦截)。这个土办法在没法远程调试的客户现场特别好使。

5. 语法降级之外:IE11 上必须手工兜底的样式与 DOM

到这一步,脚本能跑了、接口能通了,很多人就以为完事了。实际上真正的收尾工作在这里:样式错乱和运行时 API 缺失,往往比语法错误更耗时间,因为它们的表现是“看起来不对”,而不是“直接报错”。

5.1 CSS 变量、gap 与选择器

IE11 对 CSS 的支持停留在 2013 年前后的水平。最致命的是不支持 CSS 自定义属性。如果你的设计系统基于--primary-color这套变量,IE11 上所有用到变量的地方都会失效,表现为颜色丢失、间距归零。解决办法是在构建链里加postcss-custom-properties,并且在配置里关掉preserve(保留原始变量声明对 IE11 无害,但会让文件变大):

// postcss.config.js export default { plugins: { 'postcss-preset-env': { stage: 3, features: { 'custom-properties': { preserve: false }, 'nesting-rules': true, }, }, autoprefixer: {}, }, }

第二个高频问题是flex 布局的gap。IE11 完全不支持 flex 容器上的gap,元素会紧紧贴在一起。这个问题没法用前缀解决,只能改布局:用margin手工做间距,或者换成grid(IE11 的 grid 只支持老规范,需要通过 autoprefixer 的grid选项生成-ms-前缀,且功能残缺,不推荐)。实际项目里我的做法是写一个.flex-gap的降级类,配合 PostCSS 插件在检测到gap时自动补负 margin 方案,虽然不完美但能覆盖大部分场景。

第三个是CSS 嵌套。如果你用了原生 CSS 嵌套语法(&那种),IE11 只会把它当成无效选择器整条丢弃。必须通过postcss-nesting或postcss-preset-env的 nesting 特性在构建期展开。

还有一批零碎的:position: sticky不支持(需要 JS 兜底)、calc()在某些嵌套写法下解析失败、:is()/:where()/:not()的多参数形式不支持、clamp()不支持、aspect-ratio不支持。这些在样式验收时逐条过一遍,别指望自动化工具能全部发现。

5.2 常见的 DOM/BOM API 缺失清单

下面这张表是我在多个项目里累积出来的,按出现频率排序,可以直接当检查清单用。

缺失项补齐方式备注
Promisecore-js 自动最基础,缺了整体瘫痪
Object.assign/Object.entriescore-js 自动usage 模式能识别
Array.from/find/includes/flatcore-js 自动flat容易漏,检查一下
String.prototype.padStart/trimStartcore-js 自动表单格式化常用
Symbol/Map/Set/WeakMapcore-js 自动部分库内部依赖
fetchwhatwg-fetchcore-js 不管,必须单独补
URLSearchParamscore-jsweb.url-search-params一般会被 usage 检测到
Element.closestelement-closestDOM 扩展,core-js 不覆盖
NodeList.prototype.forEachcore-jsweb.dom-collections.for-each或者用Array.from转
CustomEvent构造函数手写 shimnew CustomEvent()会抛错
ResizeObserver/IntersectionObserver独立 polyfill 包现代组件库几乎必用
requestIdleCallback手写 shim 或忽略影响不大
Intl(数字/日期格式化)独立 intl polyfill体积大,按需

重点说fetch。很多团队栽在这上面:core-js 装了一堆,Promise也有了,结果埋点 SDK 初始化时调window.fetch,直接SCRIPT438: 对象不支持此属性或方法。这种报错在压缩过的第三方文件里,堆栈完全没用,只能靠“哪一行代码先调用外部 SDK”去反推。

CustomEvent那个坑更隐蔽。IE11 有document.createEvent('CustomEvent')这套老 API,但没有构造函数形式,new CustomEvent('foo', { detail: {...} })会直接抛异常。很多组件库的通信用的是这个,页面在某个交互操作后才崩,很难联想到是兼容问题。手写一个十来行的 shim 就能解决:

(function () { if (typeof window.CustomEvent === 'function') return function CustomEvent(event, params) { params = params || { bubbles: false, cancelable: false, detail: null } var evt = document.createEvent('CustomEvent') evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail) return evt } window.CustomEvent = CustomEvent })()

这段要放在所有业务代码之前,最省事的位置是在index.html里用一个内联的nomodule脚本包起来,或者放进 legacy polyfill 的入口文件里做前置导入。

5.3 第三方 SDK 的兼容盲区

项目里引的每一个外部脚本都是潜在的兼容炸弹。我遇到过的情况包括:某数据可视化库在初始化时用了Object.values(core-js 能补,但那个库是外链加载的,不走你的打包链路,补不到);某客服组件用了Element.prototype.replaceWith;某支付 SDK 依赖Intl.NumberFormat做金额格式化,IE11 上金额显示成NaN。

应对策略分两种。能打进包里的,尽量走import引入,让 Babel 和 core-js 覆盖到它,代价是包变大。必须以<script>外链加载的,只能在index.html里、在它之前注入一段兼容垫片,把所有可能用到的 API 补齐,哪怕其中一部分用不上。垫片代码可以单独打成一个 legacy-only 的小文件,现代浏览器不加载。

还有一个容易被忽略的点:第三方 SDK 的 CDN 文件通常是 UMD 格式,里面可能带有 ES6+ 语法。IE11 加载时不是运行时错误,而是语法错误,整个文件不执行,你会在后续调用处看到xxx is undefined。判断方法是把那个文件用在线语法检查工具跑一遍,或者看报错行号是否指向该文件内部。

6. 踩坑实录:几个真实会撞上的报错与定位过程

配置写对了不代表一路顺风,下面这几个坑是我在多个项目里反复遇到的,把排查链路完整写出来,方便对照。

6.1 白屏且控制台干净:legacy 脚本没有执行

最迷惑的情形:IE11 打开页面,白屏,控制台一条错误都没有。这时候第一反应应该是“脚本压根没加载”,而不是“代码有 bug”。

排查顺序这样走:先看网络面板,确认*-legacy.js和polyfills-legacy-*.js有没有发起请求。没有请求,说明 HTML 里的nomodule脚本没有被识别,常见原因有三个——HTML 被某个压缩插件处理过,nomodule属性被当成冗余属性删掉了(某些老版本 HTML 压缩器的默认行为);<script>被加上了defer或者动态插入,执行时机不对;服务器返回的 HTML 被网关或模板引擎二次处理,属性被过滤。

有请求但状态码是 404,多半是部署路径配错了。Vite 的base配置会写进产物里的资源路径,如果部署在子目录下而base还是/,现代浏览器能靠相对路径侥幸跑起来,legacy 脚本里的路径写死就容易 404。

请求成功但没执行,去看响应头。如果Content-Type不是application/javascript而是text/plain,某些情况下 IE11 会拒绝执行。这是我在一个老网关后面遇到的问题,改 MIME 类型就好了。

6.2 “对象不支持此属性或方法”:polyfill 顺序或覆盖不完整

SCRIPT438这个报错在 IE11 上出现频率最高,泛指“你调的方法不存在”。难点在于它不告诉你是哪个对象。

有效的定位方法是在 polyfill chunk 执行完之后打一个断点,然后在控制台里逐个检查关键 API 是否存在:typeof Promise、typeof Object.assign、typeof window.fetch、typeof Element.prototype.closest。缺哪个补哪个,比一行行读代码快得多。

顺序问题也在这个环节暴露。我遇到过一次:polyfill chunk 明明加载了,但业务代码里第一句Promise仍然报未定义。原因是某个优化插件把nomodule脚本改成了async,导致执行顺序不再有保证。nomodule脚本必须按顺序同步执行,加async或defer都会破坏这个前提。

还有一种情况是多个入口共享 polyfill。如果项目是多页应用(多 HTML 入口),每个页面都要引 polyfill chunk,漏掉一个页面就出问题。这种情况下建议把 polyfill 提升到公共布局模板里统一注入。

6.3 构建直接失败:terser 缺失与语法穿透

构建报terser not found这类错误很好认,装一下就行。更麻烦的是构建成功但产物里混进了 ES6+ 语法。

原因通常是某个依赖以 ESM 形式发布,且含有 Babel 不处理的语法(比如import.meta、顶层await、装饰器)。Babel 的 preset-env 只负责它认识的标准语法降级,遇到这些会原样跳过。定位办法:在dist/assets/*-legacy.js里搜import.meta、await(注意顶层 await 的特征是出现在模块顶层而不是函数体内),搜到之后顺着 sourcemap 找来源模块,用optimizeDeps.exclude或者换一个兼容版本。

另一个常见穿透点是?.和??。Babel 的 preset-env 在targets包含 IE11 时会转这两个,但如果某个依赖被标记为external没进打包流程,就不会被转。检查rollupOptions.external有没有把不该外置的东西外置了。

6.4 体积翻倍与老机器加载缓慢

开启 legacy 之后,产物体积基本是原来的两倍出头。现代用户不受影响(他们只加载现代组),但构建时间和部署体积实打实地涨了。IE11 用户那边更痛苦,他们加载的是未压缩效果最好的那份 ES5,加上 polyfill chunk,首屏可能要多等好几秒。

能做的优化有这么几条。把manualChunks拆细,让现代组和 legacy 组各自有稳定的 vendor chunk,缓存命中率提升明显。检查polyfills是不是补多了——用polyfills: ['es.promise', 'es.array.iterator', 'web.dom-collections.iterator']这种显式列表替换true,能砍掉不少用不上的模块,代价是每次新增 API 要手动加,容易漏,我一般只在体积压力特别大的项目里这么干。

还有一招是把 legacy 相关的构建产物单独部署,用不同的路径或者不同的 CDN 策略。不过这套方案维护成本高,除非 IE11 用户占比很低且对现代用户体验要求极高,否则不值得。

6.5 动态 import 与分包在 legacy 下的表现

路由懒加载在现代组里是原生import(),legacy 组被转成了System.import()。功能上等价,但有几个细节差异会咬人。

预加载行为不同。原生import()在现代浏览器里可以配合<link rel="modulepreload">提前拉取,legacy 下没有对应机制,SystemJS 是按需加载,首屏之后的切换会明显卡顿。缓解办法是在路由空闲时手动预热:System.import('./pages/xxx')提前触发一次(要在代码里判断window.System是否存在)。

错误处理也不同。原生import()失败会 reject 一个带有明确message的错误,SystemJS 的失败信息可能只有一行 script error,无法定位是哪个 chunk 加载失败。生产环境建议在router.onError里统一兜底,捕获后跳转到错误页或者重试一次,别让用户卡在白屏。

还有一个坑是 chunk 命名。如果用了output.chunkFileNames自定义命名规则,要确保 legacy 组的 chunk 也能匹配上,否则可能被 CDN 的缓存策略误伤——legacy chunk 更新了但 CDN 还在发旧版本,表现是新旧代码混跑,报错莫名其妙。这类问题的排查成本极高,建议在 chunk 名里带上构建时间戳或者内容哈希。

最后分享一个我自己一直在用的习惯:每次升级 Vite 或插件版本后,先跑一遍legacy:check脚本,再在 IE11 里把登录、列表、详情、表单提交这四条主链路手动点一遍。自动化测试覆盖不到 IE11,能靠的就是这份十几分钟的检查清单,比事后线上出问题再回滚划算得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进