翻譯自 Deno 團隊的 Fresh 原文:A Gentle Introduction to Islands
現代的 JavaScript Web 框架包含了大量 JavaScript。
但是大部分網站並不需要包含那麼多 JavaScript。但是有些網站是需要的。如果你正在開發一個動態地、互動式的儀表盤,你可以盡情地使用 JavaScript。另一方面,文檔頁面、博客、靜態內容網站等等不需要任何 JavaScript。例如,這篇博客的原文就沒有包含任何 JavaScript。
但是,還有很多網站處於一種中間狀態,它們需要一些互動性,但不是太多:
這些 "Goldilocks" (恰到好處) 的網站恰好是現在框架的問題所在:你不能靜態地生成這些頁面,但為了一個圖片輪播按鈕,而將整個框架打包並通過網絡發送給用戶,似乎過於浪費。我們可以為這類網站做什麼呢?
給他們 islands 架構。
什麼是 Islands?#
這個是我們的 商品網站,它使用 Fresh 開發,一個使用 Deno 開發的基於 Islands 架構 Web 框架。
這個頁面的主要內容是靜態的 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 加載完成頁面擁有互動能力。
水合就像這幅圖一樣:
構建階段從你的應用中提取出所有精華部分,留下一个干瘪的外壳。然後,你可以將這個干瘪的外壳和單獨的水一起發送,由客戶端的 Black & Decker hydrator 瀏覽器進行組合。這會帶給你一個可食用的披薩 / 可用的網站(感謝這個 SO 回答的類比)。
這樣做的問題是什麼?水合將頁面視為一個單獨的組件。水合自上而下地進行,遍歷整個 DOM 樹尋找需要被水合的節點。即使你在開發中將應用分解為組件,但這些信息在水合時會被丟棄,所有東西會被打包在一起發布。
這些框架開發的應用還會發送框架自帶的 JavaScript。如果我們創建一個新的 Next 應用,移除所有東西僅在首頁保留一個 h1
標籤,我們仍然會發現 JavaScript 被發送到了客戶端,包含一個 JavaScript 版本的 h1
渲染函數,即使構建階段知道這個頁面可以被靜態生成。
代碼分割(code-splitting)和漸進式水合(progressive hydration)是解決這個基礎問題的變通手段。它們將原本打包的代碼和水合過程分割為單獨的塊或者步驟。這可以使得頁面獲得互動能力的速度變快,因為你可以在剩餘部分下載完成前,就開始第一個塊的水合。
但是,你還是將所有的 JavaScript 發送到了可能並不需要使用它的客戶端,並且必須對它進行處理以便之後的使用。
Fresh 中的 Islands 架構#
如果我們在基於 Deno 的 Web 框架 Fresh 中做類似的事情,我們會發現應用沒有 JavaScript。
這個頁面上沒有任何東西需要 JavaScript,所有沒有 JavaScript 被發送。
現在讓我們以 island 的形式添加一些 JavaScript。
所以我們有了 3 個 JavaScript 文件:
chunk-A2AFYW5X.js
island-counter.js
main.js
為了演示這些 JavaScript 文件是如何產生的,這是請求接收後發生了什麼的時間線。
渲染一個 Fresh 應用:
- 服務器端:
- Fresh 邊緣服務器接收到一個 HTTP 請求;
- Fresh 從 manifest 文件中定位到 islands;
- 創建 vnodes,Preact 定位 “island” 節點並為其添加相應 HTML 注釋;
- 所需要的 JavaScript 文件被生成和打包,準備發送給客戶端;
- 服務器發送 HTML 和水合需要的 JavaScript 文件;
- 客戶端:
- 瀏覽器接收到 HTML 並緩存所有靜態資源,包含 JavaScript;
- 瀏覽器運行
main.js
,遍歷所有的 islands,遍歷 DOM 樹尋找 HTML 注釋,然後對它們進行水合; - 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.js
(main.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 不是架構或者框架的責任,而是你作為開發者的責任。