攜程商旅在 Remix 模塊預(yù)加載中的探索與優(yōu)化實(shí)踐
一、引言
去年,商旅大前端團(tuán)隊(duì)成功嘗試將部分框架從 Next.js 遷移至 Remix,并顯著提升了用戶體驗(yàn)。由于 Remix 2.0 版本在構(gòu)建工具和新功能方面進(jìn)行了大量升級(jí),我們最近決定將 Remix 1.0 版本同步升級(jí)至 Remix 2.0。
目前,商旅內(nèi)部所有 Remix 項(xiàng)目在瀏覽器中均已使用 ESModule 進(jìn)行資源加載。
在 Remix 1.0 版本中,我們通過在服務(wù)端渲染生成靜態(tài)資源模板時(shí),為所有靜態(tài)資源動(dòng)態(tài)添加 CDN 前綴來處理資源加載。簡單來說,原始的 HTML 模板如下:
<script type="module">
import init from 'assets/contact-GID3121.js';
init();
// ...
</script>
在每次生成模板時(shí),我們會(huì)動(dòng)態(tài)地為所有生成的 <script> 標(biāo)簽注入一個(gè)變量:
<script type="module">
import init from 'https://aw-s.tripcdn.com/assets/contact-GID3121.js';
init();
// ...
</script>
在 Remix 1.0 下,這種工作機(jī)制完全滿足我們的需求,并且運(yùn)行良好。然而,在商旅從 Remix 1.0 升級(jí)到 2.0 后,我們發(fā)現(xiàn)某些 CSS 資源以及 modulePreload 的 JavaScript 資源仍然會(huì)出現(xiàn) 404 響應(yīng)。
經(jīng)過排查,我們發(fā)現(xiàn)這些 404 響應(yīng)的靜態(tài)資源實(shí)際上是由于在 1.0 中動(dòng)態(tài)注入的 Host 變量未能生效。實(shí)際上,這是由于 Remix 升級(jí)過程中,Vite 對(duì)懶加載模塊(DynamicImport)進(jìn)行了優(yōu)化,以提升頁面性能。然而,這些優(yōu)化手段在我們的應(yīng)用中使用動(dòng)態(tài)加載的靜態(tài)資源時(shí)引發(fā)了新的問題。
這篇文章總結(jié)了我們?cè)?Vite Preload 改造過程中的經(jīng)驗(yàn)和心得。接下來,我們將從表象、實(shí)現(xiàn)和源碼三個(gè)層面詳細(xì)探討 Vite 如何優(yōu)化 DynamicImport,并進(jìn)一步介紹攜程商旅在 Remix 升級(jí)過程中對(duì) Vite DynamicImport 所進(jìn)行的定制化處理。
二、模塊懶加載
懶加載(Lazy Load)是前端開發(fā)中的一種優(yōu)化技術(shù),旨在提高頁面加載性能和用戶體驗(yàn)。
懶加載的核心思想是在用戶需要時(shí)才加載某些資源,而不是在頁面初始加載時(shí)就加載所有資源。
除了常見的圖像懶加載、路由懶加載外還有一種模塊懶加載。
廣義上路由懶加載可以看作是模塊懶加載的子集。
所謂的模塊懶加載表示頁面中某些模塊通過動(dòng)態(tài)導(dǎo)入(dynamic import),在需要時(shí)才加載某些 JavaScript 模塊。
目前絕大多數(shù)前端構(gòu)建工具中會(huì)將通過動(dòng)態(tài)導(dǎo)入的模塊進(jìn)行 split chunk(代碼拆分),只有在需要時(shí)才加載這些模塊的 JavaScript、Css 等靜態(tài)資源內(nèi)容。
我們以 React 來看一個(gè)簡單的例子:
import React, { Suspense, useState } from 'react';
// 出行人組件,立即加載
const Travelers = () => {
return <div>出行人組件內(nèi)容</div>;
};
// 聯(lián)系人組件,使用 React.lazy 進(jìn)行懶加載
const Contact = React.lazy(() => import('./Contact'));
const App = () => {
const [showContact, setShowContact] = useState(false);
const handleAddContactClick = () => {
setShowContact(true);
};
return (
<div>
<h1>頁面標(biāo)題</h1>
{/* 出行人組件立即展示 */}
<Travelers />
{/* 添加按鈕 */}
<button onClick={handleAddContactClick}>添加聯(lián)系人</button>
{/* 懶加載的聯(lián)系人組件 */}
{showContact && (
<Suspense fallback={<div>加載中...</div>}>
<Contact />
</Suspense>
)}
</div>
);
};
export default App;
在這個(gè)示例中:
1)Travelers 組件是立即加載并顯示的。
2)Contact 組件使用 React.lazy 以及 DynamicImport 進(jìn)行懶加載,只有在用戶點(diǎn)擊“添加聯(lián)系人”按鈕后才會(huì)加載并顯示。
3)Suspense 組件用于在懶加載的組件尚未加載完成時(shí)顯示一個(gè)回退內(nèi)容(例如“加載中...”)。
這樣,當(dāng)用戶點(diǎn)擊“添加聯(lián)系人”按鈕時(shí),Contact 組件才會(huì)被動(dòng)態(tài)加載并顯示在頁面上。
所以上邊的 Contact 聯(lián)系人組件就可以認(rèn)為是被當(dāng)前頁面懶加載。
三、Vite 中如何處理懶加載模塊
3.1 表象
首先,我們先來通過 npm create vite@latest react -- --template react 創(chuàng)建一個(gè)基于 Vite 的 React 項(xiàng)目。
無論是 React、Vue 還是源生 JavaScript ,LazyLoad 并不局限于任何框架。這里為了方便演示我就使用 React 來舉例。
想跳過簡單 Demo 編寫環(huán)節(jié)的小伙伴可以直接在這里 Clone Demo 倉庫。
首先我們通過 vite 命令行初始化一個(gè)代碼倉庫,之后我們對(duì)新建的代碼稍做修改:
// app.tsx
import React, { Suspense } from 'react';
// 聯(lián)系人組件,使用 React.lazy 進(jìn)行懶加載
const Contact = React.lazy(() => import('./components/Contact'));
// 這里的手機(jī)號(hào)組件、姓名組件可以忽略
// 實(shí)際上特意這么寫是為了利用 dynamicImport 的 splitChunk 特性
// vite 在構(gòu)建時(shí)對(duì)于 dynamicImport 的模塊是會(huì)進(jìn)行 splitChunk 的
// 自然 Phone、Name 模塊在構(gòu)建時(shí)會(huì)被拆分為兩個(gè) chunk 文件
const Phone = () => import('./components/Phone');
const Name = () => import('./components/Name');
// 防止被 sharking
console.log(Phone,'Phone')
console.log(Name,'Name')
const App = () => {
return (
<div>
<h1>頁面標(biāo)題</h1>
{/* 懶加載的聯(lián)系人組件 */}
(
<Suspense fallback={<div>加載中...</div>}>
<Contact />
</Suspense>
)
</div>
);
};
export default App;
// components/Contact.tsx
import React from 'react';
import Phone from './Phone';
import Name from './Name';
const Contact = () => {
return <div>
<h3>聯(lián)系人組件</h3>
{/* 聯(lián)系人組件依賴的手機(jī)號(hào)以及姓名組件 */}
<Phone></Phone>
<Name></Name>
</div>;
};
export default Contact;
// components/Phone.tsx
import React from 'react';
const Phone = () => {
return <div>手機(jī)號(hào)組件</div>;
};
export default Phone;
// components/Name.tsx
import React from 'react';
const Name = () => {
return <div>姓名組件</div>;
};
export default Name;
上邊的 Demo 中,我們?cè)?App.tsx 中編寫了一個(gè)簡單的頁面。
頁面中使用 dynamicImport 引入了三個(gè)模塊,分別為:
- Contact 聯(lián)系人模塊
- Phone 手機(jī)模塊
- Name 姓名模塊
對(duì)于 App.tsx 中動(dòng)態(tài)引入的 Phone 和 Name 模塊,我們僅僅是利用動(dòng)態(tài)引入實(shí)現(xiàn)在構(gòu)建時(shí)的代碼拆分。所以這里在 App.tsx 中完全可以忽略這兩個(gè)模塊。
簡單來說 vite 中對(duì)于使用 dynamicImport 的模塊會(huì)在構(gòu)建時(shí)單獨(dú)拆分成為一個(gè) chunk (通常情況下一個(gè) chunk 就代表構(gòu)建后的一個(gè)單獨(dú) javascript 文件)。
重點(diǎn)在于 App.tsx 中動(dòng)態(tài)引入的聯(lián)系人模塊,我們?cè)?App.tsx 中使用 dynamicImport 引入了 Contact 模塊。
同時(shí),在 Contact 模塊中我們又引入了 Phone、Name 兩個(gè)模塊。
由于在 App.tsx 中我們已經(jīng)使用 dynamicImport 將 Phone 和 Name 強(qiáng)制拆分為兩個(gè)獨(dú)立的 chunk,自然 Contact 在構(gòu)建時(shí)相當(dāng)于依賴了 Phone 和 Name 這兩個(gè)模塊的獨(dú)立 chunk。
此時(shí),讓我們直接直接運(yùn)行 npm run build && npm run start 啟動(dòng)應(yīng)用(只有在生產(chǎn)構(gòu)建模式下才會(huì)開啟對(duì)于 dynamicImport 的優(yōu)化)。
打開瀏覽器后我們會(huì)發(fā)現(xiàn),在 head 標(biāo)簽中多出了 3 個(gè) moduleprealod 的標(biāo)簽:
簡單來說,這便是 vite 對(duì)于使用 dynamicImport 異步引入模塊的優(yōu)化方式,默認(rèn)情況下 Vite 會(huì)對(duì)于使用 dynamicImport 的模塊收集當(dāng)前模塊的依賴進(jìn)行 modulepreload 進(jìn)行預(yù)加載。
當(dāng)然,對(duì)于 dynamicImport,Vite 內(nèi)部不僅對(duì) JS 模塊進(jìn)行了依賴模塊的 modulePreload 處理,同時(shí)也對(duì) dynamicImport 依賴的 CSS 模塊進(jìn)行了處理。
不過,讓我們先聚焦于 dynamicImport 的 JavaScript 優(yōu)化上吧。
3.2 機(jī)制
在探討源碼實(shí)現(xiàn)之前,我們先從編譯后的 JavaScript 代碼角度來分析 Vite 對(duì) DynamicImport 模塊的優(yōu)化方式。
首先,我們先查看瀏覽器 head 標(biāo)簽中的 modulePreload 標(biāo)簽可以發(fā)現(xiàn),聲明 modulePreload 的資源分別為 Contact 聯(lián)系人模塊、Phone 手機(jī)模塊以及 Name 姓名模塊。
從表現(xiàn)上來說,簡單來說可以用這段話來描述 Vite 內(nèi)部對(duì)于動(dòng)態(tài)模塊加載的優(yōu)化:
項(xiàng)目在構(gòu)建時(shí),首次訪問頁面會(huì)加載 App.tsx 對(duì)應(yīng)生成的 chunk 代碼。App.tsx 對(duì)應(yīng)的頁面在渲染時(shí)會(huì)依賴 dynamicImport 的 Contact 聯(lián)系人模塊。
此時(shí),Vite 內(nèi)部會(huì)對(duì)使用 dynamicImport 的 Contact 進(jìn)行模塊分析,發(fā)現(xiàn)聯(lián)系人模塊內(nèi)部又依賴了 Phone 以及 Name 兩個(gè) chunk。
簡單來講我們網(wǎng)頁的 JS 加載順序可以用下面的草圖來表達(dá):
App.tsx 構(gòu)建后生成的 Js Assets 會(huì)使用 dynamicImport 加載 Contact.tsx 對(duì)應(yīng)的 assets。
而 Contact.tsx 中則依賴了 name-[hash].jsx 和 phone-[hash].js 這兩個(gè) assets。
Vite 對(duì)于 App.tsx 進(jìn)行靜態(tài)掃描時(shí),會(huì)發(fā)現(xiàn)內(nèi)部存在使用 dynamicImport 語句。此時(shí)會(huì)將所有的 dynamicImport 語句進(jìn)行優(yōu)化處理,簡單來說會(huì)將
const Contact = React.lazy(() => import('./components/Contact'))
轉(zhuǎn)化為
const Contact = React.lazy(() =>
__vitePreload(() => import('./Contact-BGa5hZNp.js'), __vite__mapDeps([0, 1, 2])))
- __vitePreload 是構(gòu)建時(shí) Vite 對(duì)于使用 dynamicImport 插入的動(dòng)態(tài)加載的優(yōu)化方法。
- __vite__mapDeps([0, 1, 2])則是傳遞給 __vitePreload 的第二個(gè)參數(shù),它表示當(dāng)前動(dòng)態(tài)引入的 dynamicImport 包含的所有依賴 chunk,也就是 Contact(自身)、Phone、Name 三個(gè) chunk。
簡單來說 __vitePreload 方法首先會(huì)將 __vite__mapDeps 中所有依賴的模塊使用 document.head.appendChild 插入所有 modulePreload 標(biāo)簽之后返回真實(shí)的 import('./Contact-BGa5hZNp.js')。
最終,Vite 通過該方式就會(huì)對(duì)于動(dòng)態(tài)模塊內(nèi)部引入的所有依賴模塊實(shí)現(xiàn)對(duì)于動(dòng)態(tài)加載模塊的深層 chunk 使用 modulePreload 進(jìn)行動(dòng)態(tài)加載優(yōu)化。
3.3 原理
在了解了 Vite 內(nèi)部對(duì) modulePreload 的基本原理和機(jī)制后,接下來我們將深入探討 Vite 的構(gòu)建過程,詳細(xì)分析其動(dòng)態(tài)模塊加載優(yōu)化的實(shí)現(xiàn)方式。
Vite 在構(gòu)建過程中對(duì) dynamicImport 的優(yōu)化主要體現(xiàn)在 vite:build-import-analysis 插件中。
接下來,我們將通過分析 build-import-analysis 插件的源代碼,深入探討 Vite 是如何實(shí)現(xiàn) modulePreload 優(yōu)化的。
3.3.1 掃描/替換模塊代碼 - transform
首先,build-import-analysis 中存在 transform hook。
簡單來說,transform 鉤子用于在每個(gè)模塊被加載和解析之后,對(duì)模塊的代碼進(jìn)行轉(zhuǎn)換。這個(gè)鉤子允許我們對(duì)模塊的內(nèi)容進(jìn)行修改或替換,比如進(jìn)行代碼轉(zhuǎn)換、編譯、優(yōu)化等操作。
上邊我們講過,vite 在構(gòu)建時(shí)掃描源代碼中的所有 dynamicImport 語句同時(shí)會(huì)將所有 dynamicImport 語句增加 __vitePreload的 polyfill 優(yōu)化方法。
所謂的 transform Hook 就是掃描每一個(gè)模塊,對(duì)于模塊內(nèi)部的所有 dynamicImport 使用 __vitePreload 進(jìn)行包裹。
export const isModernFlag = `__VITE_IS_MODERN__`
export const preloadMethod = `__vitePreload`
export const preloadMarker = `__VITE_PRELOAD__`
export const preloadBaseMarker = `__VITE_PRELOAD_BASE__`
//...
// transform hook 會(huì)在每一個(gè) module 上執(zhí)行
async transform(source, importer) {
// 如果當(dāng)前模塊是在 node_modules 中,且代碼中沒有任何動(dòng)態(tài)導(dǎo)入語法,則直接返回。不進(jìn)行任何處理
if (isInNodeModules(importer) && !dynamicImportPrefixRE.test(source)) {
return
}
// 初始化 es-module-lexer
await init
let imports: readonly ImportSpecifier[] = []
try {
// 調(diào)用 es-module-lexer 的 parse 方法,解析 source 中所有的 import 語法
imports = parseImports(source)[0]
} catch (_e: unknown) {
const e = _e as EsModuleLexerParseError
const { message, showCodeFrame } = createParseErrorInfo(
importer,
source,
)
this.error(message, showCodeFrame ? e.idx : undefined)
}
if (!imports.length) {
return null
}
// environment.config.consumer === 'client' && !config.isWorker && !config.build.lib
// 客戶端構(gòu)建時(shí)(非 worker 非 lib 模式下)為 true
const insertPreload = getInsertPreload(this.environment)
// when wrapping dynamic imports with a preload helper, Rollup is unable to analyze the
// accessed variables for treeshaking. This below tries to match common accessed syntax
// to "copy" it over to the dynamic import wrapped by the preload helper.
// 當(dāng)使用預(yù)加載助手(__vite_preload 方法)包括 dynamicImport 時(shí)
// Rollup 無法分析訪問的變量是否存在 TreeShaking
// 下面的代碼主要作用為試圖匹配常見的訪問語法,以將其“復(fù)制”到由預(yù)加載幫助程序包裝的動(dòng)態(tài)導(dǎo)入中
// 例如:`const {foo} = await import('foo')` 會(huì)被轉(zhuǎn)換為 `const {foo} = await __vitePreload(async () => { const {foo} = await import('foo');return {foo}}, ...)` 簡單說就是防止直接使用 __vitePreload 包裹后的模塊無法被 TreeShaking
const dynamicImports: Record<
number,
{ declaration?: string; names?: string }
> = {}
if (insertPreload) {
let match
while ((match = dynamicImportTreeshakenRE.exec(source))) {
/* handle `const {foo} = await import('foo')`
*
* match[1]: `const {foo} = await import('foo')`
* match[2]: `{foo}`
* import end: `const {foo} = await import('foo')_`
* ^
*/
if (match[1]) {
dynamicImports[dynamicImportTreeshakenRE.lastIndex] = {
declaration: `const ${match[2]}`,
names: match[2]?.trim(),
}
continue
}
/* handle `(await import('foo')).foo`
*
* match[3]: `(await import('foo')).foo`
* match[4]: `.foo`
* import end: `(await import('foo'))`
* ^
*/
if (match[3]) {
let names = /\.([^.?]+)/.exec(match[4])?.[1] || ''
// avoid `default` keyword error
if (names === 'default') {
names = 'default: __vite_default__'
}
dynamicImports[
dynamicImportTreeshakenRE.lastIndex - match[4]?.length - 1
] = { declaration: `const {${names}}`, names: `{ ${names} }` }
continue
}
/* handle `import('foo').then(({foo})=>{})`
*
* match[5]: `.then(({foo})`
* match[6]: `foo`
* import end: `import('foo').`
* ^
*/
const names = match[6]?.trim()
dynamicImports[
dynamicImportTreeshakenRE.lastIndex - match[5]?.length
] = { declaration: `const {${names}}`, names: `{ ${names} }` }
}
}
let s: MagicString | undefined
const str = () => s || (s = new MagicString(source))
let needPreloadHelper = false
// 遍歷當(dāng)前模塊中的所有 import 引入語句
for (let index = 0; index < imports.length; index++) {
const {
s: start,
e: end,
ss: expStart,
se: expEnd,
d: dynamicIndex,
a: attributeIndex,
} = imports[index]
// 判斷是否為 dynamicImport
const isDynamicImport = dynamicIndex > -1
// 刪除 import 語句的屬性導(dǎo)入
// import { someFunction } from './module.js' with { type: 'json' };
// => import { someFunction } from './module.js';
if (!isDynamicImport && attributeIndex > -1) {
str().remove(end + 1, expEnd)
}
// 如果當(dāng)前 import 語句為 dynamicImport 且需要插入預(yù)加載助手
if (
isDynamicImport &&
insertPreload &&
// Only preload static urls
(source[start] === '"' ||
source[start] === "'" ||
source[start] === '`')
) {
needPreloadHelper = true
// 獲取本次遍歷到的 dynamic 的 declaration 和 names
const { declaration, names } = dynamicImports[expEnd] || {}
// 之后的邏輯就是純字符串拼接,將 __vitePreload(preloadMethod) 變量進(jìn)行拼接
// import ('./Phone.tsx')
// __vitePreload(
// async () => {
// const { Phone } = await import('./Phone.tsx')
// return { Phone }
// },
// __VITE_IS_MODERN__ ? __VITE_PRELOAD__ : void 0,
// )
if (names) {
/* transform `const {foo} = await import('foo')`
* to `const {foo} = await __vitePreload(async () => { const {foo} = await import('foo');return {foo}}, ...)`
*
* transform `import('foo').then(({foo})=>{})`
* to `__vitePreload(async () => { const {foo} = await import('foo');return { foo }},...).then(({foo})=>{})`
*
* transform `(await import('foo')).foo`
* to `__vitePreload(async () => { const {foo} = (await import('foo')).foo; return { foo }},...)).foo`
*/
str().prependLeft(
expStart,
`${preloadMethod}(async () => { ${declaration} = await `,
)
str().appendRight(expEnd, `;return ${names}}`)
} else {
str().prependLeft(expStart, `${preloadMethod}(() => `)
}
str().appendRight(
expEnd,
// renderBuiltUrl 和 isRelativeBase 可以參考 vite base 配置以及 renderBuildUrl 配置
`,${isModernFlag}?${preloadMarker}:void 0${
renderBuiltUrl || isRelativeBase ? ',import.meta.url' : ''
})`,
)
}
}
// 如果該模塊標(biāo)記餓了 needPreloadHelper 并且當(dāng)前執(zhí)行環(huán)境 insertPreload 為 true,同時(shí)該模塊代碼中不存在 preloadMethod 的引入,則在該模塊的頂部引入 preloadMethod
if (
needPreloadHelper &&
insertPreload &&
!source.includes(`const ${preloadMethod} =`)
) {
str().prepend(`import { ${preloadMethod} } from "${preloadHelperId}";`)
}
if (s) {
return {
code: s.toString(),
map: this.environment.config.build.sourcemap
? s.generateMap({ hires: 'boundary' })
: null,
}
}
},
上面的代碼展示了 build-import-analysis 插件中 transform 鉤子的全部內(nèi)容,并在關(guān)鍵環(huán)節(jié)添加了相應(yīng)的注釋說明。簡而言之,transform 鉤子的作用可以歸納為以下幾點(diǎn):
1)掃描動(dòng)態(tài)導(dǎo)入語句:在每個(gè)模塊中使用 es-module-lexer 掃描所有的 dynamicImport 語句。例如,對(duì)于 app.tsx 文件,會(huì)掃描到 import ('./Contact.tsx') 這樣的動(dòng)態(tài)導(dǎo)入語句。
2)注入預(yù)加載 Polyfill:對(duì)于所有的動(dòng)態(tài)導(dǎo)入語句,使用 magic-string 克隆一份源代碼,然后結(jié)合第一步掃描出的 dynamicImport 語句進(jìn)行字符串拼接,注入預(yù)加載 Polyfill。例如,import ('./Contact.tsx') 經(jīng)過 transform 鉤子處理后會(huì)被轉(zhuǎn)換為:
__vitePreload(
async () => {
const { Contact } = await import('./Contact.tsx')
return { Contact }
},
__VITE_IS_MODERN__ ? __VITE_PRELOAD__ : void 0,
''
)
其中,__VITE_IS_MODERN__ 和 __VITE_PRELOAD__ 是 Vite 內(nèi)部的固定字符串占位符,在 transform 鉤子中不會(huì)處理這兩個(gè)字符串變量,目前僅用作占位。而 __vitePreload 則是外層包裹的 Polyfill 方法。
3)引入預(yù)加載方法:transform 鉤子會(huì)檢查該模塊中是否引入了 preloadMethod (__vitePreload),如果未引入,則會(huì)在模塊頂部添加對(duì) preloadMethod 的引入。例如:
import { ${preloadMethod} } from "${preloadHelperId}"
// ...
經(jīng)過 vite:build-import-analysis 插件的 transform 鉤子處理后,動(dòng)態(tài)導(dǎo)入的優(yōu)化機(jī)制已經(jīng)初具雛形。
3.3.2 增加 preload 輔助語句 - resolveId/load
接下來,我們將針對(duì) transform 鉤子中添加的 import { ${preloadMethod} } from "${preloadHelperId}" 語句進(jìn)行分析。
當(dāng)轉(zhuǎn)換后的模塊中不存在 preloadMethod 聲明時(shí),Vite 會(huì)在構(gòu)建過程中自動(dòng)插入 preloadMethod 的引入語句。當(dāng)模塊內(nèi)部引入 preloadHelperId 時(shí),Vite 會(huì)在解析該模塊(例如 App.tsx)的過程中,通過 moduleParse 鉤子逐步分析 App.tsx 中的依賴關(guān)系。
由于我們?cè)?nbsp;App.tsx 頂部插入了 import { ${preloadMethod} } from "${preloadHelperId}" 語句,因此在 App.tsx 的 moduleParse 階段,Vite 會(huì)遞歸分析 App.tsx 中引入的 preloadHelperId 模塊。
關(guān)于 Rollup Plugin 執(zhí)行順序不了解的同學(xué),可以參考下面這張圖。
此時(shí) vite:build-import-analysis 插件的 resolveId 和 load hook 就會(huì)派上用場:
// ...
resolveId(id) {
if (id === preloadHelperId) {
return id
}
},
load(id) {
// 當(dāng)檢測到引入的模塊路徑為 ${preloadHelperId} 時(shí)
if (id === preloadHelperId) {
// 判斷是否開啟了 modulePreload 配置
const { modulePreload } = this.environment.config.build
// 判斷是否需要 polyfill
const scriptRel =
modulePreload && modulePreload.polyfill
? `'modulepreload'`
: `/* @__PURE__ */ (${detectScriptRel.toString()})()`
// 聲明對(duì)于 dynamicImport 模塊深層依賴的路徑處理方式
// 比如對(duì)于使用了 dynamicImport 引入的 Contact 模塊,模塊內(nèi)部又依賴了 Phone 和 Name 模塊
// 這里 assetsURL 方法就是在執(zhí)行對(duì)于 Phone 和 Name 模塊 preload 時(shí)是否需要其他特殊處理
// 關(guān)于 renderBuiltUrl 可以參考 Vite 文檔說明 https://vite.dev/guide/build.html#advanced-base-options
// 我們暫時(shí)忽略 renderBuiltUrl ,因?yàn)槲覀儤?gòu)建時(shí)并未傳入該配置
// 自然 assetsURL = `function(dep) { return ${JSON.stringify(config.base)}+dep }`
const assetsURL =
renderBuiltUrl || isRelativeBase
? // If `experimental.renderBuiltUrl` is used, the dependencies might be relative to the current chunk.
// If relative base is used, the dependencies are relative to the current chunk.
// The importerUrl is passed as third parameter to __vitePreload in this case
`function(dep, importerUrl) { return new URL(dep, importerUrl).href }`
: // If the base isn't relative, then the deps are relative to the projects `outDir` and the base
// is appended inside __vitePreload too.
`function(dep) { return ${JSON.stringify(config.base)}+dep }`
// 聲明 assetsURL 方法,聲明 preloadMethod 方法
const preloadCode = `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = ${preload.toString()}`
return { code: preloadCode, moduleSideEffects: false }
}
},
// ...
function detectScriptRel() {
const relList =
typeof document !== 'undefined' && document.createElement('link').relList
return relList && relList.supports && relList.supports('modulepreload')
? 'modulepreload'
: 'preload'
}
declare const scriptRel: string
declare const seen: Record<string, boolean>
function preload(
baseModule: () => Promise<unknown>,
deps?: string[],
importerUrl?: string,
) {
let promise: Promise<PromiseSettledResult<unknown>[] | void> =
Promise.resolve()
// @ts-expect-error __VITE_IS_MODERN__ will be replaced with boolean later
if (__VITE_IS_MODERN__ && deps && deps.length > 0) {
const links = document.getElementsByTagName('link')
const cspNonceMeta = document.querySelector<HTMLMetaElement>(
'meta[property=csp-nonce]',
)
// `.nonce` should be used to get along with nonce hiding (https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce#accessing_nonces_and_nonce_hiding)
// Firefox 67-74 uses modern chunks and supports CSP nonce, but does not support `.nonce`
// in that case fallback to getAttribute
const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute('nonce')
promise = Promise.allSettled(
deps.map((dep) => {
// @ts-expect-error assetsURL is declared before preload.toString()
dep = assetsURL(dep, importerUrl)
if (dep in seen) return
seen[dep] = true
const isCss = dep.endsWith('.css')
const cssSelector = isCss ? '[rel="stylesheet"]' : ''
const isBaseRelative = !!importerUrl
// check if the file is already preloaded by SSR markup
if (isBaseRelative) {
// When isBaseRelative is true then we have `importerUrl` and `dep` is
// already converted to an absolute URL by the `assetsURL` function
for (let i = links.length - 1; i >= 0; i--) {
const link = links[i]
// The `links[i].href` is an absolute URL thanks to browser doing the work
// for us. See https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#reflecting-content-attributes-in-idl-attributes:idl-domstring-5
if (link.href === dep && (!isCss || link.rel === 'stylesheet')) {
return
}
}
} else if (
document.querySelector(`link[href="${dep}"]${cssSelector}`)
) {
return
}
const link = document.createElement('link')
link.rel = isCss ? 'stylesheet' : scriptRel
if (!isCss) {
link.as = 'script'
}
link.crossOrigin = ''
link.href = dep
if (cspNonce) {
link.setAttribute('nonce', cspNonce)
}
document.head.appendChild(link)
if (isCss) {
return new Promise((res, rej) => {
link.addEventListener('load', res)
link.addEventListener('error', () =>
rej(new Error(`Unable to preload CSS for ${dep}`)),
)
})
}
}),
)
}
function handlePreloadError(err: Error) {
const e = new Event('vite:preloadError', {
cancelable: true,
}) as VitePreloadErrorEvent
e.payload = err
window.dispatchEvent(e)
if (!e.defaultPrevented) {
throw err
}
}
return promise.then((res) => {
for (const item of res || []) {
if (item.status !== 'rejected') continue
handlePreloadError(item.reason)
}
return baseModule().catch(handlePreloadError)
})
}
對(duì)于引入 preloadHelperId 的模塊,build-import-analysis 會(huì)在 resolveId 和 load 階段識(shí)別并添加 preload 方法的靜態(tài)聲明。preload 方法支持三個(gè)參數(shù):
1)第一個(gè)參數(shù)是原始的模塊引入語句,例如 import('./Phone')。
2)第二個(gè)參數(shù)是被 dynamicImport 加載的模塊的所有依賴,這些依賴需要被添加為 modulepreload。
3)第三個(gè)參數(shù)是 import.meta.url(生成的資源的 JavaScript 路徑)或空字符串,這取決于 renderBuiltUrl 或 isRelativeBase 的值。在這里,我們并沒有傳入 renderBuiltUrl 或 isRelativeBase。
也就說,在 vite:build-import-analysis 的 resolveId 以及 load 階段為會(huì)存在 __vite_preload 的模塊添加對(duì)于 preloadMethod 的聲明。
3.3.3 開啟預(yù)加載優(yōu)化 - renderChunk
經(jīng)過了 resolveId、load 以及 transform 階段的分析,build-import-analysis 插件已經(jīng)可以為使用了 dynamicImport 的模塊中包裹 __vitePreload 的方法調(diào)用以及在模塊內(nèi)部引入 __vitePreload 的聲明。
renderChunk 是 Rollup(Vite) 插件鉤子之一,用于在生成每個(gè)代碼塊(chunk)時(shí)進(jìn)行自定義處理。它的主要功能是在代碼塊被轉(zhuǎn)換為最終輸出格式之前,對(duì)其進(jìn)行進(jìn)一步的操作或修改。
build-import-analysis 會(huì)在渲染每一個(gè) chunk 時(shí),通過 renderChunk hook 來最終確定是否需要開啟 modulePrealod 。
// ...
renderChunk(code, _, { format }) {
// make sure we only perform the preload logic in modern builds.
if (code.indexOf(isModernFlag) > -1) {
const re = new RegExp(isModernFlag, 'g')
const isModern = String(format === 'es')
if (this.environment.config.build.sourcemap) {
const s = new MagicString(code)
let match: RegExpExecArray | null
while ((match = re.exec(code))) {
s.update(match.index, match.index + isModernFlag.length, isModern)
}
return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' }),
}
} else {
return code.replace(re, isModern)
}
}
return null
},
簡單來說,在渲染每一個(gè)時(shí)會(huì)判斷源代碼中是否存在 isModernFlag (code.indexOf(isModernFlag) > -1 ):
- 如果存在,則會(huì)判斷生成的 chunk 是否為 esm 格式。如果是的話,則會(huì)將 isModernFlag 全部替換為 true,否則會(huì)全部替換為 false。
- 如果不存在則不會(huì)進(jìn)行任何處理。
isModernFlag 這個(gè)標(biāo)記位,在上邊的 transform hook 中我們已經(jīng)生成了:
// transform 后對(duì)于 dynamicImport 的處理
__vitePreload(
async () => {
const { Contact } = await import('./Contact.tsx')
return { Contact }
},
__VITE_IS_MODERN__ ? __VITE_PRELOAD__ : void 0,
)
此時(shí),經(jīng)過 renderChunk 的處理會(huì)變?yōu)椋?/span>
__vitePreload(
async () => {
const { Contact } = await import('./Contact.tsx')
return { Contact }
},
true ? __VITE_PRELOAD__ : void 0,
''
)
3.3.4 尋找/加載需要預(yù)加載模塊 - generateBundle
經(jīng)過上述各個(gè)階段的處理,vite 內(nèi)部會(huì)將 import ('Contact.tsx') 轉(zhuǎn)化為:
__vitePreload(
async () => {
const { Contact } = await import('./Contact.tsx')
return { Contact }
},
__VITE_PRELOAD__,
''
)
對(duì)于 __vitePreload 方法,唯一尚未解決的變量是 __VITE_PRELOAD__。
如前所述,Vite 內(nèi)部對(duì)動(dòng)態(tài)導(dǎo)入(dynamicImport)的優(yōu)化會(huì)對(duì)被動(dòng)態(tài)加載模塊的所有依賴進(jìn)行 modulePreload。在 __vitePreload 方法中,第一個(gè)參數(shù)是原始被動(dòng)態(tài)加載的 baseModule,第二個(gè)參數(shù)目前是占位符 __VITE_PRELOAD__,第三個(gè)參數(shù)是對(duì)引入資源路徑的額外處理參數(shù),在當(dāng)前配置下為空字符串。
結(jié)合 preload 方法的定義,可以推測接下來的步驟是將 __VITE_PRELOAD__ 轉(zhuǎn)化為每個(gè) dynamicImport 的深層依賴,從而使 preload 方法在加載 baseModule 時(shí)能夠?qū)λ幸蕾囘M(jìn)行 modulePreload。
generateBundle 是 Rollup(Vite) 插件鉤子之一,用于在生成最終輸出文件之前對(duì)整個(gè)構(gòu)建結(jié)果進(jìn)行處理。
它的主要作用是在所有代碼塊(chunks)和資產(chǎn)(assets)都生成之后,對(duì)這些輸出進(jìn)行進(jìn)一步的操作或修改。
這里 build-import-analysis 插件中的 generateBundle 鉤子正是用于實(shí)現(xiàn)對(duì)于最終生成的 assets 中的內(nèi)容進(jìn)行修改,尋找當(dāng)前生成的 assets 中所有 dynamicImport 的深層依賴文件從而替換 __VITE_PRELOAD__ 變量。
generateBundle({ format }, bundle) {
// 檢查生成模塊規(guī)范如果不為 es 則直接返回
if (format !== 'es') {
return
}
// 如果當(dāng)前環(huán)境并為開啟 modulePreload 的優(yōu)化
// if (!getInsertPreload(this.environment)) 中的主要目的是在預(yù)加載功能未啟用的情況下,移除對(duì)純 CSS 文件的無效 dynamicImport 導(dǎo)入,以確保生成的包(bundle)中沒有無效的導(dǎo)入語句,從而避免運(yùn)行時(shí)錯(cuò)誤。
// 在 Vite 中,純 CSS 文件可能會(huì)被單獨(dú)處理,并從最終的 JavaScript 包中移除。這是因?yàn)?CSS 通常會(huì)被提取到單獨(dú)的 CSS 文件中,以便瀏覽器可以并行加載 CSS 和 JavaScript 文件,從而提高加載性能。
// 當(dāng)純 CSS 文件被移除后,任何對(duì)這些 CSS 文件的導(dǎo)入語句將變成無效的導(dǎo)入。如果不移除這些無效的導(dǎo)入語句,運(yùn)行時(shí)會(huì)出現(xiàn)錯(cuò)誤,因?yàn)檫@些 CSS 文件已經(jīng)不存在于生成的包中。
// 默認(rèn)情況下,modulePreload 都是開啟的。同時(shí),我們的 Demo 中并不涉及 CSS 文件的處理,所以這里的邏輯并不會(huì)執(zhí)行。
if (!getInsertPreload(this.environment)) {
const removedPureCssFiles = removedPureCssFilesCache.get(config)
if (removedPureCssFiles && removedPureCssFiles.size > 0) {
for (const file in bundle) {
const chunk = bundle[file]
if (chunk.type === 'chunk' && chunk.code.includes('import')) {
const code = chunk.code
let imports!: ImportSpecifier[]
try {
imports = parseImports(code)[0].filter((i) => i.d > -1)
} catch (e: any) {
const loc = numberToPos(code, e.idx)
this.error({
name: e.name,
message: e.message,
stack: e.stack,
cause: e.cause,
pos: e.idx,
loc: { ...loc, file: chunk.fileName },
frame: generateCodeFrame(code, loc),
})
}
for (const imp of imports) {
const {
n: name,
s: start,
e: end,
ss: expStart,
se: expEnd,
} = imp
let url = name
if (!url) {
const rawUrl = code.slice(start, end)
if (rawUrl[0] === `"` && rawUrl[rawUrl.length - 1] === `"`)
url = rawUrl.slice(1, -1)
}
if (!url) continue
const normalizedFile = path.posix.join(
path.posix.dirname(chunk.fileName),
url,
)
if (removedPureCssFiles.has(normalizedFile)) {
// remove with Promise.resolve({}) while preserving source map location
chunk.code =
chunk.code.slice(0, expStart) +
`Promise.resolve({${''.padEnd(expEnd - expStart - 19, ' ')}})` +
chunk.code.slice(expEnd)
}
}
}
}
}
return
}
const buildSourcemap = this.environment.config.build.sourcemap
const { modulePreload } = this.environment.config.build
// 遍歷 bundle 中的所有 assets
for (const file in bundle) {
const chunk = bundle[file]
// 如果生成的文件類型為 chunk 同時(shí)源文件內(nèi)容中包含 preloadMarker
if (chunk.type === 'chunk' && chunk.code.indexOf(preloadMarker) > -1) {
const code = chunk.code
let imports!: ImportSpecifier[]
try {
// 獲取模塊中所有的動(dòng)態(tài) dynamicImport 語句
imports = parseImports(code)[0].filter((i) => i.d > -1)
} catch (e: any) {
const loc = numberToPos(code, e.idx)
this.error({
name: e.name,
message: e.message,
stack: e.stack,
cause: e.cause,
pos: e.idx,
loc: { ...loc, file: chunk.fileName },
frame: generateCodeFrame(code, loc),
})
}
const s = new MagicString(code)
const rewroteMarkerStartPos = new Set() // position of the leading double quote
const fileDeps: FileDep[] = []
const addFileDep = (
url: string,
runtime: boolean = false,
): number => {
const index = fileDeps.findIndex((dep) => dep.url === url)
if (index === -1) {
return fileDeps.push({ url, runtime }) - 1
} else {
return index
}
}
if (imports.length) {
// 遍歷當(dāng)前模塊中所有的 dynamicImport 語句
for (let index = 0; index < imports.length; index++) {
const {
n: name,
s: start,
e: end,
ss: expStart,
se: expEnd,
} = imports[index]
// check the chunk being imported
let url = name
if (!url) {
const rawUrl = code.slice(start, end)
if (rawUrl[0] === `"` && rawUrl[rawUrl.length - 1] === `"`)
url = rawUrl.slice(1, -1)
}
const deps = new Set<string>()
let hasRemovedPureCssChunk = false
let normalizedFile: string | undefined = undefined
if (url) {
// 獲取當(dāng)前動(dòng)態(tài)導(dǎo)入 dynamicImport 的模塊路徑(相較于應(yīng)用根目錄而言)
normalizedFile = path.posix.join(
path.posix.dirname(chunk.fileName),
url,
)
const ownerFilename = chunk.fileName
// literal import - trace direct imports and add to deps
const analyzed: Set<string> = new Set<string>()
const addDeps = (filename: string) => {
if (filename === ownerFilename) return
if (analyzed.has(filename)) return
analyzed.add(filename)
const chunk = bundle[filename]
if (chunk) {
// 將依賴添加到 deps 中
deps.add(chunk.fileName)
// 遞歸當(dāng)前依賴 chunk 的所有 import 靜態(tài)依賴
if (chunk.type === 'chunk') {
// 對(duì)于所有 chunk.imports 進(jìn)行遞歸 addDeps 加入到 deps 中
chunk.imports.forEach(addDeps)
// 遍歷當(dāng)前代碼塊導(dǎo)入的 CSS 文件
// 確保當(dāng)前代碼塊導(dǎo)入的 CSS 在其依賴項(xiàng)之后加載。
// 這樣可以防止當(dāng)前代碼塊的樣式被意外覆蓋。
chunk.viteMetadata!.importedCss.forEach((file) => {
deps.add(file)
})
}
} else {
// 如果當(dāng)前依賴的 chunk 并沒有被生成,檢查當(dāng)前 chunk 是否為純 CSS 文件的 dynamicImport
const removedPureCssFiles =
removedPureCssFilesCache.get(config)!
const chunk = removedPureCssFiles.get(filename)
// 如果是的話,則會(huì)將 css 文件加入到依賴中
// 同時(shí)更新 dynamicImport 的 css 為 promise.resolve({}) 防止找不到 css 文件導(dǎo)致的運(yùn)行時(shí)錯(cuò)誤
if (chunk) {
if (chunk.viteMetadata!.importedCss.size) {
chunk.viteMetadata!.importedCss.forEach((file) => {
deps.add(file)
})
hasRemovedPureCssChunk = true
}
s.update(expStart, expEnd, 'Promise.resolve({})')
}
}
}
// 將當(dāng)前 dynamicImport 的模塊路徑添加到 deps 中
// 比如 import('./Contact.tsx') 會(huì)將 [root]/assets/Contact.tsx 添加到 deps 中
addDeps(normalizedFile)
}
// 尋找當(dāng)前 dynamicImport 語句中的 preloadMarker 的位置
let markerStartPos = indexOfMatchInSlice(
code,
preloadMarkerRE,
end,
)
// 邊界 case 處理,我們可以忽略這個(gè)判斷。找不到的清咖滾具體參考相關(guān) issue #3051
if (markerStartPos === -1 && imports.length === 1) {
markerStartPos = indexOfMatchInSlice(code, preloadMarkerRE)
}
// 如果找到了 preloadMarker
// 判斷 vite 構(gòu)建時(shí)是否開啟了 modulePreload
// 如果開啟則將當(dāng)前 dynamicImport 的所有依賴項(xiàng)添加到 deps 中
// 否則僅會(huì)添加對(duì)應(yīng) css 文件
if (markerStartPos > 0) {
// the dep list includes the main chunk, so only need to reload when there are actual other deps.
let depsArray =
deps.size > 1 ||
// main chunk is removed
(hasRemovedPureCssChunk && deps.size > 0)
? modulePreload === false
?
// 在 Vite 中,CSS 依賴項(xiàng)的處理機(jī)制與模塊預(yù)加載(module preloads)的機(jī)制是相同的。
// 所以,及時(shí)沒有開啟 dynamicImport 的 modulePreload 優(yōu)化,仍然需要通過 vite_preload 處理 dynamicImport 的 CSS 依賴項(xiàng)。
[...deps].filter((d) => d.endsWith('.css'))
: [...deps]
: []
// 具體可以參考 https://vite.dev/config/build-options.html#build-modulepreload
// resolveDependencies 是一個(gè)函數(shù),用于確定給定模塊的依賴關(guān)系。在 Vite 的構(gòu)建過程中,Vite 會(huì)調(diào)用這個(gè)函數(shù)來獲取每個(gè)模塊的依賴項(xiàng),并生成相應(yīng)的預(yù)加載指令。
// 在 vite 構(gòu)建過程中我們可以通過 resolveDependencies 函數(shù)來自定義修改模塊的依賴關(guān)系從而響應(yīng) preload 的聲明
// 我們這里并沒有開啟,所以為 undefined
const resolveDependencies = modulePreload
? modulePreload.resolveDependencies
: undefined
if (resolveDependencies && normalizedFile) {
// We can't let the user remove css deps as these aren't really preloads, they are just using
// the same mechanism as module preloads for this chunk
const cssDeps: string[] = []
const otherDeps: string[] = []
for (const dep of depsArray) {
;(dep.endsWith('.css') ? cssDeps : otherDeps).push(dep)
}
depsArray = [
...resolveDependencies(normalizedFile, otherDeps, {
hostId: file,
hostType: 'js',
}),
...cssDeps,
]
}
let renderedDeps: number[]
// renderBuiltUrl 可以參考 Vite 文檔說明
// 這里我們也沒有開啟 renderBuiltUrl 選項(xiàng)
// 簡單來說 renderBuiltUrl 用于在構(gòu)建過程中自定義處理資源 URL 的生成
if (renderBuiltUrl) {
renderedDeps = depsArray.map((dep) => {
const replacement = toOutputFilePathInJS(
this.environment,
dep,
'asset',
chunk.fileName,
'js',
toRelativePath,
)
if (typeof replacement === 'string') {
return addFileDep(replacement)
}
return addFileDep(replacement.runtime, true)
})
} else {
// 最終,我們的 Demo 中對(duì)于 depsArray 會(huì)走到這個(gè)的邏輯處理
// 首先會(huì)根據(jù) isRelativeBase 判斷構(gòu)建時(shí)的 basename 是否為相對(duì)路徑
// 如果為相對(duì)路徑,調(diào)用 toRelativePath 將每個(gè)依賴想相較于 basename 的地址進(jìn)行轉(zhuǎn)換之后調(diào)用 addFileDep
// 否則,直接將依賴地址調(diào)用 addFileDep
renderedDeps = depsArray.map((d) =>
// Don't include the assets dir if the default asset file names
// are used, the path will be reconstructed by the import preload helper
isRelativeBase
? addFileDep(toRelativePath(d, file))
: addFileDep(d),
)
}
// 最終這里會(huì)將當(dāng)前 import 語句中的 __VITE_PRELOAD__ 替換為 __vite__mapDeps([${renderedDeps.join(',')}])
// renderedDeps 則為當(dāng)前 dynamicImport 模塊所有需要被優(yōu)化的依賴項(xiàng)的 FileDep 類型對(duì)象
s.update(
markerStartPos,
markerStartPos + preloadMarker.length,
renderedDeps.length > 0
? `__vite__mapDeps([${renderedDeps.join(',')}])`
: `[]`,
)
rewroteMarkerStartPos.add(markerStartPos)
}
}
}
// 這里的邏輯主要用于生成 __vite__mapDeps 方法
if (fileDeps.length > 0) {
// 將 fileDeps 對(duì)象轉(zhuǎn)化為字符串
const fileDepsCode = `[${fileDeps
.map((fileDep) =>
// 檢查是否存在 runtime
// 關(guān)于 runtime 的邏輯,可以參考 vite 文檔 https://vite.dev/config/build-options.html#build-modulepreload
// Demo 中并沒有定義任何 runtime 邏輯,所以這里的 runtime 為 false
// 如果存在,則直接使用 fileDep.url 的字符串
// 否則使用 fileDep.url 的 JSON 字符串
fileDep.runtime ? fileDep.url : JSON.stringify(fileDep.url),
)
.join(',')}]`
const mapDepsCode = `const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=${fileDepsCode})))=>i.map(i=>d[i]);\n`
// 將生成的 __vite__mapDeps 聲明插入到生成的文件頂部
if (code.startsWith('#!')) {
s.prependLeft(code.indexOf('\n') + 1, mapDepsCode)
} else {
s.prepend(mapDepsCode)
}
}
// 看上去像是為了確保所有的預(yù)加載標(biāo)記都被正確移除。
// 不過上述的 case 理論上來說已經(jīng)處理了所有的 dynamicImport ,這里具體為什么在檢查一遍,我也不是很清楚
// But it's not important! ?? 這并不妨礙我們理解 preload 優(yōu)化的原理,我們可以將它標(biāo)記為兜底的異常邊界處理
let markerStartPos = indexOfMatchInSlice(code, preloadMarkerRE)
while (markerStartPos >= 0) {
if (!rewroteMarkerStartPos.has(markerStartPos)) {
s.update(
markerStartPos,
markerStartPos + preloadMarker.length,
'void 0',
)
}
markerStartPos = indexOfMatchInSlice(
code,
preloadMarkerRE,
markerStartPos + preloadMarker.length,
)
}
// 修改最終生成的文件內(nèi)容
if (s.hasChanged()) {
chunk.code = s.toString()
if (buildSourcemap && chunk.map) {
const nextMap = s.generateMap({
source: chunk.fileName,
hires: 'boundary',
})
const map = combineSourcemaps(chunk.fileName, [
nextMap as RawSourceMap,
chunk.map as RawSourceMap,
]) as SourceMap
map.toUrl = () => genSourceMapUrl(map)
chunk.map = map
if (buildSourcemap === 'inline') {
chunk.code = chunk.code.replace(
convertSourceMap.mapFileCommentRegex,
'',
)
chunk.code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}`
} else if (buildSourcemap) {
const mapAsset = bundle[chunk.fileName + '.map']
if (mapAsset && mapAsset.type === 'asset') {
mapAsset.source = map.toString()
}
}
}
}
}
}
},
上邊的代碼中,我對(duì)于 generateBundle hook 每一行都進(jìn)行了詳細(xì)的注釋。
在 generateBundle hook 中,簡單來說就是遍歷每一個(gè)生成的 chunk ,通過檢查每個(gè) chunk 中的 js assets 中是否包含 preloadMarker 標(biāo)記來檢查生成的資源中是否需要被處理。
如果當(dāng)前文件存在 preloadMarker 標(biāo)記的話,此時(shí)會(huì)解析出生成的 js 文件中所有的 dynamicImport 語句,遍歷每一個(gè) dynamicImport 語句。
同時(shí)將 dynamicImport 的模塊以及依賴的模塊全部通過 addDeps 方法加入到 deps 的 Set 中。
也就說,每個(gè) chunk 中的每個(gè) asset 的每一個(gè) dynamicImport 都存在一個(gè)名為 deps 的 Set ,它會(huì)收集到當(dāng)前 dynamicImport 模塊的所有依賴(從被動(dòng)態(tài)導(dǎo)入的自身模塊開始遞歸尋找)。
比如 import('./Contact.tsx') 模塊就會(huì)尋找到 Contact、Phone、Name 這三個(gè) chunk 對(duì)應(yīng)的 js asset 文件路徑。
之后,會(huì)將上述生成的
__vitePreload(
async () => {
const { Contact } = await import('./Contact.tsx')
return { Contact }
},
__VITE_PRELOAD__,
''
)
中的 __VITE_PRELOAD__ 替換成為
__vitePreload(
async () => {
const { Contact } = await import('./Contact.tsx')
return { Contact }
},
__vite__mapDeps([${renderedDeps.join(',')}],
''
)
對(duì)于我們 Demo 中的 Contact 模塊,renderedDeps 則是 Contact、Phone 以及 Name 對(duì)應(yīng)構(gòu)建后生成的 js 資源路徑。
之后,又會(huì)在生成的 js 文件中插入這樣一段代碼:
const __vite__mapDeps = (i, m = __vite__mapDeps, d = m.f || (m.f = ${fileDepsCode})) =>
i.map((i) => d[i])
在我們的 Demo 中 fileDepsCode 即為 fileDeps 中每一項(xiàng)依賴的靜態(tài)資源地址(也就是執(zhí)行 dynamicImport Contact 時(shí)需要依賴的 js 模塊)轉(zhuǎn)化為 JSON 字符串之后的路徑。
Tips: fileDeps 是 asset (資源文件) 緯度的,也就是一個(gè) JS 資源中所有 dynamicImport 的資源都會(huì)被加入到 fileDeps 數(shù)組中,而 deps 是每個(gè) dynamicImport 語句維護(hù)的。最終在調(diào)用 preload 時(shí),每個(gè) preload 語句的 deps 是一個(gè)索引的數(shù)組,我們會(huì)通過 deps 中的索引去 fileDeps 中尋找對(duì)應(yīng)下標(biāo)的資源路徑。
最終,代碼中的 await import('./Contact.tsx') 經(jīng)過 vite 的構(gòu)建后會(huì)變?yōu)椋?/span>
const __vite__mapDeps = (
i,
m = __vite__mapDeps,
d = m.f ||
(m.f = [
'assets/Contact-BGa5hZNp.js',
'assets/Phone-CqabSd3V.js',
'assets/Name-Blg-G5Um.js',
]),
) => i.map((i) => d[i])
const Contact = React.lazy(() =>
__vitePreload(
() => import('./Contact-BGa5hZNp.js'),
__vite__mapDeps([0, 1, 2]),
),
)
至此,我們已經(jīng)詳細(xì)講解了 Vite 內(nèi)部 modulePreload 預(yù)加載的全部源碼實(shí)現(xiàn)。
四、商旅對(duì)于 DynamicImport 的內(nèi)部改造
目前,商旅內(nèi)部對(duì) Remix 2.0 的升級(jí)優(yōu)化工作已接近尾聲。相比于 Remix 1.0 的運(yùn)行方式,2.0 中如果僅在服務(wù)端模板生成時(shí)為所有 ES 模塊動(dòng)態(tài)添加 AresHost,對(duì)于某些動(dòng)態(tài)導(dǎo)入(DynamicImport)的模塊,構(gòu)建后代碼發(fā)布時(shí)可能會(huì)出現(xiàn) modulePreload 標(biāo)簽和 CSS 資源加載 404 的問題。這些 404 資源問題正是由于 Vite 中 build-import-analysis 對(duì) DynamicImport 的優(yōu)化所導(dǎo)致的。
為了解決這一問題,我們不僅對(duì) Remix 進(jìn)行了改造,還對(duì) Vite 中處理 DynamicImport 的邏輯進(jìn)行了優(yōu)化,以支持在 modulePreload 開啟時(shí)以及 DynamicImport 模塊中的靜態(tài)資源實(shí)現(xiàn) Ares 的運(yùn)行時(shí) CDN Host 注入。
實(shí)際上,Vite 中存在一個(gè)實(shí)驗(yàn)性屬性 experimental.renderBuiltUrl,也支持為靜態(tài)資源添加動(dòng)態(tài) Host。然而,renderBuiltUrl 的局限性在于它無法獲取服務(wù)端的運(yùn)行變量。由于我們的前端應(yīng)用在服務(wù)端運(yùn)行時(shí)將 AresHost 掛載在每次請(qǐng)求的 request 中,而 renderBuiltUrl 屬性無法訪問每次請(qǐng)求的 request。
我們期望不僅在客戶端運(yùn)行時(shí),還能在服務(wù)端 SSR 應(yīng)用模板生成時(shí)通過 request 獲取動(dòng)態(tài)的 Ares 前綴并掛載在靜態(tài)資源上,顯然 renderBuiltUrl 無法滿足這一需求。
簡單來說,對(duì)于修改后的 Remix 框架,我們將所有攜程相關(guān)的通用框架屬性集成到 RemixContext 中,并通過傳統(tǒng) SSR 應(yīng)用服務(wù)端和客戶端傳遞數(shù)據(jù)的方式(script 腳本)在 window 上掛載 __remixContext.aresHost 屬性。
之后,我們?cè)?Vite 內(nèi)部的 build-import-analysis 插件中的 preload 函數(shù)中增加了一段代碼,為所有鏈接添加 window.__remixContext.aresHost 屬性,從而確保 dynamicImport 模塊中依賴的 CSS 和 modulePreload 腳本能夠正確攜帶當(dāng)前應(yīng)用的 AresHost。
五、結(jié)尾
商旅大前端團(tuán)隊(duì)在攜程內(nèi)部是較早采用 Streaming 和 ESModule 技術(shù)的。相比集團(tuán)的 NFES(攜程內(nèi)部一款基于 React 18 + Next.js 13.1.5 + Webpack 5 的前端框架),Remix 在開發(fā)友好度和服務(wù)端 Streaming 處理方面具有獨(dú)特優(yōu)勢。目前,Remix 已在商旅的大流量頁面中得到了驗(yàn)證,并取得了良好效果。
本文主要從 preload 細(xì)節(jié)入手,分享我們?cè)谶@方面遇到的問題和心得。后續(xù)我們將繼續(xù)分享更多關(guān)于 Remix 的技術(shù)細(xì)節(jié),并為大家介紹更多商旅對(duì) Remix 的改造。