Islands 架構原理和實踐

Islands 架構是今年比較火的一個話題,目前社區一些比較知名的新框架如 Fresh、Astro 都是 Islands 架構的典型代表。本文將給大家介紹 Islands 架構誕生的來龍去脈,分析它相比於 Next.js、Gatsby 等傳統方案的優勢,並且剖析社區相關框架的實現原理,以及分享筆者在這個方向上的一些實踐。

MPA 和 SPA 的取捨

MPA 和 SPA 是構建前端頁面常見的兩種方式,理解 MPA 和 SPA 的區別和不同場景的取捨是理解 Islands 架構的關鍵。

概念

MPA(Multi-page application) 即多頁應用,是從服務器加載多個 HTML 頁面的應用程序。每個頁面都彼此獨立,有自己的 URL。當單擊 a 標籤鏈接導航到另一個頁面時,瀏覽器將向服務器發送請求並加載新頁面。例如,傳統的模板技術如 JSP、Python、Django、PHP、Laravel 等都是基於 MPA 的框架,包括目前比較火的 Astro 也是採用的 MPA 方案。

SPA(Single-page application) 即單頁應用,它只有一個不包含具體頁面內容的 HTML,當瀏覽器拿到這份 HTML 之後,會請求頁面所需的 JavaScript 代碼,通過執行 JavaScript 代碼完成 DOM 樹的構建和 DOM 的事件綁定,從而讓頁面可以交互。如現在使用的大多數 Vue、React 中後臺應用都是 SPA 應用。

對比

1. 性能

在 MPA 中,服務器將響應完整的 HTML 頁面給瀏覽器,但是 SPA 需要先請求客戶端的 JS Bundle 然後執行 JS 以渲染頁面。因此,MPA 中的頁面的首屏加載性能比 SPA 更好。

但 SPA 在後續頁面加載方面有更好的性能和體驗。因爲 SPA 在完成首屏加載之後,在訪問其它的頁面時只需要動態加載頁面的一部分組件,而不是整個頁面。而且,當頁面發生跳轉時,SPA 不會重新加載頁面,對用戶更友好。

2. SEO

MPA 中服務端會針對每個頁面返回完整的 HTML 內容,對 SEO 更加友好;而 SPA 的頁面內容則需要執行 JS 才能拉取到,不利於 SEO。

3. 路由

MPA 在瀏覽器側其實不需要路由,每個頁面都在服務端都有一份 URL 地址,瀏覽器拿到 URL 直接請求服務端即可。

但 SPA 則不同,它需要 JS 掌管後續所有路由跳轉的邏輯,因此會引入一些路由方案來管理前端的路由,比如基於 hashchange 事件或者瀏覽器 history API 來實現。

4. 狀態管理

除了路由,SPA 另外一個複雜的點在於狀態管理。SPA 當中所有路由的狀態都是由 JS 進行管理,在不同的路由進行跳轉時通過 JS 代碼進行一些狀態的流轉,在頁面的規模越來越大的時候,狀態管理就變得越來越複雜了。因此,社區也誕生了不少的狀態管理方案,如傳統的 Redux、社區新秀 Valtio、Zustand 包括字節自研的 Reduck,都是爲了解決 SPA 狀態管理的問題,一方面降低操作的複雜度、另一方面引入一些規範和限制 (比如 Redux 中的 action 機制) 來提高項目可維護性。

而 MPA 則會簡單很多,因爲每個頁面之間都是相互獨立的,不需要在前端做複雜的狀態管理。

取捨

總而言之,MPA 有更好的首屏性能,SPA 在後續頁面的訪問中有更好的性能和體驗,但 SPA 也帶來了更高的工程複雜度、略差的首屏性能和 SEO。這樣就需要在不同的場景中做一些取捨。

不過,MPA 和 SPA 也並不是完全割裂的,兩者也是能夠有所結合的,比如 SSR/SSG 同構方案就是一個典型的體現,首先框架側會在服務端生成完整的 HTML 內容,並且同時注入客戶端所需要的 SPA 腳本。這樣瀏覽器會拿到完整的 HTML 內容,然後執行客戶端的腳本事件的綁定 (這個過程也叫 hydrate),後續路由的跳轉由 JS 來掌管。當下很多的框架都是採用這樣的方案,比如 Next.js、Gatsby、公司內部的 Eden SSR、Modern.js。

但實際上,把 MPA 和 SPA 結合的方案也並不是完美無缺的,主要的問題在於這類方案仍然會下載全量的客戶端 JS 及執行全量的組件 Hydrate 過程,造成頁面的首屏 TTI 劣化。

