如何使用 Vite 进行语法降级和 Polyfill | 您所在的位置:网站首页 › corejs怎样兼容ie11 › 如何使用 Vite 进行语法降级和 Polyfill |
如何使用 Vite 进行语法降级和 Polyfill 可能很多人都觉得 Vite 是一个现代前端构建工具,应该在现代浏览器中使用,放到各种语法特性都缺失的低版本浏览器(如 ie 11)就不适用了。这种观念对不对呢?是不对的。 通过 Vite 构建我们完全可以兼容各种低版本浏览器,打包出既支持现代(Modern)浏览器又支持旧版(Legacy)浏览器的产物。 场景复现 以下是在线上环境的一些报错 旧版浏览器的语法兼容问题主要分两类: **语法降级问题。**比如某些浏览器不支持箭头函数,我们就需要将其转换为 function(){} 语法**Polyfill 缺失问题。**Polyfill 本身可以翻译为垫片,也就是为浏览器提前注入一些 API 的实现代码,如 Object.entries 方法的实现,这样可以保证产物可以正常使用这些 API,防止报错这两类问题本质上是通过前端的编译工具链(如 Babel)以及 JS 的基础 Polyfill 库(如 corejs)来解决的,不会跟具体的构建工具所绑定。 也就是说,对于这些本质的解决方案,在其它的构建工具(如 Webpack)能使用,在 Vite 中也完全可以使用。 构建工具考虑的仅仅是如何将这些底层基础设施接入到构建过程的问题,自己并不需要提供底层的解决方案,正所谓术业有专攻,把专业的事情交给专业的工具去做。 接下来的部分,我就来带你熟悉一下所谓专业的工具到底有哪些,以及如何使用这些工具。 底层工具链 编译时和运行时工具 解决上述提到的两类语法兼容问题,主要需要用到两方面的工具,分别包括: 编译时工具。代表工具有 @babel/preset-env 和 @babel/plugin-transform-runtime运行时基础库。代表库包括 core-js 和 regenerator-runtime编译时工具的作用是在代码编译阶段进行语法降级以及**添加 polyfill 代码的引用语句,**例如: javascriptimport "core-js/modules/es6.set.js"import "core-js/modules/es6.set.js"由于这些工具只是编译阶段用到,运行时并不需要,我们需要将其放入 devDependencies 中 而运行时基础库是根据 ESMAScript 官方语言规范提供各种 Polyfill 实现代码。主要包括 core-js 和 regenerator-runtime 两个基础库。 不过在 babel 中也会有一些上层的封装,包括: @babel/polyfill@babel/runtime@babel/runtime-corejs2@babel/runtime-corejs3这些各种运行时库看似眼花缭乱,其实都是 core-js 和 regenerator-runtime 不同版本的封装罢了。(@babel/runtime 是个特例,不包含 core-js 的 Polyfill)这类库是项目运行时必须要使用到的,因此一定要放到 package.json 中的 dependencies 中! 工具的具体使用 首先安装一些必要的依赖: pnpm i @babel/cli @babel/core @babel/preset-envpnpm i @babel/cli @babel/core @babel/preset-env解释一下各个依赖的作用: @babel/cli:babel 官方的脚手架工具,很适合我们练习用@babel/core:babel 核心编译库@babel/preset-env:babel 的预设工具集,基本为 babel 必装的库接着新建 src 目录,在目录下增加 index.js 文件: javascriptconst func = async () => { console.log(12123) } Promise.resolve().finally();const func = async () => { console.log(12123) } Promise.resolve().finally();示例代码中既包含了高级语法也包含现代浏览器的 API,正好可以针对语法降级和 Polyfill 注入两个功能进行测试。 接下来新建 .babelrc.json 即 babel 的配置文件,内容如下: json{ "presets": [ [ "@babel/preset-env", { // 指定兼容的浏览器版本 "targets": { "ie": "11" }, // 基础库 core-js 的版本,一般指定为最新的大版本 "corejs": 3, // Polyfill 注入策略,后文详细介绍 "useBuiltIns": "usage", // 不将 ES 模块语法转换为其他模块语法 "modules": false } ] ] }{ "presets": [ [ "@babel/preset-env", { // 指定兼容的浏览器版本 "targets": { "ie": "11" }, // 基础库 core-js 的版本,一般指定为最新的大版本 "corejs": 3, // Polyfill 注入策略,后文详细介绍 "useBuiltIns": "usage", // 不将 ES 模块语法转换为其他模块语法 "modules": false } ] ] }其中有两个比较关键的配置:targets 和 usage targets 我们可以通过 targets 参数指定要兼容的浏览器版本,有两种配置方法。 对象配置: javascript{ "targets": { "ie": "11" } }{ "targets": { "ie": "11" } }用 Browserslist 配置语法: javascript{ // ie 不低于 11 版本,全球超过 0.5% 使用,且还在维护更新的浏览器 "targets": "ie >= 11, > 0.5%, not dead" }{ // ie 不低于 11 版本,全球超过 0.5% 使用,且还在维护更新的浏览器 "targets": "ie >= 11, > 0.5%, not dead" }Browserslist 是一个帮助我们设置目标浏览器的工具,不光是 Babel 用到,其他的编译工具如 postcss-preset-env、autoprefix 中都有所应用。 对于 Browserslist 的配置内容,你既可以放到 Babel 这种特定工具当中,也可以在 package.json 中通过 browserslist 声明: javascript// package.json { "browserslist": "ie >= 11" }// package.json { "browserslist": "ie >= 11" }或者通过 .browserslistrc 进行声明: javascript// .browserslistrc ie >= 11// .browserslistrc ie >= 11在实际的项目中,一般我们可以将使用下面这些最佳实践集合来描述不同的浏览器类型,减轻配置负担: javascript// 现代浏览器 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// 现代浏览器 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 deaduseBuiltIns 接下来我们来看另外一个重要的配置:useBuiltIns,它决定了添加 Polyfill 的策略,默认是 false,即不添加任何的 Polyfill。 你可以手动将 useBuiltIns 配置为 "entry" 或者 "usage",接下来我们看看这两个配置究竟有什么区别。 首先你可以将这个字段配置为 entry,需要注意的是,entry 配置规定你必须在入口文件手动添加一行这样的代码: javascript// index.js 开头加上 import 'core-js';// index.js 开头加上 import 'core-js';接着在终端执行下面的命令进行 Babel 编译: npx babel src --out-dir distnpx babel src --out-dir dist产物输出在 dist 目录中,你可以去观察一下产物的代码: 但这个配置有一个问题,即无法做到按需导入,上面的产物代码其实有大部分的 Polyfill 的代码我们并没有用到。 接下来我们试试 useBuiltIns: "usage" 这个按需导入的配置,改动配置后执行编译命令,查看产物输出: 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 -Spnpm 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 作如下的配置: @babel/preset-env 是用到的基础 plugin 的预设,@babel/plugin-transform-runtime 是对 plugin 的扩展 javascript{ "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 } ] ] }{ "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 } ] ] }对比一下最终产物结果: 另外,transform-runtime 方案引用的基础库也发生了变化,不再是直接引入 core-js 和 regenerator-runtime,而是引入 @babel/runtime-corejs3 好,介绍完了 Babel 语法降级与 Polyfill 注入的底层方案,接下来我们来看看如何在 Vite 中利用这些方案来解决低版本浏览器的兼容性问题。 Vite 语法降级与 Polyfill 注入 Vite 官方已经为我们封装好了一个开箱即用的方案:@vitejs/plugin-legacy,我们可以基于它来解决项目语法的浏览器兼容问题。 这个插件内部同样使用 @babel/preset-env 以及 core-js 等一系列基础库来进行语法降级和 Polyfill 注入。 因此对于上文所介绍的底层工具链的掌握是必要的,否则无法理解插件内部所做的事情,真正遇到问题时往往会不知所措。 插件使用 安装一下官方的插件: pnpm i @vitejs/plugin-legacy -Dpnpm i @vitejs/plugin-legacy -D随后在项目中使用它: javascript// vite.config.ts import legacy from '@vitejs/plugin-legacy'; import { defineConfig } from 'vite' export default defineConfig({ plugins: [ // 省略其它插件 legacy({ // 设置目标浏览器,browserslist 配置语法 targets: ['ie >= 11'], }) ] })// vite.config.ts import legacy from '@vitejs/plugin-legacy'; import { defineConfig } from 'vite' export default defineConfig({ plugins: [ // 省略其它插件 legacy({ // 设置目标浏览器,browserslist 配置语法 targets: ['ie >= 11'], }) ] })我们同样可以通过 targets 指定目标浏览器,这个参数在插件内部会透传给 @babel/preset-env 在引入插件后,我们可以尝试执行 npm run build 对项目进行打包,可以看到如下的产物信息: 让我们继续观察一下 index.html 的产物内容: htmlDOCTYPE html> Vite App 兼容 iOS nomodule 特性的 polyfill,省略具体代码 System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src')) DOCTYPE html> Vite App 兼容 iOS nomodule 特性的 polyfill,省略具体代码 System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))通过官方的 legacy 插件,Vite 会分别打包出 Modern 模式和 Legacy 模式的产物,然后将两种产物插入同一个 HTML 里面,Modern 产物被放到 type="module" 的 script 标签中,而 Legacy 产物则被放到带有 nomodule 的 script 标签中。 浏览器对于这两种 script 标签的加载策略如下图所示: 当然,在具体的代码语法层面,插件还需要考虑语法降级和 Polyfill 按需注入的问题,接下来我们就来分析一下 Vite 的官方 legacy 插件是如何解决这些问题的。 官方的 legacy 插件是一个相对复杂度比较高的插件,直接看源码可能会很难理解,这里我梳理了画了一张简化后的流程图,接下来我们就根据这张流程图来一一拆解这个插件在各个钩子阶段到底做了些什么。 接着,在 renderChunk 阶段,插件会对 Legacy 模式产物进行语法转译和 Polyfill 收集,值得注意的是,这里并不会真正注入 Polyfill,而仅仅只是收集 Polyfill: javascript{ renderChunk(raw, chunk, opts) { // 1. 使用 babel + @babel/preset-env 进行语法转换与 Polyfill 注入 // 2. 由于此时已经打包后的 Chunk 已经生成 // 这里需要去掉 babel 注入的 import 语句,并记录所需的 Polyfill // 3. 最后的 Polyfill 代码将会在 generateBundle 阶段生成 } }{ renderChunk(raw, chunk, opts) { // 1. 使用 babel + @babel/preset-env 进行语法转换与 Polyfill 注入 // 2. 由于此时已经打包后的 Chunk 已经生成 // 这里需要去掉 babel 注入的 import 语句,并记录所需的 Polyfill // 3. 最后的 Polyfill 代码将会在 generateBundle 阶段生成 } }由于场景是应用打包,所以这里直接使用 @babel/preset-env 的 useBuiltIns: 'usage' 来进行全局 Polyfill 的收集是比较标准的做法。 回到 Vite 构建的主流程中,接下来会进入 generateChunk 钩子阶段,现在 Vite 会对之前收集到的 Polyfill 进行统一的打包,实现也比较精妙,主要逻辑集中在 buildPolyfillChunk 函数中: javascript// 打包 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,保证产物写到磁盘中 }// 打包 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,保证产物写到磁盘中 }因此,你可以理解为这个函数的作用即通过 vite build 对 renderChunk 中收集到的 polyfill 代码进行打包,生成一个单独的 chunk: 需要注意的是,polyfill chunk 中除了包含一些 core-js 和 regenerator-runtime 的相关代码,也包含了 SystemJS 的实现代码,你可以将其理解为 ESM 的加载器,实现了在旧版浏览器下的模块加载能力。 现在我们已经能够拿到 Legacy 模式的产物文件名及 Polyfill Chunk 的文件名,那么就可以通过 transformIndexHtml 钩子来将这些产物插入到 HTML 的结构中: javascript{ transformIndexHtml(html) { // 1. 插入 Polyfill chunk 对应的 标签 // 2. 插入 Legacy 产物入口文件对应的 标签 } }{ transformIndexHtml(html) { // 1. 插入 Polyfill chunk 对应的 标签 // 2. 插入 Legacy 产物入口文件对应的 标签 } }Vite 官方的 legacy 插件的主要原理就介绍到这里,为了方便大家理解,讲解的过程中忽略了一些与主流程关联不大的细节,最后给大家补充一下: 当插件参数中开启了 modernPolyfills 选项时,Vite 也会自动对 Modern 模式的产物进行 Polyfill 收集,并单独打包成 polyfills-modern.js 的 chunk,原理和 Legacy 模式下处理 Polyfill 一样。Sarari 10.1 版本不支持 nomodule,为此需要单独引入一些补丁代码,点击查看。部分低版本 Edge 浏览器虽然支持 type="module",但不支持动态 import,为此也需要插入一些补丁代码,针对这种情况下降级使用 Legacy 模式的产物。 |
CopyRight 2018-2019 实验室设备网 版权所有 |