Islands 架構(gòu)原理和實(shí)踐
Islands 架構(gòu)是今年比較火的一個(gè)話題,目前社區(qū)一些比較知名的新框架如 Fresh、Astro 都是 Islands 架構(gòu)的典型代表。本文將給大家介紹 Islands 架構(gòu)誕生的來(lái)龍去脈,分析它相比于 Next.js、Gatsby 等傳統(tǒng)方案的優(yōu)勢(shì),并且剖析社區(qū)相關(guān)框架的實(shí)現(xiàn)原理,以及分享筆者在這個(gè)方向上的一些實(shí)踐。
MPA 和 SPA 的取舍
MPA 和 SPA 是構(gòu)建前端頁(yè)面常見(jiàn)的兩種方式,理解 MPA 和 SPA 的區(qū)別和不同場(chǎng)景的取舍是理解 Islands 架構(gòu)的關(guān)鍵。
概念
MPA(Multi-page application) 即多頁(yè)應(yīng)用,是從服務(wù)器加載多個(gè) HTML 頁(yè)面的應(yīng)用程序。每個(gè)頁(yè)面都彼此獨(dú)立,有自己的 URL。當(dāng)單擊 a 標(biāo)簽鏈接導(dǎo)航到另一個(gè)頁(yè)面時(shí),瀏覽器將向服務(wù)器發(fā)送請(qǐng)求并加載新頁(yè)面。例如,傳統(tǒng)的模板技術(shù)如JSP、Python、Django、PHP、Laravel 等都是基于 MPA 的框架,包括目前比較火的 Astro 也是采用的 MPA 方案。
SPA(Single-page application) 即單頁(yè)應(yīng)用,它只有一個(gè)不包含具體頁(yè)面內(nèi)容的 HTML,當(dāng)瀏覽器拿到這份 HTML 之后,會(huì)請(qǐng)求頁(yè)面所需的 JavaScript 代碼,通過(guò)執(zhí)行 JavaScript 代碼完成 DOM 樹(shù)的構(gòu)建和 DOM 的事件綁定,從而讓頁(yè)面可以交互。如現(xiàn)在使用的大多數(shù) Vue、React 中后臺(tái)應(yīng)用都是 SPA 應(yīng)用。
對(duì)比
1. 性能
在 MPA 中,服務(wù)器將響應(yīng)完整的 HTML 頁(yè)面給瀏覽器,但是 SPA 需要先請(qǐng)求客戶端的 JS Bundle 然后執(zhí)行 JS 以渲染頁(yè)面。因此,MPA 中的頁(yè)面的首屏加載性能比 SPA 更好。
2. SEO
3. 路由
4. 狀態(tài)管理
取舍
總而言之,MPA 有更好的首屏性能,SPA 在后續(xù)頁(yè)面的訪問(wèn)中有更好的性能和體驗(yàn),但 SPA 也帶來(lái)了更高的工程復(fù)雜度、略差的首屏性能和 SEO。這樣就需要在不同的場(chǎng)景中做一些取舍。
不過(guò),MPA 和 SPA 也并不是完全割裂的,兩者也是能夠有所結(jié)合的,比如 SSR/SSG 同構(gòu)方案就是一個(gè)典型的體現(xiàn),首先框架側(cè)會(huì)在服務(wù)端生成完整的 HTML 內(nèi)容,并且同時(shí)注入客戶端所需要的 SPA 腳本。這樣瀏覽器會(huì)拿到完整的 HTML 內(nèi)容,然后執(zhí)行客戶端的腳本事件的綁定(這個(gè)過(guò)程也叫 hydrate),后續(xù)路由的跳轉(zhuǎn)由 JS 來(lái)掌管。當(dāng)下很多的框架都是采用這樣的方案,比如 Next.js、Gatsby、公司內(nèi)部的 Eden SSR、Modern.js。
但實(shí)際上,把 MPA 和 SPA 結(jié)合的方案也并不是完美無(wú)缺的,主要的問(wèn)題在于這類(lèi)方案仍然會(huì)下載全量的客戶端 JS 及執(zhí)行全量的組件 Hydrate 過(guò)程,造成頁(yè)面的首屏 TTI 劣化。
我們可以試想對(duì)于一個(gè)文檔類(lèi)型的站點(diǎn),其實(shí)里面的大多數(shù)組件是不需要交互的,主要以靜態(tài)頁(yè)面的渲染為主,因此直接采用 MPA 方案是一個(gè)比 MPA + SPA 更好的一個(gè)選擇。進(jìn)一步講,對(duì)于更多的輕交互、重內(nèi)容的應(yīng)用場(chǎng)景,MPA 也依然是一個(gè)更好的方案。
由于頁(yè)面中有時(shí)仍然不可避免的需要一些交互的邏輯,那放在 MPA 中如何來(lái)完成呢?這就是 Islands 架構(gòu)所要解決的問(wèn)題。
什么是 Islands 架構(gòu)?
Json Miller 在 Islnads Architecture 一文中得到推廣。這個(gè)模型主要用于 SSR (也包括 SSG) 應(yīng)用,我們知道,在傳統(tǒng)的 SSR 應(yīng)用中,服務(wù)端會(huì)給瀏覽器響應(yīng)完整的 HTML 內(nèi)容,并在 HTML 中注入一段完整的 JS 腳本用于完成事件的綁定,也就是完成 hydration (注水) 的過(guò)程。當(dāng)注水的過(guò)程完成之后,頁(yè)面也才能真正地能夠進(jìn)行交互。
Islands 實(shí)現(xiàn)原理
Astro
https://astro.build/
在 Astro 中,默認(rèn)所有的組件都是靜態(tài)組件,比如:
// index.astro
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent />
激活孤島組件了,在使用組件時(shí)加上client:load指令即可:// index.astro
---
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent client:load />
Astro 除了支持本身 Astro 語(yǔ)法之外,也支持 Vue、React 等框架,可以通過(guò)插件的方式來(lái)導(dǎo)入。在構(gòu)建的時(shí)候,Astro 只會(huì)打包并注入 Islands 組件的代碼,并且在瀏覽器渲染,分別調(diào)用不同框架(Vue、React)的渲染函數(shù)完成各個(gè) Islands 組件的 hydrate 過(guò)程。
Astro 是典型的 MPA 方案,不支持引入 SPA 的路由和狀態(tài)管理。
Fresh
islands 目錄專(zhuān)門(mén)存放 island 組件:.
├── README.md
├── components
│ └── Button.tsx
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands // Islands 組件目錄
│ └── Counter.tsx
├── main.ts
├── routes
│ ├── [name].tsx
│ ├── api
│ │ └── joke.ts
│ └── index.tsx
├── static
│ ├── favicon.ico
│ └── logo.svg
└── utils
└── twind.ts
通過(guò)掃描 islands 目錄記錄項(xiàng)目中聲明的所有 Islands 組件。 攔截 Preact 中 vnode 的創(chuàng)建邏輯,目的是為了匹配之前記錄的 Island 組件,如果能匹配上,則記錄 Island 組件的 props 信息,并將組件用 的注釋標(biāo)簽來(lái)包裹,id 值為 Island 的 id,數(shù)字為該 Island 的 props 在全局 props 列表中的位置,方便 hydrate 的時(shí)候能夠找到對(duì)應(yīng)組件的 props。 調(diào)用 Preact 的 renderToString 方法將組件渲染為 HTML 字符串。 向 HTML 中注入客戶端 hydrate 的邏輯。 拼接完整的 HTML,返回給前端。
更多細(xì)節(jié)可以參考篇文章:深入解讀 Fresh
實(shí)踐分享
倉(cāng)庫(kù): https://github.com/sanyuan0704/island.js
使用 Island 組件
__island 標(biāo)志即可,比如:import { Aside } from '../components/Aside';
export function Layout() {
return <Aside __island />;
}
內(nèi)部實(shí)現(xiàn)

