OneKuma

OneKuma's Blog

One Lonely Kuma.
github
bilibili
twitter

【翻譯】Islands 架構和 Fresh 框架簡介

翻譯自 Deno 團隊的 Fresh 原文:A Gentle Introduction to Islands

現代的 JavaScript Web 框架包含了大量 JavaScript。

但是大部分網站並不需要包含那麼多 JavaScript。但是有些網站是需要的。如果你正在開發一個動態地、互動式的儀表盤,你可以盡情地使用 JavaScript。另一方面,文檔頁面、博客、靜態內容網站等等不需要任何 JavaScript。例如,這篇博客的原文就沒有包含任何 JavaScript。

但是,還有很多網站處於一種中間狀態,它們需要一些互動性,但不是太多:

goldilocks-of-javascript

這些 "Goldilocks" (恰到好處) 的網站恰好是現在框架的問題所在:你不能靜態地生成這些頁面,但為了一個圖片輪播按鈕,而將整個框架打包並通過網絡發送給用戶,似乎過於浪費。我們可以為這類網站做什麼呢?

給他們 islands 架構

什麼是 Islands?#

這個是我們的 商品網站,它使用 Fresh 開發,一個使用 Deno 開發的基於 Islands 架構 Web 框架。

islands-from-merch-shop

這個頁面的主要內容是靜態的 HTML:頁眉和頁腳、標題、鏈接和文本。這些都不需要互動能力,因此沒有使用任何 JavaScript。但是,該頁面上的三個元素需要進行互動:

  • "Add to Cart" 按鈕
  • 圖片輪播圖
  • 購物車按鈕

這些就是 islands。Islands 是隔離的 Preact 组件,然後會在客戶端和靜態渲染的 HTML 進行水合(hydration)。

  • 隔離:這些組件是獨立編寫和發布的,與頁面中的其他部分無關;
  • Preact:一個僅有 3kb 大小的 React 替代,所以即使 Fresh 正在發布 islands,它仍然僅使用最少量的 JS;
  • 水合:如何將 JavaScript 從服務器渲染添加到客戶端頁面;
  • 靜態渲染的 HTML 頁面:沒有 JavaScript 的基本 HTML 會從服務器發送到客戶端,如果頁面上沒有使用 islands,那麼只會發送 HTML。

其中最關鍵的部分是水合。這是 JavaScript 框架正在努力解決的問題,因為這是框架工作的基礎,但同時水合是純粹的開銷

JavaScript 框架水合頁面,但是 Islands 框架水合的是組件。

水合的問題 - “Hydrate level 4, please”#

為什麼沒有使用 Islands 架構時,會發送非常多的 JavaScript?因為這是現代 “meta” JavaScript 框架的工作方式。你可以使用框架來創建你的內容,並為頁面添加互動能力,分別發送它們,然後在瀏覽器裡使用一種叫 “水合” 的機制將它們合併。

在開始時,這些東西是分離的。你有一個服務端的框架來生成 HTML(PHP,Django,再到 NodeJS)和一個客戶端的插件來提供互動能力(最常見的是 jQuery)。然後,我們來到了 React SPA 的領域,所有事情變成了在客戶端一側。你發布了一個基本的 HTML 框架,而整個網站,包括內容、數據和互動能力都在客戶端生成。

後來,頁面越來越大,SPA 變得越來越慢。服務端渲染又回來了,但是通過相同的代母添加互動能力,而不是一個另外的插件。你使用 JavaScript 創建整個應用,然後在構建階段,互動能力和應用的初始狀態(組件的狀態以及從 API 服務器獲取的任何數據)被序列化並打包成 JavaScript 和 JSON。

當一個頁面被請求時,服務器端會發送 HTML 以及互動能力和狀態所需要的打包後的 JavaScript。之後,客戶端會 “水合” JavaScript,也就是:

  • 從根節點遍歷整個 DOM 樹;
  • 對於每個 DOM 節點,如果它是可互動的,就為它添加事件監聽器,設置初始狀態然後重新渲染。如果節點不需要互動,復用原始 DOM 中的節點進行調和(reconcile)。

使用這種方式 HTML 會被迅速地顯示出來,用戶不需要盯著一個白屏,等待 JavaScript 加載完成頁面擁有互動能力。

水合就像這幅圖一樣:

Back to the Future, Part 2

Back to the Future, Part 2

構建階段從你的應用中提取出所有精華部分,留下一个干瘪的外壳。然後,你可以將這個干瘪的外壳和單獨的水一起發送,由客戶端的 Black & Decker hydrator 瀏覽器進行組合。這會帶給你一個可食用的披薩 / 可用的網站(感謝這個 SO 回答的類比)。

這樣做的問題是什麼?水合將頁面視為一個單獨的組件。水合自上而下地進行,遍歷整個 DOM 樹尋找需要被水合的節點。即使你在開發中將應用分解為組件,但這些信息在水合時會被丟棄,所有東西會被打包在一起發布。

