Vite + Vue3 兼容低版本浏览器之三——你真的会用 plugin-legacy 吗

3,350 阅读13分钟

欢迎关注公众号:前端成长指南

前言

在解决了前两篇问题之后,又遇到一个白屏的问题(实际上是路由跳转页面不能加载)。通过仔细的阅读文档、分析理解,我对 Vite 打包及其对浏览器兼容的处理有了一个新的认识。

本篇是一个总结,具体的问题不是重点。其中,对文档概念的理解很重要。另外,我借助 ChatGPT 对 plugin-legacy 的整篇文档进行了翻译。我将文档部分的理解放在前面,后面再说具体的问题。

Vite 构建文档

生产构建

Vite build

这个文档英文原文没有包含最低支持 es2015 时支持的浏览器范围(黄框),但是官网中文版如上截图。

有一个最新的 issue:docs: clarify browser compatibility #19253 有修改回来,但是网站还没更新,搞不懂怎么回事。

以上官方文档说明 Vite 可以设置 build.target 进行兼容,但是由于原生 ESM 的支持,只能支持到如上的浏览器范围。而且 Vite 只处理语法转译,不包含任何 polyfill。如果需要 polyfill 可以使用 babel 或其他方式。

通过 @vitejs/plugin-legacy 插件能够解决传统浏览器的兼容问题。其中包含语法转移和 polyfill 相关的设置。

build.target

build.target

内容和 Guide 部分差不多:

  • target 默认是 modules,相当于:['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14']

  • esnext 是包含原生动态导入的更新更小的支持范围。

  • Vite 使用 esbuild 进行语法转换,target 可以是 ES version 如 es2015 或者浏览器版本或者数组。

  • 如果代码包含了不能被安全转换的特性,构建会失败。 我尝试了一下,在 esbuild.github.io/try/ 设置 --target=es2015 转换 import.meta; 会报错:

    "import.meta" is not available in the configured target environment ("es2015") and will be empty [empty-import-meta]

@vitejs/plugin-legacy 文档

Vite 的默认浏览器支持基线是 Native ESMnative ESM dynamic importimport.meta。当构建生产版本时,该插件为不支持这些特性的旧版浏览器提供支持。

默认情况下,该插件会执行以下操作:

  • 为最终构建中的每个 chunk 生成一个对应的 legacy chunk,使用 @babel/preset-env 进行转换,并以 SystemJS 模块 的形式输出(仍支持代码拆分)。

  • 生成一个包含 SystemJS 运行时的 polyfill chunk,并根据指定的浏览器目标和实际使用情况自动注入必要的 polyfills。

  • 在生成的 HTML 中注入 <script nomodule> 标签,以便在不支持现代特性的浏览器中,按需加载 polyfills 和 legacy bundle。

  • 注入 import.meta.env.LEGACY 变量,该变量在旧版生产构建中为 true,在所有其他情况下为 false

使用方法

// vite.config.js
import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11'],
    }),
  ],
}

由于 plugin-legacy 使用 Terser 进行代码压缩,因此必须安装 Terser:

npm add -D terser

选项

targets

如果显式设置,它将传递给 @babel/preset-env 以渲染 legacy chunks

该查询符合 Browserslist compatible。更多详细信息请参考 Browserslist 最佳实践

如果未设置,plugin-legacy 将加载 Browserslist config sources,然后回退到默认值。

modernTargets

如果显式设置,它将传递给 @babel/preset-env 以渲染 modern chunks

该查询符合 Browserslist compatible。更多详细信息请参考 Browserslist 最佳实践

如果未设置,plugin-legacy 将回退到默认值。

polyfills

  • 类型: boolean | string[]
  • 默认值: true

默认情况下,会基于 target 浏览器范围和最终 bundle 中的实际使用情况(通过 @babel/preset-envuseBuiltIns: 'usage' 进行检测)生成 polyfills chunk。

设置为字符串数组可以显式控制要包含的 polyfills。详情请参考 Polyfill Specifiers

设置为 false 可避免生成 polyfills,需自行处理(仍然会生成 legacy chunks 并进行语法转换)。

additionalLegacyPolyfills

  • 类型: string[]

可向 legacy polyfills chunk 添加自定义导入项。由于基于使用情况的 polyfill 检测仅涵盖 ES 语言特性,因此可能需要使用此选项手动指定额外的 DOM API polyfills。

