Vite 是怎么兼容老舊瀏覽器的?你以為僅僅依靠 Babel?
作者:京東科技 孫凱
一、前言
對(duì)前端開發(fā)者來(lái)說(shuō),Vite 應(yīng)該不算陌生了,它是一款基于 nobundle 和 bundleless 思想誕生的前端開發(fā)與構(gòu)建工具,官網(wǎng)對(duì)它的概括和期待只有一句話:“下一代的前端工具鏈”。
Vite 最早的版本由尤雨溪發(fā)布于3年前,經(jīng)歷了3年多的發(fā)展,Vite 也已逐漸迭代成熟,它的穩(wěn)定性、擴(kuò)展性、周邊生態(tài)足以在生產(chǎn)環(huán)境中支撐各種業(yè)務(wù)場(chǎng)景的落地。但是關(guān)于Vite的優(yōu)劣勢(shì)分析我們就戛然而止,不在深入展開了,這不是本文的重點(diǎn)。
本文的重點(diǎn)在于探究 Vite 如何實(shí)現(xiàn)兼容低版本瀏覽器,這一切還得從那個(gè)陽(yáng)光明媚的午后說(shuō)起。
二、那個(gè)午后
本著嘗鮮的態(tài)度,我在某一個(gè)項(xiàng)目中用了 Vite,當(dāng)時(shí)還是3.x.x的版本,跟著文檔配置,從項(xiàng)目啟動(dòng)到項(xiàng)目構(gòu)建,一路都很“德芙”(縱享絲滑),在經(jīng)歷了 Vite 帶來(lái)的短暫新鮮感后,就一直沉浸在業(yè)務(wù)模塊的開發(fā)中了,因?yàn)樵?Vite 剛發(fā)布后的那段時(shí)間曾看過(guò)相關(guān)原理解析,是基于瀏覽器原生的模塊化能力按需構(gòu)建BALABALA等,所以后來(lái) Vite 的這種新鮮感對(duì)我而言并沒(méi)有保持多久。
但直到有天下午我開始打包提測(cè),審查頁(yè)面元素后發(fā)現(xiàn)構(gòu)建產(chǎn)物居然跟以往 webpack 的產(chǎn)物竟然有點(diǎn)不一樣,在好奇心的驅(qū)使下,于是我開始嘗試解謎。
三、跟webpack構(gòu)建產(chǎn)物到底哪里不一樣?
1. 準(zhǔn)備工作
為了能更好的對(duì)比兩者產(chǎn)物究竟有什么區(qū)別,我們首先要確保我們的業(yè)務(wù)代碼基本一致,不一致的地方僅僅是使用不同工具( vite 和 webpack)進(jìn)行構(gòu)建,這樣才能排除最大干擾因素。
于是我們分別使用最新版的 Vite 和 webpack 初始化了兩個(gè)頁(yè)面,為了做作區(qū)分,兩個(gè)頁(yè)面的僅標(biāo)題和標(biāo)題背景不一致,他們?cè)跒g覽器中渲染后的分別長(zhǎng)這個(gè)樣子:
2. 構(gòu)建工具版本說(shuō)明
?Vite:v4.1.4
?webpack:v5.75.0
3. 構(gòu)建工具配置項(xiàng)說(shuō)明
?Vite (非常簡(jiǎn)單,啥也沒(méi)有)
// vite.config.js
import { defineConfig } from 'vite'
import legacy from '@vitejs/plugin-legacy'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue(),
legacy({
targets: ['ios >= 9', 'android >= 4.2', '> 1%']
})
],
server: {
host: '127.0.0.1'
},
build: {
minify: false
}
})
?webpack(太多了,也比較常規(guī),就不在這里貼出來(lái)全部配置項(xiàng)了,僅在這里配置好跟 Vite 一樣的需要兼容到最低的瀏覽器版本)
// .browserslistrc
ios >= 9
android >= 4.2
> 1%
至此,準(zhǔn)備工作完畢,讓我們看看兩者的構(gòu)建產(chǎn)物吧。
4. 構(gòu)建產(chǎn)物
從產(chǎn)物的命名中,我們就能多少看出些許區(qū)別,webpack的產(chǎn)物比較簡(jiǎn)單,中規(guī)中矩,而 Vite 的 JS 文件不但比 webpack 多,而且部分文件命名中還多了一個(gè)單詞:legacy,百度翻譯對(duì)它的解釋是:遺產(chǎn);遺贈(zèng)財(cái)物;遺留;后遺癥;(計(jì)算機(jī)系統(tǒng)或產(chǎn)品)已停產(chǎn)的,通過(guò)翻譯,或許你可以猜出來(lái),名字中帶 legacy 的文件大概率就是瀏覽器的兼容文件,那么事實(shí)到底是不是這樣呢?
如果你足夠細(xì)心,其實(shí)你應(yīng)該可以從上面 Vite 的配置項(xiàng)代碼中嗅到一絲端倪,在 Vite 的配置文件中,有一個(gè)名為 @vitejs/plugin-legacy 的插件,它的名字也包含 legacy,Vite 官網(wǎng)中對(duì)這個(gè)插件的解釋是這樣的:
“傳統(tǒng)瀏覽器可以通過(guò)插件 @vitejs/plugin-legacy 來(lái)支持,它將自動(dòng)生成傳統(tǒng)版本的 chunk 及與其相對(duì)應(yīng) ES 語(yǔ)言特性方面的 polyfill。兼容版的 chunk 只會(huì)在不支持原生 ESM 的瀏覽器中進(jìn)行按需加載?!?/p>
也就是說(shuō),這個(gè)插件它不但提供了低版本瀏覽器的兼容能力,還提供了檢測(cè)是否支持原生 ESM 的能力。那么這個(gè)插件都做了哪些事?
主要是以下三點(diǎn):
1.為最每個(gè)生成的 ESM 模塊化方式的 chunk 也對(duì)應(yīng)生成一個(gè) legacy chunk,同時(shí)使用 @babel/preset-env 轉(zhuǎn)換(沒(méi)錯(cuò),Vite 的內(nèi)部集成了 Babel),生成一個(gè) SystemJS 模塊,關(guān)于 SystemJS 可以看點(diǎn)擊這里查看,它在瀏覽器中實(shí)現(xiàn)了模塊化,用來(lái)加載有依賴關(guān)系的各個(gè) chunk。
2.生成 polyfill 包,包含 SystemJS 的運(yùn)行時(shí),同時(shí)包含由要兼容的目標(biāo)瀏覽器版本和代碼中的高級(jí)語(yǔ)法產(chǎn)生的 polyfill。
3.生成 <script nomodule> 標(biāo)簽,并注入到 HTML 文件中,用來(lái)在不兼容 ESM 的老舊瀏覽器中加載 polyfill 和 legacy chunk。
如此可見,Vite 兼容低版本瀏覽器的能力就是來(lái)自于 @babel/preset-env 無(wú)疑了,都是生成 polyfill 和語(yǔ)法轉(zhuǎn)換, 但是這不就和 webpack 一樣了么,事實(shí)是 Vite 又幫我們多做了一層,那就是上面反復(fù)提到的原生瀏覽器模塊化能力 ESM。
5. Vite 的原生模塊化能力
我們看看 Vite 打包后HTML中的內(nèi)容,內(nèi)容較多,我分開講,先看 head 標(biāo)簽中的內(nèi)容
<head>
<script type="module" crossorigin src="/assets/index-a712caef.js"></script>
<link rel="stylesheet" href="/assets/index-d853141a.css" />
<script type="module">
import.meta.url;
import("_").catch(() => 1);
async function* g() { }
window.__vite_is_modern_browser = true;
</script>
<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>
</head>
代碼的前兩行加載了入口 JS (index-a712caef.js,記住這個(gè)文件名,后面會(huì)用到)和 CSS,JS資源使用了 ESM 的模塊化方式加載,等等,嗯?JS 居然使用了 ESM ?如果當(dāng)前瀏覽器不兼容 ESM,那這段 JS豈不是永遠(yuǎn)不會(huì)執(zhí)行?
其實(shí)這就是 ESM 模塊化的能力之一,對(duì)于攜帶 type="module" 這個(gè)屬性的 script 標(biāo)簽,不支持 ESM 的瀏覽器不會(huì)執(zhí)行內(nèi)部代碼,所以報(bào)錯(cuò)也就不存在了,與之對(duì)應(yīng)的 script 上還有 nomodule 這個(gè)屬性,支持ESM的瀏覽器會(huì)忽略攜帶這個(gè)屬性的 script,可以防止某些兼容邏輯在高版本瀏覽器執(zhí)行,這兩個(gè)屬性組合使用就是為了決定瀏覽器在面對(duì)未知版本瀏覽器時(shí)的代碼執(zhí)行策略,我們畫個(gè)簡(jiǎn)易流程圖理解一下:
繼續(xù)往下看。
接下來(lái)的兩段內(nèi)聯(lián) script 標(biāo)簽中的內(nèi)容很關(guān)鍵,我們先看第一段代碼,這段代碼暫且命名為代碼A:
<script type="module">
import.meta.url;
import("_").catch(() => 1);
async function* g() { }
window.__vite_is_modern_browser = true;
</script>
期初我看上面這段代碼的時(shí)候,我就想:這寫的都是些個(gè)什么東西!前三行都是高級(jí)ES語(yǔ)法,部分瀏覽器根本不兼容好嘛,這都能寫上去,真不怕報(bào)錯(cuò)和白屏?
其實(shí)要注意 script 標(biāo)簽上 type="module" 這個(gè)屬性,ESM模塊化的好處之一就是,它在處理報(bào)錯(cuò)信息的時(shí)候,不像普通 script 一樣會(huì)把錯(cuò)誤拋到模塊外部,內(nèi)部出錯(cuò)也不會(huì)阻塞后續(xù)邏輯的執(zhí)行和頁(yè)面渲染,接下來(lái)我們驗(yàn)證一下這個(gè)觀點(diǎn),直接上代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>title</title>
</head>
<body>
<script type="module">
throw new Error('拋出一個(gè)錯(cuò)誤')
console.log('這段代碼執(zhí)行了沒(méi)')
</script>
<script type="module">
console.log('代碼執(zhí)行了')
</script>
<script>
console.log('代碼又又又執(zhí)行了')
</script>
</body>
</html>
執(zhí)行結(jié)果如下:
先不管代碼結(jié)果的輸出順序,我們?cè)谶@只看輸出結(jié)果,與上述結(jié)論一致的,即錯(cuò)誤影響了內(nèi)部模塊,并中斷了后續(xù)的代碼邏輯,而外部不受影響。
在 Vite 生成的 HTML 中這樣做的好處就是為了檢測(cè)瀏覽器對(duì)相關(guān)語(yǔ)法的支持程度,如果模塊中的語(yǔ)法不支持,就會(huì)停止執(zhí)行;如果支持,那么同時(shí)打上一個(gè)標(biāo)記,也就是上述示例代碼A的倒數(shù)第二行——通過(guò)在 window 上設(shè)置全局變量(因?yàn)镋SM模塊中的變量影響不到外部)window.__vite_is_modern_browser = true,來(lái)標(biāo)識(shí)當(dāng)前瀏覽器是否為一個(gè)“現(xiàn)代瀏覽器”,是否支持的某些語(yǔ)法特性(import.meta、動(dòng)態(tài)導(dǎo)入、異步生成器),這樣可以使 Vite 后續(xù)更準(zhǔn)確的判斷該加載那些 JS。
于是接下來(lái)我們就看到了下面這段代碼:
<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>
它內(nèi)部判斷了window.__vite_is_modern_browser 這個(gè)全局標(biāo)識(shí)是否存在,如果存在,說(shuō)明上一個(gè)模塊中的代碼執(zhí)行沒(méi)有問(wèn)題,直接退出;如果不存在,說(shuō)明當(dāng)前瀏覽器不是一個(gè)“現(xiàn)代瀏覽器”,那就該加載和執(zhí)行兼容文件了,于是可以看到這段代碼的后半段,Vite 使用 SystemJs 加載了帶有 legacy 標(biāo)記的文件。
到了這里還沒(méi)有結(jié)束, 雖然 Vite 在個(gè)別情況下加載了兼容文件,但如果你仔細(xì)看上述代碼,會(huì)發(fā)現(xiàn)整個(gè)加載邏輯是放在擁有 type="module" 這個(gè)屬性的 script 中的,在前面已經(jīng)闡述過(guò)了, type="module" 在低版本瀏覽器是不會(huì)執(zhí)行的,換句話說(shuō)就是,低版本瀏覽器的兼容文件并不會(huì)被加載。于是 Vite 為了低版本瀏覽器能正常執(zhí)行業(yè)務(wù)邏輯,又做了如下操作——
以下代碼來(lái)自 VIte 打包后 body 標(biāo)簽中的內(nèi)容:
<script nomodule crossorigin id="vite-legacy-polyfill" src="/assets/polyfills-legacy-d5e90708.js"></script>
<script nomodule crossorigin id="vite-legacy-entry" data-src="/assets/index-legacy-4aa958d8.js">
System.import(
document.getElementById("vite-legacy-entry").getAttribute("data-src")
);
</script>
可以看到,在低版本瀏覽器中 Vite 使用了帶有 nomodule 屬性的 script 標(biāo)簽,先加載了 polyfills 文件,確保后續(xù)代碼中使用的API能正確執(zhí)行,再通過(guò) SystemJs 加載入口文件執(zhí)行后續(xù)邏輯,至此, Vite 兼容舊版本瀏覽器的邏輯算是基本講述完畢了。
6. “魔鬼藏在細(xì)節(jié)中”
縱觀Vite的整個(gè)加載流程,粗一看沒(méi)有什么大問(wèn)題,但是經(jīng)不起推敲,我們?cè)賮?lái)捋一捋,看看還發(fā)生了什么。
第一步,Vite 在頁(yè)面最開始加載了 CSS 和 JS,加載 JS 的方式是使用 ESM
第二步,Vite 判斷了現(xiàn)代瀏覽器的兼容性,如果是現(xiàn)代瀏覽器,則不執(zhí)行 nomodule 中的代碼,也不會(huì)使用 SystemJs 加載 legacy 文件,反之亦然。
第三部,Vite 對(duì)低版本瀏覽器使用 nomodule 的 script 標(biāo)簽,加載和執(zhí)行 legacy 文件。
等等,你有沒(méi)有發(fā)現(xiàn),第一步和第二步有些問(wèn)題?
我們前面已經(jīng)說(shuō)過(guò)了,在第二步中,Vite 根據(jù)window.__vite_is_modern_browser 處理了是否加載 legacy 文件的邏輯,但是這里的代碼是包裹在 type="module" 這個(gè)屬性的 script 中的!問(wèn)題就出現(xiàn)在這里!
我們想象一個(gè)場(chǎng)景:總有那么一部分瀏覽器支持 ESM 的同時(shí),又不支持 import.meta.url; import("_").catch(() => 1); async function* g() { } 這三種語(yǔ)法之一,這是必然的,因?yàn)檎Z(yǔ)法誕生的時(shí)間不一致。
這也就導(dǎo)致了一個(gè) Vite 的行為:在支持 ESM、同時(shí)不支持高級(jí)上述三種語(yǔ)法任意一種的時(shí)候,window.__vite_is_modern_browser 為 false,Vite 通過(guò) SystemJs 加載了 legacy 文件,但也因?yàn)楫?dāng)前瀏覽器支持 ESM,致使 Vite 在第一步中通過(guò) ESM 加載的 JS 是重復(fù)加載!
也就是說(shuō),Vite 在這種情況下同時(shí)加載了原生模塊化的文件和兼容文件!
但更值得思考的還在后面:不管是原生模塊化的文件,還是兼容文件,他們對(duì)頁(yè)面的處理邏輯是一致的,因?yàn)槲募耐瑫r(shí)加載,這會(huì)不會(huì)導(dǎo)致頁(yè)面執(zhí)行兩次相同的邏輯?
答案是:不會(huì)。
Vite 是知道這種情況的,并且已經(jīng)處理過(guò)了,它處理的手段你肯定會(huì)覺(jué)得很眼熟,就在整個(gè) ESM 文件入口的前幾行(也就是本文構(gòu)建產(chǎn)物中的 index-a712caef.js )——
function __vite_legacy_guard() {
import.meta.url;
import("_").catch(() => 1);
async function* g() {};
};
(function polyfill() {
// 后續(xù)其他邏輯不在這里貼了,可以使用 Vite 自行打包查看
...
})();
...
它聲明了一個(gè)函數(shù),函數(shù)內(nèi)部包含了高版本的語(yǔ)法,Vite 充分利用了 JS 語(yǔ)法邊解析邊執(zhí)行的特性:如果當(dāng)前環(huán)境不支持高版本語(yǔ)法,那就在語(yǔ)法解析階段報(bào)錯(cuò)就好了,直接暴力阻止后續(xù)邏輯的執(zhí)行,因?yàn)槭褂昧嗽K化的能力,反正錯(cuò)誤也不會(huì)拋給外面,對(duì)頁(yè)面沒(méi)有什么影響!
怎么樣,這才是完整的 Vite 兼容方案,不得不說(shuō),真是有很多細(xì)節(jié)值得學(xué)習(xí)和思考。
對(duì)于重復(fù)加載 ESM 文件, @vitejs/plugin-legacy 是承認(rèn)缺點(diǎn)存在的,這個(gè)插件在 README 中原文是這樣解釋的:
The legacy plugin offers a way to use widely-available features natively in the modern build, while falling back to the legacy build in browsers with native ESM but without those features supported (e.g. Legacy Edge). This feature works by injecting a runtime check and loading the legacy bundle with SystemJs runtime if needed. There are the following drawbacks:
Modern bundle is downloaded in all ESM browsers
Modern bundle throws SyntaxError in browsers without those features support
The following syntax are considered as widely-available:
dynamic import
import.meta
async generator
大概意思就是:它認(rèn)為主流瀏覽器對(duì)這三種語(yǔ)法是廣泛認(rèn)可的,換句話也就是說(shuō),Vite 的目標(biāo)其實(shí)還是絕大部分現(xiàn)代瀏覽器,太過(guò)低端的已經(jīng)不考慮了。。。
最后放出 @vitejs/plugin-legacy 的 README 地址:https://github.com/vitejs/vite/tree/main/packages/plugin-legacy#readme
四、總結(jié)
不啰嗦,直接上加載過(guò)程完整的流程圖,一百句話也不如一張圖直觀。
最后,實(shí)名感謝各位小伙伴的觀看,如果能點(diǎn)個(gè)贊就更好了 。