這些框架開發的應用還會發送框架自帶的 JavaScript。如果我們創建一個新的 Next 應用,移除所有東西僅在首頁保留一個 h1 標籤,我們仍然會發現 JavaScript 被發送到了客戶端,包含一個 JavaScript 版本的 h1 渲染函數,即使構建階段知道這個頁面可以被靜態生成。

hello-from-next

代碼分割(code-splitting)和漸進式水合(progressive hydration)是解決這個基礎問題的變通手段。它們將原本打包的代碼和水合過程分割為單獨的塊或者步驟。這可以使得頁面獲得互動能力的速度變快,因為你可以在剩餘部分下載完成前,就開始第一個塊的水合。

但是,你還是將所有的 JavaScript 發送到了可能並不需要使用它的客戶端,並且必須對它進行處理以便之後的使用。

Fresh 中的 Islands 架構#

如果我們在基於 Deno 的 Web 框架 Fresh 中做類似的事情,我們會發現應用沒有 JavaScript。

hello-from-fresh-no-island

這個頁面上沒有任何東西需要 JavaScript,所有沒有 JavaScript 被發送。

現在讓我們以 island 的形式添加一些 JavaScript。

hello-from-fresh-with-island

所以我們有了 3 個 JavaScript 文件:

  • chunk-A2AFYW5X.js
  • island-counter.js
  • main.js

為了演示這些 JavaScript 文件是如何產生的,這是請求接收後發生了什麼的時間線。

request-timeline

渲染一個 Fresh 應用:

  1. 服務器端:
    1. Fresh 邊緣服務器接收到一個 HTTP 請求;
    2. Fresh 從 manifest 文件中定位到 islands;
    3. 創建 vnodes,Preact 定位 “island” 節點並為其添加相應 HTML 注釋;
    4. 所需要的 JavaScript 文件被生成和打包,準備發送給客戶端;
    5. 服務器發送 HTML 和水合需要的 JavaScript 文件;
  2. 客戶端:
    1. 瀏覽器接收到 HTML 並緩存所有靜態資源,包含 JavaScript;
    2. 瀏覽器運行 main.js,遍歷所有的 islands,遍歷 DOM 樹尋找 HTML 注釋,然後對它們進行水合;
    3. Islands 現在可以互動了。

注意這個時間線是對於一個 Fresh 應用的首次請求。對於已經緩存的靜態資源,後續的請求只需要簡單地從緩存中檢索

讓我們深入一些關鍵步驟來看看 islands 是如何工作的。

fresh.gen.ts 中為 islands 檢查 manifest#

第一步定位所有 islands 需要從 fresh.gen.ts 檢查 manifest。這是一個你的應用自動生成的文檔,它可以列出應用中的所有頁面和 islands。

// fresh.gen.ts

import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/index.tsx";
import * as $$0 from "./islands/Counter.tsx";

const manifest = {
  routes: {
    "./routes/index.tsx": $0,
  },
  islands: {
    "./islands/Counter.tsx": $$0,
  },
  baseUrl: import.meta.url,
  config,
};

export default manifest;

Fresh 框架會將 manifest 清單處理成不同的頁面(此處沒有展示)和組件。任何 islands 會被傳入一個 islands 陣列。

// context.ts

// Overly simplified for sake of example.
for (const [self, module] of Object.entries(manifest.islands)) {
  const url = new URL(self, baseUrl).href;
  if (typeof module.default !== "function") {
    throw new TypeError(
      `Islands must default export a component ('${self}').`,
    );
  }
  islands.push({ url, component: module.default });
}

在服務端渲染時將每個 island 替換為唯一的 HTML 注釋#

render.ts 進行服務端渲染時,Preact 創建了一個虛擬 DOM。既然每個虛擬 DOM 都會被創建,因此 Preact 的 options.vnode.hook 會被調用。

// render.ts

options.vnode = (vnode) => {
  assetHashingHook(vnode);
  const originalType = vnode.type as ComponentType<unknown>;
  if (typeof vnode.type === "function") {
    const island = ISLANDS.find((island) => island.component === originalType);
    if (island) {
      if (ignoreNext) {
        ignoreNext = false;
        return;
      }
      ENCOUNTERED_ISLANDS.add(island);
      vnode.type = (props) => {
        ignoreNext = true;
        const child = h(originalType, props);
        ISLAND_PROPS.push(props);
        return h(
          `!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`,
          null,
          child,
        );
      };
    }
  }
  if (originalHook) originalHook(vnode);
};

動態生成的水合腳本#

下一步是基於檢測到的 islands 生成水合腳本,即根據所有被加入集合 ENCOUNTERED_ISLANDS 的 islands。

render.ts 中,如果 ENCOUNTERED_ISLANDS 不是一個空集,那麼我們會為即將發送到客戶端的水合腳本,添加從 main.js 導入的 revive 函數的語句。

