作者:京東科技 孫凱
一、前言
對前端開發者來說,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
四、總結
不囉嗦,直接上載入過程完整的流程圖,一百句話也不如一張圖直觀。
最後,實名感謝各位小夥伴的觀看,如果能點個贊就更好了 ?。