1. SSR Runtime
// island-jsx-runtime.js
import * as jsxRuntime from 'react/jsx-runtime';
export const data = {
// 存放 islands 組件的 props
islandProps: [],
// 存放 islands 組件的文件路徑
islandToPathMap: {}
};
const originJsx = jsxRuntime.jsx;
const originJsxs = jsxRuntime.jsxs;
const internalJsx = (jsx, type, props, ...args) => {
if (props && props.__island) {
data.islandProps.push(props || {});
const id = type.name;
// __island 的 prop 將在 SSR 構(gòu)建階段轉(zhuǎn)換為 `__island: 文件路徑`
data.islandToPathMap[id] = props.__island;
delete props.__island;
return jsx('div', {
__island: `${id}:${data.islandProps.length - 1}`,
children: jsx(type, props, ...args)
});
}
return jsx(type, props, ...args);
};
export const jsx = (...args) => internalJsx(originJsx, ...args);
export const jsxs = (...args) => internalJsx(originJsxs, ...args);
export const Fragment = jsxRuntime.Fragment;
2. Build Time
SSR bundle: 用于 renderToString Client bundle: 客戶端 Runtime 代碼,用于激活頁(yè)面
__island prop,它實(shí)際上是為了標(biāo)識(shí)該組件是一個(gè) Islands 組件,但我們拿不到組件的路徑信息。為了之后能夠順利打包 Islands 組件,我們需要在 SSR 構(gòu)建過(guò)程中將 __isalnd 進(jìn)行轉(zhuǎn)換,使之帶上路徑信息。比如下面有兩個(gè)組件:// Layout.tsx
import { Aside } from './Aside.tsx';
export function Layout() {
return (
<div>
<Aside __island a={1} />
</div>
)
}
// Aside.tsx
export function Aside() {
return <div>內(nèi)容省略...</div>
}
<Aside __island /> 的方式來(lái)使用 Aside 組件,標(biāo)識(shí)其為一個(gè) Islands 組件。那么我們將會(huì)在 SSR 編譯過(guò)程中用 babel 插件改寫(xiě)這個(gè) prop,原理如下:<Aside __island />
// 被轉(zhuǎn)換為
<Aside __island="./Aside.tsx!!island!!Users/project/src/Layout.tsx" />
{
islandProps: [ { a: 1 } ],
islandToPathMap: {
Aside: './Aside.tsx!!island!!Users/project/src/Layout.tsx'
}
}
將 islandProps 的數(shù)據(jù)作為 id 為 island-props的 script 標(biāo)簽注入到 HTML 中;根據(jù) islandToPathMap 的信息構(gòu)造虛擬模塊,打包所有的 Islands 組件。
import { Aside } from './Aside.tsx!!island!!Users/project/src/Layout.tsx';
window.islands = {
Aside
};
window.ISLAND_PROPS = JSON.parse(
document.getElementById('island-props').textContent
);
將這個(gè)虛擬模塊打包后我們得到一份 Islands bundle,將這個(gè) bundle 注入到 HTML 中以完成 Islands 組件的注冊(cè)。
?? 問(wèn)題: islands bundle 和 client bundle 有共同的依賴(lài) React,由于在兩次不同的打包流程中,所以 React 會(huì)打包兩份。解決方案是 external 掉 react 和 react-dom 依賴(lài),通過(guò) import map 指向全局唯一的 React 實(shí)例。
3. Client Runtime
import { hydrateRoot, createRoot } from 'react-dom/client';
const islands = document.querySelectorAll('[__island]');
for (let i = 0; i < islands.length; i++) {
const island = islands[i];
const [id, index] = island.getAttribute('__island')!.split(':');
const Element = window.ISLANDS[id];
hydrateRoot(
island,
<Element {...window.ISLAND_PROPS[index]}></Element>
);
}




