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

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

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

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