1. Islands 架構(gòu)原理和實踐

        共 11485字,需瀏覽 23分鐘

         ·

        2022-10-21 16:35

        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 更好。

        但 SPA 在后續(xù)頁面加載方面有更好的性能和體驗。因為 SPA 在完成首屏加載之后,在訪問其它的頁面時只需要動態(tài)加載頁面的一部分組件,而不是整個頁面。而且,當(dāng)頁面發(fā)生跳轉(zhuǎn)時,SPA 不會重新加載頁面,對用戶更友好。
        2. SEO
        MPA 中服務(wù)端會針對每個頁面返回完整的 HTML 內(nèi)容,對 SEO 更加友好;而 SPA 的頁面內(nèi)容則需要執(zhí)行 JS 才能拉取到,不利于 SEO。
        3. 路由
        MPA 在瀏覽器側(cè)其實不需要路由,每個頁面都在服務(wù)端都有一份 URL 地址,瀏覽器拿到 URL 直接請求服務(wù)端即可。
        但 SPA 則不同,它需要 JS 掌管后續(xù)所有路由跳轉(zhuǎn)的邏輯,因此會引入一些路由方案來管理前端的路由,比如基于 hashchange 事件或者瀏覽器 history API 來實現(xiàn)。
        4. 狀態(tài)管理
        除了路由,SPA 另外一個復(fù)雜的點(diǎn)在于狀態(tài)管理。SPA 當(dāng)中所有路由的狀態(tài)都是由 JS 進(jìn)行管理,在不同的路由進(jìn)行跳轉(zhuǎn)時通過 JS 代碼進(jìn)行一些狀態(tài)的流轉(zhuǎn),在頁面的規(guī)模越來越大的時候,狀態(tài)管理就變得越來越復(fù)雜了。因此,社區(qū)也誕生了不少的狀態(tài)管理方案,如傳統(tǒng)的 Redux、社區(qū)新秀 Valtio、Zustand 包括字節(jié)自研的 Reduck,都是為了解決 SPA 狀態(tài)管理的問題,一方面降低操作的復(fù)雜度、另一方面引入一些規(guī)范和限制(比如 Redux 中的 action 機(jī)制)來提高項目可維護(hù)性。
        而 MPA 則會簡單很多,因為每個頁面之間都是相互獨(dú)立的,不需要在前端做復(fù)雜的狀態(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)?

        Islands 架構(gòu)模型早在 2019 年就被提出來了,并在 2021 年被 Preact 作者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)行交互。
        當(dāng)一個頁面中只有部分的組件交互,那么對于這些可交互的組件,我們可以執(zhí)行 hydration 過程,因為組件之間是互相獨(dú)立的。
        而對于靜態(tài)組件,即不可交互的組件,我們可以讓其不參與 hydration 過程,直接復(fù)用服務(wù)端下發(fā)的 HTML 內(nèi)容。
        可交互的組件就猶如整個頁面中的孤島(Island),因此這種模式叫做 Islands 架構(gòu)。

        Islands 實現(xiàn)原理

        Astro

        https://astro.build/

        在 Astro 中,默認(rèn)所有的組件都是靜態(tài)組件,比如:

        // 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 等框架,可以通過插件的方式來導(dǎo)入。在構(gòu)建的時候,Astro 只會打包并注入 Islands 組件的代碼,并且在瀏覽器渲染,分別調(diào)用不同框架(Vue、React)的渲染函數(shù)完成各個 Islands 組件的 hydrate 過程。

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

        Fresh

        Fresh 是一個基于 Preact 和 Deno 的全棧框架,同時也主打 Islands 架構(gòu)。它約定項目中的 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 在渲染層核心主要做了以下的事情:
        • 通過掃描 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,返回給前端。
        值得注意的是客戶端 hydrate 方法的實現(xiàn),傳統(tǒng)的 SSR 一般都是直接對根節(jié)點(diǎn)調(diào)用 hydrate,而在 Islands 架構(gòu)中,F(xiàn)resh 對每個 Island 進(jìn)行獨(dú)立渲染。
        更多細(xì)節(jié)可以參考篇文章:深入解讀 Fresh

        實踐分享

        筆者基于 Islands 架構(gòu)開發(fā)了一個文檔站方案 island.js。
        • 倉庫: https://github.com/sanyuan0704/island.js
        大體定位是支持 Mdx 的類 VitePress 方案,目前也實現(xiàn)了 Islands + MPA 架構(gòu),接下來給大家分享一下這個方案是如何來實現(xiàn) Islands 架構(gòu)的。

        使用 Island 組件

        與 Astro 類似,Island.js 里面默認(rèn)采用 MPA 且 0 JS 的方案,如果存在存在交互的組件,在使用的時候傳入一個__island 標(biāo)志即可,比如:
        import { Aside } from '../components/Aside';

        export function Layout({
          return <Aside __island />;
        }
        這樣在生產(chǎn)環(huán)境打包的過程中自動識別出 Islands 組件并打包,在 hydrate 的時候各自執(zhí)行 hydration。

        內(nèi)部實現(xiàn)

        總體流程如下:
        1. SSR Runtime
        指組件 renderToString 的過程,我們需要在這個運(yùn)行時過程中搜集到所有的 Islands 組件。主要的實現(xiàn)思路是攔截組件創(chuàng)建的邏輯,在 React 中可以通過攔截 React.createElement 實現(xiàn)或者 jsx-runtime 來完成,Island.js 里面實現(xiàn)了后者,通過自定義 jsx-runtime 來攔截 SSR 運(yùn)行時:
        // 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;
        然后在 JSX 編譯階段,指定 jsxRuntime 參數(shù)為我們自定義的路徑即可。
        2. Build Time
        Build Time 分為兩個階段: renderToString 之前、renderToString 之后。
        renderToString 之前會打兩份 bundle:
        • SSR bundle: 用于 renderToString
        • Client bundle: 客戶端 Runtime 代碼,用于激活頁面
        在  SSR bundle 生成過程中,我們會特殊處理 __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>
        }
        可以看到 Layout 組件中通過<Aside __island /> 的方式來使用 Aside 組件,標(biāo)識其為一個 Islands 組件。那么我們將會在 SSR 編譯過程中用 babel 插件改寫這個 prop,原理如下:
        <Aside __island />
        // 被轉(zhuǎn)換為
        <Aside __island="./Aside.tsx!!island!!Users/project/src/Layout.tsx" />
        這樣,在 renderToString 過程中,我們就能記錄下 Islands 組件所在的文件路徑。當(dāng) renderToString 完成之后,我們可以通過自定義的 jsx-runtime 模塊拿到如下的數(shù)據(jù):
        {
          islandProps: [ { a: 1 } ],
          islandToPathMap: {
            Aside: './Aside.tsx!!island!!Users/project/src/Layout.tsx'
          }
        }
        之后在 Build Time 會做兩件事情:
        1. 將 islandProps  的數(shù)據(jù)作為 id 為island-props 的  script 標(biāo)簽注入到  HTML 中;
        2. 根據(jù) islandToPathMap 的信息構(gòu)造虛擬模塊,打包所有的 Islands 組件。
        虛擬模塊內(nèi)容如下:
        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 實現(xiàn)了 Islands 架構(gòu),在實際的頁面渲染過程中,瀏覽器僅需請求 React + 少量組件的代碼甚至是 0 js。SSG + SPA 方案和 Islands 架構(gòu)的頁面加載情況對比如下:
        SSG + SPA
        SSG + Islands architecture(MPA)

        HTTP 請求資源(KB)FCP (s)DCL(s)
        SPA 模式4510.480.84
        Islands 模式1410.400.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)化
        關(guān)于是否需要支持 Vue,這里就不得不提到目前基于 Vue  框架的文檔方案 VitePress 了,Vue 官網(wǎng)現(xiàn)已接入 VitePress 方案,那基于 VitePress 是否需要做 Islands 架構(gòu)的優(yōu)化呢?
        答案是不需要。VitePress 內(nèi)部使用的是 Shell  架構(gòu),以 Vue 官網(wǎng)為例:

        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
        由于 VitePress 采用的是 SSG + SPA 模式,其會根據(jù)是否為首屏來分發(fā)不同的 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):
        注意第二個傳參,里面會記錄靜態(tài)節(jié)點(diǎn)的數(shù)量,在 hydrate 的過程中對靜態(tài)節(jié)點(diǎ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)化。


        瀏覽 72
        點(diǎn)贊
        評論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點(diǎn)贊
        評論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報
          
          

            1. 白洁王乙传说 | 成一人一黄一片免费版 | 婷婷亚洲视频 | 人善交精品一区二区三区 | 美女操逼视频免费观看 |