作者:京東科技 孫凱
一、前言
對前端開發者來説,Vite 應該不算陌生了,它是一款基於 nobundle 和 bundleless 思想誕生的前端開發與構建工具,官網對它的概括和期待只有一句話:“下一代的前端工具鏈”。
Vite 最早的版本由尤雨溪發佈於3年前,經歷了3年多的發展,Vite 也已逐漸迭代成熟,它的穩定性、擴展性、周邊生態足以在生產環境中支撐各種業務場景的落地。但是關於Vite的優劣勢分析我們就戛然而止,不在深入展開了,這不是本文的重點。
本文的重點在於探究 Vite 如何實現兼容低版本瀏覽器,這一切還得從那個陽光明媚的午後説起🤔。
二、那個午後
本着嚐鮮的態度,我在某一個項目中用了 Vite,當時還是3.x.x的版本,跟着文檔配置,從項目啓動到項目構建,一路都很“德芙”(縱享絲滑),在經歷了 Vite 帶來的短暫新鮮感後,就一直沉浸在業務模塊的開發中了,因為在 Vite 剛發佈後的那段時間曾看過相關原理解析,是基於瀏覽器原生的模塊化能力按需構建BALABALA等,所以後來 Vite 的這種新鮮感對我而言並沒有保持多久。
但直到有天下午我開始打包提測,審查頁面元素後發現構建產物居然跟以往 webpack 的產物竟然有點不一樣,在好奇心的驅使下,於是我開始嘗試解謎。
三、跟webpack構建產物到底哪裏不一樣?
1. 準備工作
為了能更好的對比兩者產物究竟有什麼區別,我們首先要確保我們的業務代碼基本一致,不一致的地方僅僅是使用不同工具( vite 和 webpack)進行構建,這樣才能排除最大幹擾因素。
於是我們分別使用最新版的 Vite 和 webpack 初始化了兩個頁面,為了做作區分,兩個頁面的僅標題和標題背景不一致,他們在瀏覽器中渲染後的分別長這個樣子:
2. 構建工具版本説明
• Vite:v4.1.4
• webpack:v5.75.0
3. 構建工具配置項説明
• Vite (非常簡單,啥也沒有)
// 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(太多了,也比較常規,就不在這裏貼出來全部配置項了,僅在這裏配置好跟 Vite 一樣的需要兼容到最低的瀏覽器版本)
// .browserslistrc
ios >= 9
android >= 4.2
> 1%
至此,準備工作完畢,讓我們看看兩者的構建產物吧。
4. 構建產物
從產物的命名中,我們就能多少看出些許區別,webpack的產物比較簡單,中規中矩,而 Vite 的 JS 文件不但比 webpack 多,而且部分文件命名中還多了一個單詞:legacy,百度翻譯對它的解釋是:遺產;遺贈財物;遺留;後遺症;(計算機系統或產品)已停產的,通過翻譯,或許你可以猜出來,名字中帶 legacy 的文件大概率就是瀏覽器的兼容文件,那麼事實到底是不是這樣呢?
如果你足夠細心,其實你應該可以從上面 Vite 的配置項代碼中嗅到一絲端倪,在 Vite 的配置文件中,有一個名為 @vitejs/plugin-legacy 的插件,它的名字也包含 legacy,Vite 官網中對這個插件的解釋是這樣的:
“傳統瀏覽器可以通過插件 @vitejs/plugin-legacy 來支持,它將自動生成傳統版本的 chunk 及與其相對應 ES 語言特性方面的 polyfill。兼容版的 chunk 只會在不支持原生 ESM 的瀏覽器中進行按需加載。”
也就是説,這個插件它不但提供了低版本瀏覽器的兼容能力,還提供了檢測是否支持原生 ESM 的能力。那麼這個插件都做了哪些事?
主要是以下三點:
- 為最每個生成的 ESM 模塊化方式的 chunk 也對應生成一個 legacy chunk,同時使用 @babel/preset-env 轉換(沒錯,Vite 的內部集成了 Babel),生成一個 SystemJS 模塊,關於 SystemJS 可以看點擊這裏查看,它在瀏覽器中實現了模塊化,用來加載有依賴關係的各個 chunk。
- 生成 polyfill 包,包含 SystemJS 的運行時,同時包含由要兼容的目標瀏覽器版本和代碼中的高級語法產生的 polyfill。
- 生成 <script nomodule> 標籤,並注入到 HTML 文件中,用來在不兼容 ESM 的老舊瀏覽器中加載 polyfill 和 legacy chunk。
如此可見,Vite 兼容低版本瀏覽器的能力就是來自於 @babel/preset-env 無疑了,都是生成 polyfill 和語法轉換, 但是這不就和 webpack 一樣了麼,事實是 Vite 又幫我們多做了一層,那就是上面反覆提到的原生瀏覽器模塊化能力 ESM。
5. Vite 的原生模塊化能力
我們看看 Vite 打包後HTML中的內容,內容較多,我分開講,先看 head 標籤中的內容
<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,記住這個文件名,後面會用到)和 CSS,JS資源使用了 ESM 的模塊化方式加載,等等,嗯?JS 居然使用了 ESM ?如果當前瀏覽器不兼容 ESM,那這段 JS豈不是永遠不會執行?
其實這就是 ESM 模塊化的能力之一,對於攜帶 type="module" 這個屬性的 script 標籤,不支持 ESM 的瀏覽器不會執行內部代碼,所以報錯也就不存在了,與之對應的 script 上還有 nomodule 這個屬性,支持ESM的瀏覽器會忽略攜帶這個屬性的 script,可以防止某些兼容邏輯在高版本瀏覽器執行,這兩個屬性組合使用就是為了決定瀏覽器在面對未知版本瀏覽器時的代碼執行策略,我們畫個簡易流程圖理解一下:
繼續往下看。
接下來的兩段內聯 script 標籤中的內容很關鍵,我們先看第一段代碼,這段代碼暫且命名為代碼A:
<script type="module">
import.meta.url;
import("_").catch(() => 1);
async function* g() { }
window.__vite_is_modern_browser = true;
</script>
期初我看上面這段代碼的時候,我就想:這寫的都是些個什麼東西!前三行都是高級ES語法,部分瀏覽器根本不兼容好嘛,這都能寫上去,真不怕報錯和白屏?
其實要注意 script 標籤上 type="module" 這個屬性,ESM模塊化的好處之一就是,它在處理報錯信息的時候,不像普通 script 一樣會把錯誤拋到模塊外部,內部出錯也不會阻塞後續邏輯的執行和頁面渲染,接下來我們驗證一下這個觀點,直接上代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>title</title>
</head>
<body>
<script type="module">
throw new Error('拋出一個錯誤')
console.log('這段代碼執行了沒')
</script>
<script type="module">
console.log('代碼執行了')
</script>
<script>
console.log('代碼又又又執行了')
</script>
</body>
</html>
執行結果如下:
先不管代碼結果的輸出順序,我們在這隻看輸出結果,與上述結論一致的,即錯誤影響了內部模塊,並中斷了後續的代碼邏輯,而外部不受影響。
在 Vite 生成的 HTML 中這樣做的好處就是為了檢測瀏覽器對相關語法的支持程度,如果模塊中的語法不支持,就會停止執行;如果支持,那麼同時打上一個標記,也就是上述示例代碼A的倒數第二行——通過在 window 上設置全局變量(因為ESM模塊中的變量影響不到外部)window.\_\_vite\_is\_modern\_browser = true,來標識當前瀏覽器是否為一個“現代瀏覽器”,是否支持的某些語法特性(import.meta、動態導入、異步生成器),這樣可以使 Vite 後續更準確的判斷該加載那些 JS。
於是接下來我們就看到了下面這段代碼:
<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>
它內部判斷了 window.\_\_vite\_is\_modern\_browser 這個全局標識是否存在,如果存在,説明上一個模塊中的代碼執行沒有問題,直接退出;如果不存在,説明當前瀏覽器不是一個“現代瀏覽器”,那就該加載和執行兼容文件了,於是可以看到這段代碼的後半段,Vite 使用 SystemJs 加載了帶有 legacy 標記的文件。
到了這裏還沒有結束, 雖然 Vite 在個別情況下加載了兼容文件,但如果你仔細看上述代碼,會發現整個加載邏輯是放在擁有 type="module" 這個屬性的 script 中的,在前面已經闡述過了, type="module" 在低版本瀏覽器是不會執行的,換句話説就是,低版本瀏覽器的兼容文件並不會被加載。於是 Vite 為了低版本瀏覽器能正常執行業務邏輯,又做了如下操作——
以下代碼來自 VIte 打包後 body 標籤中的內容:
<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 標籤,先加載了 polyfills 文件,確保後續代碼中使用的API能正確執行,再通過 SystemJs 加載入口文件執行後續邏輯,至此, Vite 兼容舊版本瀏覽器的邏輯算是基本講述完畢了。
6. “魔鬼藏在細節中”
縱觀Vite的整個加載流程,粗一看沒有什麼大問題,但是經不起推敲,我們再來捋一捋,看看還發生了什麼。
第一步,Vite 在頁面最開始加載了 CSS 和 JS,加載 JS 的方式是使用 ESM
第二步,Vite 判斷了現代瀏覽器的兼容性,如果是現代瀏覽器,則不執行 nomodule 中的代碼,也不會使用 SystemJs 加載 legacy 文件,反之亦然。
第三部,Vite 對低版本瀏覽器使用 nomodule 的 script 標籤,加載和執行 legacy 文件。
等等,你有沒有發現,第一步和第二步有些問題?
我們前面已經説過了,在第二步中,Vite 根據 window.\_\_vite\_is\_modern\_browser 處理了是否加載 legacy 文件的邏輯,但是這裏的代碼是包裹在 type="module" 這個屬性的 script 中的!問題就出現在這裏!
我們想象一個場景:總有那麼一部分瀏覽器支持 ESM 的同時,又不支持 _import.meta.url; import("_").catch(() => 1); async function g() { }_* 這三種語法之一,這是必然的,因為語法誕生的時間不一致。
這也就導致了一個 Vite 的行為:在支持 ESM、同時不支持高級上述三種語法任意一種的時候,window.\_\_vite\_is\_modern\_browser 為 false,Vite 通過 SystemJs 加載了 legacy 文件,但也因為當前瀏覽器支持 ESM,致使 Vite 在第一步中通過 ESM 加載的 JS 是重複加載!
也就是説,Vite 在這種情況下同時加載了原生模塊化的文件和兼容文件!
但更值得思考的還在後面:不管是原生模塊化的文件,還是兼容文件,他們對頁面的處理邏輯是一致的,因為文件的同時加載,這會不會導致頁面執行兩次相同的邏輯?
答案是:不會。
Vite 是知道這種情況的,並且已經處理過了,它處理的手段你肯定會覺得很眼熟,就在整個 ESM 文件入口的前幾行(也就是本文構建產物中的 index-a712caef.js )——
function __vite_legacy_guard() {
import.meta.url;
import("_").catch(() => 1);
async function* g() {};
};
(function polyfill() {
// 後續其他邏輯不在這裏貼了,可以使用 Vite 自行打包查看
...
})();
...
它聲明瞭一個函數,函數內部包含了高版本的語法,Vite 充分利用了 JS 語法邊解析邊執行的特性:如果當前環境不支持高版本語法,那就在語法解析階段報錯就好了,直接暴力阻止後續邏輯的執行,因為使用了原生模塊化的能力,反正錯誤也不會拋給外面,對頁面沒有什麼影響!
怎麼樣,這才是完整的 Vite 兼容方案,不得不説,真是有很多細節值得學習和思考。
對於重複加載 ESM 文件, @vitejs/plugin-legacy 是承認缺點存在的,這個插件在 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
大概意思就是:它認為主流瀏覽器對這三種語法是廣泛認可的,換句話也就是説,Vite 的目標其實還是絕大部分現代瀏覽器,太過低端的已經不考慮了。。。
最後放出 @vitejs/plugin-legacy 的 README 地址: https://github.com/vitejs/vite/tree/main/packages/plugin-legacy#readme
四、總結
不囉嗦,直接上加載過程完整的流程圖,一百句話也不如一張圖直觀。
最後,實名感謝各位小夥伴的觀看,如果能點個贊就更好了 🙌。