| HTTP 請(qǐng)求資源(KB) | FCP (s) | DCL(s) | |
|---|---|---|---|
| SPA 模式 | 451 | 0.48 | 0.84 |
| Islands 模式 | 141 | 0.40 | 0.52 |
| 優(yōu)化情況 | 減少近 60% | 變化不大 | 提前近 40% |
Islands 架構(gòu)的適用性
1. 框架無(wú)關(guān)
Island 架構(gòu)的實(shí)現(xiàn)其實(shí)是可以做到框架無(wú)關(guān)的。從 SSR Runtime、Build Time 到 Client Runtime,整個(gè)環(huán)節(jié)中關(guān)于 React 的部分,我們都可以替換成其它框架的實(shí)現(xiàn),這些部分包括:
創(chuàng)建組件的方法
組件轉(zhuǎn)換為 HTML 字符串的 renderToString 方法
瀏覽器端的 hydrate 方法
因此,不光是 React,對(duì)于 Vue、Preact、Solidjs 這些框架中都可以實(shí)現(xiàn) Islands 架構(gòu)。因此,在 Island.js 中兼容除 React 的其它框架也是原理上完全可行的。
并且考慮到 React 的包體積問(wèn)題,后續(xù) Island.js 考慮適配其它的框架,如 Solid,體積相比 React 可以減少 90%:

數(shù)據(jù)來(lái)源: https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f
2. VitePress 的特殊優(yōu)化

VitePress 會(huì)在 hydrate 的過(guò)程中把正文的靜態(tài)部分排除,具體實(shí)現(xiàn)原理如下:
Vue 模板編譯階段,Vue 會(huì)對(duì)靜態(tài)虛擬 DOM 節(jié)點(diǎn)進(jìn)行優(yōu)化,輸出
createStaticVNode的格式

在 Chunk 生成階段(實(shí)現(xiàn)鏈接),把內(nèi)容部分用 __VP_STATIC_START__和__VP_STATIC_END__標(biāo)志位包裹在生成打包產(chǎn)物前,針對(duì)每個(gè)頁(yè)面打包出兩份 JS 一份是包含完整內(nèi)容的 JS,把標(biāo)志位去掉即可,比如文件名為 recommend.[hash].js另一份是不包含內(nèi)容的 JS,把標(biāo)志位及其里面的內(nèi)容刪掉,文件名為 recommend.[hash].lean.js
首屏使用 .lean.js,不包含正文部分的 JS,實(shí)現(xiàn)Partial Client Bundle+Partial Hydration,跟 Islands 架構(gòu)一樣的效果二次頁(yè)面跳轉(zhuǎn)使用完整的 JS,因?yàn)樽?SPA 路由跳轉(zhuǎn),需要拿到完整的頁(yè)面內(nèi)容,用 JS 渲染出來(lái)。
.lean.js 里面,組件的代碼都被改了,難道 Vue 在 hydrate 不會(huì)發(fā)現(xiàn)內(nèi)容和服務(wù)端渲染的 HTML 對(duì)應(yīng)不上進(jìn)而報(bào)錯(cuò)嗎?答案是不會(huì),我們可以看看 Vue 里面 createStaticVNode 的實(shí)現(xiàn):
staticCount即節(jié)點(diǎn)數(shù)量而不是內(nèi)容,那么對(duì)于如下的 VNode 節(jié)點(diǎn)來(lái)講 hydrate 仍然是可以成功的:// recommend.[hash].lean.js
const html = ` A <span>foo</span> B`
const { vnode, container } = mountWithHydration(html, () =>
// 保證第二個(gè)參數(shù)正確即可
createStaticVNode(``, 3)
)
總之, VitePress 利用 Vue 的編譯時(shí)優(yōu)化以及內(nèi)部定制的 Hydrate 方案足以解決傳統(tǒng) SSG 的全量 hydration 問(wèn)題,采用 Islands 架構(gòu)意義并不大。
那進(jìn)一步講,像 Vue 這種 Shell 優(yōu)化方案對(duì)于包含編譯時(shí)的前端框架是否通用?這里我們可以先大概總結(jié)出 Shell 方案需要滿足的條件:
模板編譯階段,將靜態(tài)節(jié)點(diǎn)進(jìn)行特殊標(biāo)記
運(yùn)行時(shí),支持 hydrate 跳過(guò)對(duì)靜態(tài)節(jié)點(diǎn)的內(nèi)容檢查
基于上面這兩點(diǎn),其他的代表性編譯時(shí)框架如Solid、Svelte 很難實(shí)現(xiàn) Vue 的 Shell 架構(gòu)(沒(méi)法標(biāo)記靜態(tài)節(jié)點(diǎn)),因此 Shell 方案可以理解為在 Vue 框架下的一個(gè)特殊優(yōu)化了。對(duì)于 Vue 外的其它框架方案,仍然可以采用 Islands 進(jìn)行特定場(chǎng)景的優(yōu)化。

往期推薦



最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專(zhuān)業(yè)的技術(shù)人...