if (ENCOUNTERED_ISLANDS.size > 0) {
  // ...
  script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;

注意到如果 ENCOUNTERED_ISLANDS 是空集,那麼整個 islands 部分的處理會被跳過,並且沒有個 JavaScript 會被發送到客戶端。

然後,render 函數會將每個 island 的 JavaScript(/island-${island.id}.js)添加到陣列中,同時將相應的 import 語句添加到 script 裡。

//render.ts, continued
  let islandRegistry = "";
  for (const island of ENCOUNTERED_ISLANDS) {
    const url = bundleAssetUrl(`/island-${island.id}.js`);
    script += `import ${island.name} from "${url}";`;
    islandRegistry += `${island.id}:${island.name},`;
  }
  script += `revive({${islandRegistry}}, STATE[0]);`;
}

render 函數的最後,所有 import 語句合成的 script 字符串和 revive() 函數,會被添加到 HTML 中。除此之外,每個 island 的 JavaScript 的 URL 路徑的 import 陣列會被渲染為 HTML 字符串。

下面是 script 字符串在被加載到瀏覽器中的樣子。

<script type="module">
const STATE_COMPONENT = document.getElementById("__FRSH_STATE");
const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");
import { revive } from "/_frsh/js/1fx0e17w05dg/main.js";
import Counter from "/_frsh/js/1fx0e17w05dg/island-counter.js";
revive({counter:Counter,}, STATE[0]);
</script>

為了方便查看,語句之間添加了換行符。

當這個字符串被瀏覽器加載後,它將會從 main.js 里運行 revive 方法去水合 Counter island。

瀏覽器運行 revive#

main.jsmain.ts 壓縮後的版本)中定義了 revive 函數。它會遍歷虛擬 DOM,搜索正則表達式匹配 Fresh 在之前步驟中添加的 HTML 注釋。

// main.js

function revive(islands, props) {
  function walk(node) {
    let tag = node.nodeType === 8 &&
        (node.data.match(/^\s*frsh-(.*)\s*$/) || [])[1],
      endNode = null;
    if (tag) {
      let startNode = node,
        children = [],
        parent = node.parentNode;
      for (; (node = node.nextSibling) && node.nodeType !== 8;) {
        children.push(node);
      }
      startNode.parentNode.removeChild(startNode);
      let [id, n] = tag.split(":");
      re(
        ee(islands[id], props[Number(n)]),
        createRootFragment(parent, children),
      ), endNode = node;
    }
    let sib = node.nextSibling,
      fc = node.firstChild;
    endNode && endNode.parentNode?.removeChild(endNode),
      sib && walk(sib),
      fc && walk(fc);
  }
  walk(document.body);
}
var originalHook = d.vnode;
d.vnode = (vnode) => {
  assetHashingHook(vnode), originalHook && originalHook(vnode);
};
export { revive };

如果我們查看 index.html,會發現下面能夠匹配 revive 函數裡正則表達式的注釋:

<!--frsh-counter:0-->

revive 函數發現了這個注釋,它會調用 createRootFragment 使用 Preact 的 render / h 函數來渲染這個組件。

現在客戶端擁有一個可以互動的 island,可以立即使用!

其他框架中的 Islands#

Fresh 不是使用 islands 架構的唯一框架。Astro 也基於 islands 架構,但是使用了不同配置,你可以指定每個組件如何加載 JavaScript。例如,這個組件不需要加在 JavaScript。

<MyReactComponent />

但是,你可以添加一個 client 指令,現在它會加載 JavaScript。

<MyReactComponent client:load />

其它框架例如 Marko 使用了部分水合。它和 islands 之間的區別是微妙的。

在 Islands 中,開發人員明確知道哪些組件將會被水合,哪些不會。例如,Fresh 中,僅在具有 CamelCase 或 kebab-case 命名的 islands 目錄中的組件才會發送 JavaScript。

在部分水合中,組件是和正常一樣編寫的,而框架會在構建過程中確定哪些 JavaScript 會被發送。

另外一個解決這個問題的答案是 React 服務端組件,他支持了 NextJS 的新 /app 目錄結構。這有助於更清晰地定義哪些在工作服務端進行,哪些在客戶端進行,儘管它是否減少了發送的 JavaScript 數量仍然有待商榷

除了 islands 架構外最令人興奮的發展是 Qwik 的 resumability 特性。它們將水合步驟完全移除,取而代之的是將 JavaScript 序列化到打包 的 HTML 中。一旦 HTML 發送到客戶端,整個應用就都可以使用,包括所有互動能力。

Islands 架構總結#

將 islands 架構和 resumability 特性結合在一起可能可以發送更少的 JavaScript,並且移除掉水合步驟。

但是,islands 架構帶來的不僅僅是更小的打包體積。Islands 架構的一個巨大的好處是,它帶給你開發過程中的心智模型。使用 islands,你必須選擇 JavaScript 是否被發送。你永遠不要錯誤地將不必要的 JavaScript 發送到客戶端裡。當開發者構建一個應用時,每個互動能力的包含與否都應該是開發者的選擇後的結果。

因此,發送更少的 JavaScript 不是架構或者框架的責任,而是你作為開發者的責任。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。