Vite中的语法降级与polyfill

6,936 阅读6分钟

来自掘金小册 深入浅出 Vite 的学习实践与总结

Babel语法降级与polyfill

编译时工具

  • @babel/preset-env:babel 的预设工具集,基本为 babel 必装的库
  • @babel/plugin-transform-runtime:polyfill 的另一种解决方案,可以使用非全局的方式导入 polyfill,对于工具函数,也是将其变为了 import 语句进行导入

用于在代码编译阶段进行语法降级,以及添加 polyfill 代码的引用语句,例如:

import "core-js/modules/es6.set.js"

运行时工具

其中包含两个基础库

  • core-js
  • regenerator-runtime

polyfill就是依赖于这两个库来运行的

babel 对上面两个库又进行了封装,就有了下面 4 个库:

  • @babel/polyfill
  • @babel/runtime
  • @babel/runtime-corejs2
  • @babel/runtime-corejs3

除了@babel/runtime中不包含 core-js,这些库都是 core-js 和 regenerator-runtime 不同版本的封装

core-js 有 3 种产物:

  1. core-js:全局 polyfill,以全局变量存在的形式
  2. core-js-pure:不会把 polyfill 注入到全局环境,可以按需引入,例如 @babel/runtime-corejs3
  3. core-js-bundle:打包好的产物,包含所有 polyfill

使用示例

源代码仓库地址: babel-study

@babel/preset-env解决方案

先安装一下以下的包:

  • @babel/cli:babel 官方脚手架工具
  • @babel/core:babel 核心编译库
  • @babel/preset-env:babel 的预设工具集

针对下面一段代码进行编译,进行语法降级以及注入 polyfill:

// src/index.js
async function foo() {
  console.log('金小钗');
}

new Promise(() => {
  console.log('金小钗');
});

添加 babel 配置文件:

// .babelrc.json

// 使用 useBuiltIns 配置完成 polyfill 注入
{
  "presets": [
    [
      "@babel/preset-env",
      {
        // 浏览器兼容版本
        "targets": {
          "ie": "11"
        },
        /*
          使用 browserslist语法
          含义:ie 不低于 11 版本,全球超过 0.5% 使用,且还在维护更新的浏览器
        */
        // "targets": "ie >= 11, > 0.5%, not dead"
        
        // core.js 的版本
        "corejs": 3,
        
        /*
          polyfill 注入策略,可以设置为 entry | usage | false
          默认值:false,即不添加 polyfill
          
          入口文件需要主动导入 core-js,该策略会全量导入 polyfill
        */
        "useBuiltIns": "entry",
        
        // 设置为不将 ES 模块语法转换为其他模块语法
        "modules": false
      }
    ]
  ]
}

由于useBuiltIns的值为entry,所以需要在入口文件导入core-js

// src/index.js

// useBuiltIns = entry 时使用,全量导入 polyfill
import 'core-js';
// 省略内容...

添加一个构建脚本并执行:"build": "babel src --out-dir dist"。产物文件中会出现一些工具函数以及导入全部的 polyfill:


按需导入 polyfill

在上面的产物中,导入了全部 polyfill,产生了很多无用的代码,下面修改配置来进行按需导入 polyfill:

// .babelrc.json
{
  "presets": [
    [
      "@babel/preset-env",
      {
        // 按需导入 polyfill
        "useBuiltIns": "usage",
      }
    ]
  ]
}

并注释掉导入 polyfill 的语句:

// src/index.js

// import 'core-js';

构建一下,发现少了好几百行代码,因为实现了 polyfill 的按需导入:


总结

上述两种方式已经达到了语句降级与 polyfill 的注入,即可以全量导入也可以按需导入,但也存在一些问题:

  1. 因为是在全局环境注入 polyfill,那么开发第三方工具库时,就会存在对全局空间造成污染的问题
  2. 工具函数(例如asyncGeneratorStep_asyncToGenerator)会在每个文件都生成,导致代码冗余

transform-runtime解决方案

使用 transform-runtime 可以很好地解决上面提到的问题。

先安装一下以下的包:

  • @babel/plugin-transform-runtime:编译时工具,用来转换语法和注入 polyfill
  • @babel/runtime-corejs3:封装了基础库 core-js 和 regenerator-runtime

修改 babel 的配置文件:

// .babelrc.json