additionalModernPolyfills

  • 类型: string[]

可向 modern polyfills chunk 添加自定义导入项。由于基于使用情况的 polyfill 检测仅涵盖 ES 语言特性,因此可能需要使用此选项手动指定额外的 DOM API polyfills。

modernPolyfills

  • 类型: boolean | string[]
  • 默认值: false

默认为 false。启用此选项将为 modern build 生成单独的 polyfills chunk(适用于支持 ESM 但不支持某些现代特性的浏览器)。

如果设置为字符串数组,可以显式控制要包含的 polyfills,详情请参考 Polyfill Specifiers

如果未设置 modernTargets不推荐使用 true,因为 core-js@3 默认包含大量 polyfills,即使针对 Native ESM 的支持就会注入 15KB 的 polyfills!

如果你不依赖前沿的运行时特性,可以完全避免在现代构建中使用 polyfills。或者,你可以考虑设置 modernTargets,或使用 cdnjs.cloudflare.com/polyfill/ 这样的按需服务,根据浏览器的 User-Agent 仅加载必要的 polyfills(大多数现代浏览器什么都不需要!)。

renderLegacyChunks

  • 类型: boolean
  • 默认值: true

设置为 false 可禁用 legacy chunks 的生成。这个选项只在使用 modernPolyfills 时有用,它允许你使用这个插件只给 modern build 注入 polyfills。

import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    legacy({
      modernPolyfills: [
        /* ... */
      ],
      renderLegacyChunks: false,
    }),
  ],
}

externalSystemJS

  • 类型: boolean
  • 默认值: false

默认为 false。启用此选项将从 polyfills-legacy chunk 中排除 systemjs/dist/s.min.js

renderModernChunks

  • 类型: boolean
  • 默认值: true

设置为 false 仅输出支持所有 target 浏览器的 legacy bundles。

兼容 ESM 但不支持广泛可用特性的浏览器

该插件允许在 modern build 中使用广泛可用的特性,同时在支持 ESM 但不支持这些特性的浏览器(例如旧版 Edge)中回退到 legacy build。

此功能通过注入运行时检查,在必要时加载带有 SystemJS 运行时的 legacy bundle。然而,有以下缺点:

  • 所有 ESM 浏览器都会下载 modern bundle
  • 在不支持这些特性的浏览器中,现代代码会抛出 SyntaxError

以下语法被认为是广泛可用的:

  • dynamic import
  • import.meta
  • async generator

Polyfill 规范

polyfillsmodernPolyfills 可使用以下字符串:

示例

import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    legacy({
      polyfills: ['es.promise.finally', 'es/map', 'es/set'],
      modernPolyfills: ['es.promise.finally'],
    }),
  ],
}

几个问题

看完以上所有相关文档之后,有几个问题需要搞清楚。读者不妨先看问题思考一下。

很多人以为 Vite 解决兼容问题只要用上官方插件就行了,这是错误的理解,也许这样做之后就解决了当时面临的兼容问题,但是理解原理才是根本之道。很多文章也只是讲 plugin-legacy 是什么怎么用,没有讲清楚原理。

理解上面的文档很重要!理解上面的文档很重要!理解上面的文档很重要!

Vite build 兼容和 plugin-legacy 兼容是什么关系?

Vite 使用 esbuild 打包(Vite 的实现原理可以复习一下,这里不再赘述),打包的产物可以根据 target 进行调整。那么既然 Vite 可以打包出更兼容的代码,为什么还需要 plugin-legacy

因为 esbuild 不能兼容到 ES5,其次虽然可以兼容到 es2015,但是仅仅是语法转译,不包含任何 polyfill。而插件是利用 babel 进行兼容处理,能尽可能的兼容我们的 target 浏览器。

至于它们生成的产物是什么关系,是否会冲突,在了解插件原理之后就会清楚。

知道这些之后,就知道 Vite build 的兼容不一定要用插件 plugin-legacy,根据 target 浏览器,自行使用 babel 也能解决。只不过 plugin-legacy 是官方插件,处理兼容问题更加方便。

什么是 polyfill,什么是语法转译?

语法转译主要用于将现代 JavaScript 语法转换为旧版 JavaScript 语法,使其能够在不支持新语法的旧版浏览器中运行。主要解决浏览器不支持 let、const、箭头函数、async/await 等语法的问题。常见的语法转译工具是 Babel,@babel/preset-env 可以根据目标环境自动转换语法。例如,将:

const sum = (a, b) => a + b;

转为:

var sum = function (a, b) {
  return a + b;
};

polyfill 用于填补旧版浏览器缺失的 JavaScript API 或功能。主要解决浏览器不支持 Promise、Object.fromEntries、Array.prototype.flat 等 API 的问题。通常使用 core-js@babel/polyfill 来自动引入 polyfills。例如旧版浏览器不支持 Promise.finally,解决方案是引入 core-js 提供的 polyfill:

import 'core-js/es/promise/finally';

什么是 browserslist?

Browserslist 是一个用于定义项目需要支持的浏览器范围的工具,主要用于前端构建工具(如 Autoprefixer, Babel, ESLint, PostCSS, and Webpack 等)。

通过 browsersl.ist/ 可以在线查看浏览器的支持范围,例如设置 target 为 defaults(等同于 > 0.5%, last 2 versions, not dead),则表示支持以下浏览器范围:

browserslist

支持的浏览器全球市场占有率 88.9%,Chrome 最低支持到 109,如果我使用兼容插件生成兼容代码,Chrome 109 以下不支持。

区分传统浏览器和现代浏览器的标准是什么?

plugin-legacy 的判断标准是全部支持以下 3 个语法就是现代浏览器,否则是传统浏览器。

  • import.meta.url;
  • import("_").catch(()=>1);
  • (async function*(){})().next();

但是有可能,浏览器全部支持这些语法(也就是被判断为现代浏览器),却不支持其他某些被认为是现代浏览器已经广泛支持的特性。比如上一篇提到的展开运算符,WebView 内核为 Chrome 72,但却不支持展开运算符,可能是国产改造的原因。

plugin-legacy 兼容的原理是什么?

生成两套代码,modern build(使用 esbuild 生成,支持 ESM,不带 polyfill),legacy build(使用 babel 降级,转换为 ES5,并添加 core-js polyfills)。

HTML 自动注入 script type="module" 供现代浏览器使用,nomodule 供旧浏览器使用,部分 ESM 浏览器(如 Legacy Edge)用 SystemJS 兼容。

Vite是如何兼容旧版本浏览器的 这篇文章写的很好,可以参考。

plugin-legacy 只是解决传统浏览器兼容吗?

兼容插件给人的感觉就是对传统浏览器提供兼容支持,这样理解没错。但是插件不仅仅对 legacy chunks 产生影响,也能对 modern chunks 产生影响。什么时候需要对 modern chunks 做处理呢?

当我们按照文档简单的使用插件:

// vite.config.js
import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11'],
    }),
  ],
}

打包会生成 legacy chunks,但是此时的 modern chunks 不包含 polyfill。假设有一个浏览器是现代浏览器,但是不支持项目中使用的某个新 API,那么项目就会报错,此时需要对 modern chunks 进行兼容。

plugin-legacy 选项说明

modern chunks 和 legacy chunks 是相对的两种构建产物。插件的设置选项也是两两相对的。

  • targetsmodernTargets 是一对,target 指定 legacy chunks 的语法转译范围,modernTargets 指定针对 modern 浏览器的生成的 modern chunks 语法转译范围。
  • polyfillsmodernPolyfills 是一对。polyfills 指定 legacy chunks 需要的垫片,默认为 true,使用 @babel/preset-env 的 useBuiltIns: 'usage',即按需引入;modernPolyfills 指定 modern chunks 需要的垫片,默认为 false。
  • renderLegacyChunksrenderModernChunks 是一对。renderLegacyChunks 指定是否生成 legacy chunks,默认 true;renderModernChunks 指定是否生成 modern chunks,默认 false。

一个白屏的问题

背景

项目使用 Vite + Vue3,使用 @vitejs/plugin-legacy 兼容低版本浏览器。

项目中的有一个页面白屏,机型为 vivo y5s,后续发现 vivo 多款机型都有问题。

问题排查

首先在开发环境启动,发现页面打不开。

构建,pnpm run preview,打开页面,发现控制台报错:

TypeError: flat is not a function

说明此手机 WebView 是一个较低的版本,Vite 开发环境需要支持较多的新语法。有可能生产加载的是兼容版本代码(为什么是可能?)