我們可以試想對於一個文檔類型的站點,其實裏面的大多數組件是不需要交互的,主要以靜態頁面的渲染爲主,因此直接採用 MPA 方案是一個比 MPA + SPA 更好的一個選擇。進一步講,對於更多的輕交互、重內容的應用場景,MPA 也依然是一個更好的方案。

由於頁面中有時仍然不可避免的需要一些交互的邏輯,那放在 MPA 中如何來完成呢?這就是 Islands 架構所要解決的問題。

什麼是 Islands 架構?

Islands 架構模型早在 2019 年就被提出來了,並在 2021 年被 Preact 作者Json Miller 在 Islnads Architecture 一文中得到推廣。這個模型主要用於 SSR (也包括 SSG) 應用,我們知道,在傳統的 SSR 應用中,服務端會給瀏覽器響應完整的 HTML 內容,並在 HTML 中注入一段完整的 JS 腳本用於完成事件的綁定,也就是完成 hydration (注水) 的過程。當注水的過程完成之後,頁面也才能真正地能夠進行交互。

當一個頁面中只有部分的組件交互,那麼對於這些可交互的組件,我們可以執行 hydration 過程,因爲組件之間是互相獨立的。

而對於靜態組件,即不可交互的組件,我們可以讓其不參與 hydration 過程,直接複用服務端下發的 HTML 內容。

可交互的組件就猶如整個頁面中的孤島 (Island),因此這種模式叫做 Islands 架構。

Islands 實現原理

Astro

https://astro.build/

在 Astro 中,默認所有的組件都是靜態組件,比如:

// index.astro
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent />

這種寫法不會在瀏覽器添加任何的 JS 代碼。但有時我們需要在組件中綁定一些交互事件,那麼這時就需要激活孤島組件了,在使用組件時加上client:load指令即可:

// index.astro
---
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent client:load />

Astro 除了支持本身 Astro 語法之外,也支持 Vue、React 等框架,可以通過插件的方式來導入。在構建的時候,Astro 只會打包並注入 Islands 組件的代碼,並且在瀏覽器渲染,分別調用不同框架 (Vue、React) 的渲染函數完成各個 Islands 組件的 hydrate 過程。

Astro 是典型的 MPA 方案,不支持引入 SPA 的路由和狀態管理。

Fresh

Fresh 是一個基於 Preact 和 Deno 的全棧框架,同時也主打 Islands 架構。它約定項目中的 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

Fresh 在渲染層核心主要做了以下的事情:

值得注意的是客戶端 hydrate 方法的實現,傳統的 SSR 一般都是直接對根節點調用 hydrate,而在 Islands 架構中,Fresh 對每個 Island 進行獨立渲染。

更多細節可以參考篇文章:深入解讀 Fresh

實踐分享

筆者基於 Islands 架構開發了一個文檔站方案 island.js。

大體定位是支持 Mdx 的類 VitePress 方案,目前也實現了 Islands + MPA 架構,接下來給大家分享一下這個方案是如何來實現 Islands 架構的。

使用 Island 組件

與 Astro 類似,Island.js 裏面默認採用 MPA 且 0 JS 的方案,如果存在存在交互的組件,在使用的時候傳入一個__island 標誌即可,比如:

import { Aside } from '../components/Aside';

export function Layout() {
  return <Aside __island />;
}

這樣在生產環境打包的過程中自動識別出 Islands 組件並打包,在 hydrate 的時候各自執行 hydration。

內部實現

總體流程如下:

1. SSR Runtime

指組件 renderToString 的過程,我們需要在這個運行時過程中搜集到所有的 Islands 組件。主要的實現思路是攔截組件創建的邏輯,在 React 中可以通過攔截 React.createElement 實現或者 jsx-runtime 來完成,Island.js 裏面實現了後者,通過自定義 jsx-runtime 來攔截 SSR 運行時:

// 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 構建階段轉換爲 `__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;

然後在 JSX 編譯階段,指定 jsxRuntime 參數爲我們自定義的路徑即可。

2. Build Time

Build Time 分爲兩個階段: renderToString 之前、renderToString 之後。

renderToString 之前會打兩份 bundle:

在  SSR bundle 生成過程中,我們會特殊處理 __island prop,它實際上是爲了標識該組件是一個 Islands 組件,但我們拿不到組件的路徑信息。爲了之後能夠順利打包 Islands 組件,我們需要在 SSR 構建過程中將 __isalnd 進行轉換,使之帶上路徑信息。比如下面有兩個組件:

// Layout.tsx
import { Aside } from './Aside.tsx';

export function Layout() {
  return (
    <div>
      <Aside __island a={1} />
    </div>
  )
}

// Aside.tsx
export function Aside() {
  return <div>內容省略...</div>
}

可以看到 Layout 組件中通過<Aside __island /> 的方式來使用 Aside 組件,標識其爲一個 Islands 組件。那麼我們將會在 SSR 編譯過程中用 babel 插件改寫這個 prop,原理如下:

<Aside __island />
// 被轉換爲
<Aside __island="./Aside.tsx!!island!!Users/project/src/Layout.tsx" />

這樣,在 renderToString 過程中,我們就能記錄下 Islands 組件所在的文件路徑。當 renderToString 完成之後,我們可以通過自定義的 jsx-runtime 模塊拿到如下的數據:

{
  islandProps: [ { a: 1 } ],
  islandToPathMap: {
    Aside: './Aside.tsx!!island!!Users/project/src/Layout.tsx'
  }
}

之後在 Build Time 會做兩件事情:

  1. 將 islandProps  的數據作爲 id 爲island-props 的  script 標籤注入到  HTML 中;

  2. 根據 islandToPathMap 的信息構造虛擬模塊,打包所有的 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

在客戶端渲染階段,我們僅需要少量的腳本來激活 Islands 組件:

  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>
    );
  }

由此,我們便在 React 實現了 Islands 架構,在實際的頁面渲染過程中,瀏覽器僅需請求 React + 少量組件的代碼甚至是 0 js。SSG + SPA 方案和 Islands 架構的頁面加載情況對比如下:

SSG + SPA

SSG + Islands architecture(MPA)

uH2dEE

Islands 架構的適用性

1. 框架無關

Island 架構的實現其實是可以做到框架無關的。從 SSR Runtime、Build Time  到 Client Runtime,整個環節中關於 React 的部分,我們都可以替換成其它框架的實現,這些部分包括:

因此,不光是 React,對於 Vue、Preact、Solidjs 這些框架中都可以實現 Islands 架構。因此,在 Island.js 中兼容除 React 的其它框架也是原理上完全可行的。

並且考慮到 React 的包體積問題,後續 Island.js 考慮適配其它的框架,如 Solid,體積相比 React 可以減少 90%:

數據來源: https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f

2. VitePress 的特殊優化

關於是否需要支持 Vue,這裏就不得不提到目前基於 Vue  框架的文檔方案 VitePress 了,Vue 官網現已接入 VitePress 方案,那基於 VitePress 是否需要做 Islands 架構的優化呢?

答案是不需要。VitePress 內部使用的是 Shell  架構,以 Vue 官網爲例:

VitePress 會在 hydrate 的過程中把正文的靜態部分排除,具體實現原理如下:

由於 VitePress 採用的是 SSG + SPA 模式,其會根據是否爲首屏來分發不同的 JS:

你可能會問了,在 .lean.js 裏面,組件的代碼都被改了,難道 Vue 在 hydrate 不會發現內容和服務端渲染的 HTML 對應不上進而報錯嗎?答案是不會,我們可以看看 Vue 裏面 createStaticVNode 的實現:

注意第二個傳參,裏面會記錄靜態節點的數量,在 hydrate 的過程中對靜態節點會特殊處理,直接檢查 staticCount即節點數量而不是內容,那麼對於如下的 VNode 節點來講 hydrate 仍然是可以成功的:

// recommend.[hash].lean.js
const html = ` A <span>foo</span> B`
const { vnode, container } = mountWithHydration(html, () =>
// 保證第二個參數正確即可
  createStaticVNode(``, 3)
)

總之, VitePress 利用 Vue 的編譯時優化以及內部定製的 Hydrate 方案足以解決傳統 SSG 的全量 hydration 問題,採用 Islands 架構意義並不大。

那進一步講,像 Vue 這種 Shell 優化方案對於包含編譯時的前端框架是否通用?這裏我們可以先大概總結出 Shell 方案需要滿足的條件:

基於上面這兩點,其他的代表性編譯時框架如SolidSvelte 很難實現 Vue 的 Shell 架構 (沒法標記靜態節點),因此 Shell 方案可以理解爲在 Vue 框架下的一個特殊優化了。對於 Vue  外的其它框架方案,仍然可以採用 Islands 進行特定場景的優化。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/MfztwYyEH30F9IL0keAM5w