// 使用 plugin-transform-runtime 来完成 polyfill 注入
{
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3
      }
    ]
  ],
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "ie": "11"
        },
        "corejs": 3,
        // 使用了 transform-runtime 后,
        // 就得关闭 useBuiltIns 配置
        "useBuiltIns": false,
        "modules": false
      }
    ]
  ]
}

执行构建脚本,可以看到无论是 polyfill 还是工具函数,都是通过具体的模块进行导入了,而不是全局注入了:

Vite语法降级与Polyfill注入

在 Vite 中如果要进行语法降级与 polyfill 注入,则可以使用 Vite 提供的官方插件 @vitejs/plugin-legacy,开箱即用。

其底层实现原理也是基于 babel 来完成的,使用了 @babel/preset-env 的 useBuiltIns 配置进行语法降级与注入polyfill。

基本使用

代码仓库

安装插件:

npm i @vitejs/plugin-legacy@1.7.1

配置插件:

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

export default defineConfig({
  plugins: [
    legacy({
      // 设置目标浏览器,browserslist 配置语法
      targets: ['ie >= 11'],
    }),
  ],
});

build 一下,多了 3 个文件:

而在构建后的 index.html 中,有着支持现代与旧浏览器的两种解决方案:

<!-- index.html -->
<html lang="en">
  <head>
    <!-- 针对现代浏览器的产物 -->
    <script type="module" crossorigin src="/assets/index.de2cfa0f.js"></script>
    <link rel="modulepreload" href="/assets/vendor.f47da054.js" />
    <link rel="stylesheet" href="/assets/index.cd9c0392.css" />
    <script type="module"><!-- 动态导入处理代码 --></script>
  </head>
  <body>
    <!-- 针对旧浏览器的产物 -->
    <script nomodule>
      <!-- 这里的代码是用来兼容 iOS nomodule 特性的 polyfill -->
    </script>
    <script
      nomodule
      id="vite-legacy-polyfill"
      src="/assets/polyfills-legacy.5ee4502b.js"
    ></script>
    <script
      nomodule
      id="vite-legacy-entry"
      data-src="/assets/index-legacy.39c9a687.js"
    >
      System.import(
        document.getElementById('vite-legacy-entry').getAttribute('data-src')
      );
    </script>
  </body>
</html>

插件执行原理

简化后的插件执行流程图:


  1.  configResolved 的主要实现代码:
// 在这里添加一个 output 配置,用于构建出一个 system 产物
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 || {}]
}

  1. renderChunk 的主要实现代码:
renderChunk(raw, chunk, opts) {
  // 1. 使用 babel + @babel/preset-env 进行语法转换与 Polyfill 注入
  // 2. 由于此时已经打包后的 Chunk 已经生成
  //   这里需要去掉 babel 注入的 import 语句,并记录所需的 Polyfill
  // 3. 最后的 Polyfill 代码将会在 generateBundle 阶段生成
}

由于场景是应用打包,这里直接使用 @babel/preset-env 的useBuiltIns: 'usage'来进行全局 Polyfill 的收集是比较标准的做法。


  1. generateBundle 的主要实现代码:
// 对之前收集到的 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,保证产物写到磁盘中
}

可以理解为这个函数的作用是通过 renderChunk 中收集到 polyfill 代码进行打包,生成一个单独的 chunk:

需要注意的是,polyfill chunk 中除了包含一些 core-js 和 regenerator-runtime 的相关代码,也包含了 SystemJS 的实现代码,在上面的 index.html 文件中,针对旧浏览器就使用到了 System 来加载模块。

  1.  transformIndexHtml 的主要实现代码:
{
  transformIndexHtml(html) {
    // 1. 插入 Polyfill chunk 对应的 <script nomodule> 标签
    // 2. 插入 Legacy 产物入口文件对应的 <script nomodule> 标签
  }
}

到了这里,整个流程就结束了。

注意事项

  • 当插件参数中开启了modernPolyfills 选项时,Vite 也会自动对 Modern 模式的产物进行 polyfill 收集,并单独打包成 polyfills-modern.js 的 chunk,原理和 Legacy 模式下处理 Polyfill 一样
  • Safari 10.1 版本不支持 nomodule,为此需要单独引入一些补丁代码,点击查看
  • 部分低版本 Edge 浏览器虽然支持 type="module",但不支持动态 import,为此也需要插入一些补丁代码,针对这种情况下降级使用 Legacy 模式的产物