打印 WebView navigator,显示内核为 Chrome 68

调试构建 bundle 发现,此 WebView 被判断为现代浏览器,也就是全部支持上面提到的 3 个语法。

那么,页面加载就是 modern chunks,现代构建 Vite 只进行了语法转换,并没有 polyfill。上面分析已经说明。而 flat 方法需要 polyfill。

Vite build 设置 target: es2015,说是能兼容到 Chrome 64(中文官网构建生产版本

flat 兼容性

可以看到浏览器兼容中,最低 Chrome 69 才开始支持 flat

flat browser compatibility

我们的 WebView 是 Chrome 68,刚好不满足。

另外需要注意 flat 在 2020 得到广泛支持,但是 Chrome 69 在 2018 年就开始支持了。

解决

配置:

modernPolyfills: ['es.array.flat'],

配置之前 build,index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>modernPolyfills</title>
    <script type="module" crossorigin src="./js/entry-Z-2BFW7w.js"></script>
    <link rel="modulepreload" crossorigin href="./js/.pnpm-DrVSy4BF.js">
    <link rel="modulepreload" crossorigin href="./js/_plugin-vue_export-helper-Du3cLKUM.js">
    <link rel="modulepreload" crossorigin href="./js/dayofdesign01-BWiILTOM.js">
    <link rel="stylesheet" crossorigin href="./assets/.pnpm-qnPx-A1M.css">
    <link rel="stylesheet" crossorigin href="./assets/_plugin-vue_export-helper-DAw3AN3A.css">
    <link rel="stylesheet" crossorigin href="./assets/entry-Dz9Tb4eM.css">
    <script type="module">import.meta.url;import("_").catch(()=>1);(async function*(){})().next();if(location.protocol!="file:"){window.__vite_is_modern_browser=true}</script>
    <script type="module">!function(){if(window.__vite_is_modern_browser)return;console.warn("vite: loading legacy chunks, syntax error above and the same error below should be ignored");var e=document.getElementById("vite-legacy-polyfill"),n=document.createElement("script");n.src=e.src,n.onload=function(){System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))},document.body.appendChild(n)}();</script>
  </head>
  <body>
    <div id="app"></div>
    <script nomodule>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
    <script nomodule crossorigin id="vite-legacy-polyfill" src="./js/polyfills-legacy-DXmxMS5x.js"></script>
    <script nomodule crossorigin id="vite-legacy-entry" data-src="./js/entry-legacy-EhgMgmo3.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
  </body>
</html>

配置 modernPolyfills 之后,build:

<!doctype html>
<html lang="en">
  <head>
    <script type="module" crossorigin src="./js/polyfills-D3FdfBm4.js"></script>
    <meta charset="UTF-8" />
  </head>
</html>

区别只是多了最上面的一行 script module polyfill 文件。是针对 modern bundle 的 polyfill。

总结

  • 首先要理解 Vite build 和兼容插件所做的工作,它们之间的区别。Vite build 会依据 target 对构建产物进行一定范围的语法转译。插件 plugin-legacy 是为了更大程度的兼容低版本浏览器。
  • 语法转译(transform)和 polyfill 是不同的概念。Vite build(esbuild)只对语法进行转译(transform),不包含任何垫片(polyfill)。
  • plugin-legacy 不仅可以生成、设置 legacy chunks,而且可以设置 modern chunks。对二者是分开设置的。分别可以设置语法转译和 polyfill 的范围。
  • target 设置需要兼容的浏览器范围,遵循 browserslist 规范。Vite build target 设置 esbuild 转译的范围;plugin-legacy target 设置 legacy chunks 需要转译的语法范围,modernTargets 设置 modern chunks 需要转译的语法范围。
  • esbuild 并不是所有语法都会转译,比如 BigInt 是 es2020 规范,但是 esbuild 不会转译,在 target 设置 es2020 时,原样输出;在 target 设置 es2020 以下时,build 报错。具体有哪些语法,可以参考文档。
  • 知道 plugin-legacy 对现代浏览器的判断条件是什么,所谓的现代浏览器并非绝对支持所有的 widely-available 语法。
  • 国产浏览器/WebView可能会存在:被插件判断为现代浏览器,但是对某些 widely-available features 却不支持,导致报错和页面白屏。 也就是它加载了 modern chunks,其中只是 esbuild 的转译。

参考