Deno チームの Fresh の原文から翻訳:A Gentle Introduction to Islands
現代の JavaScript Web フレームワークは大量の JavaScript を含んでいます。
しかし、ほとんどのウェブサイトはそれほど多くの JavaScript を必要としません。しかし、いくつかのウェブサイトは必要です。もしあなたが動的でインタラクティブなダッシュボードを開発しているなら、JavaScript を存分に使うことができます。一方で、ドキュメントページ、ブログ、静的コンテンツのウェブサイトなどは、JavaScript を必要としません。例えば、このブログの原文には、JavaScript が含まれていません。
しかし、インタラクティブ性が必要でありながら、それほど多くはない中間的な状態にあるウェブサイトもたくさんあります:
これらの「ゴルディロックス」(ちょうど良い)なウェブサイトは、現在のフレームワークの問題点です:これらのページを静的に生成することはできませんが、画像スライドショーボタンのために、全体のフレームワークをパッケージ化してユーザーに送信するのは無駄に思えます。このようなウェブサイトのために何ができるでしょうか?
彼らに islands アーキテクチャ を提供します。
Islands とは何ですか?#
これは私たちの 商品サイト、Deno を使用して開発された Islands アーキテクチャに基づく Web フレームワークである Fresh を使用しています。
このページの主な内容は静的な HTML です:ヘッダーとフッター、タイトル、リンク、テキスト。これらはインタラクティブな機能を必要としないため、JavaScript は使用されていません。しかし、このページ上の 3 つの要素はインタラクションが必要です:
- 「カートに追加」ボタン
- 画像スライドショー
- カートボタン
これらが islands です。Islands は隔離された Preact コンポーネント であり、クライアントと静的にレンダリングされた HTML で水和(hydration)されます。
- 隔離:これらのコンポーネントは独立して作成され、発行され、ページ内の他の部分とは無関係です;
- Preact:わずか 3kb のサイズの React の代替品であり、Fresh が islands を発行しても、最小限の JS しか使用しません;
- 水和:サーバーからクライアントページに JavaScript を追加する方法;
- 静的にレンダリングされた HTML ページ:JavaScript のない基本的な HTML がサーバーからクライアントに送信され、ページに islands が使用されていない場合は、HTML のみが送信されます。
最も重要な部分は水和です。これは JavaScript フレームワークが解決しようとしている問題であり、フレームワークの機能の基礎ですが、同時に水和は純粋なオーバーヘッドです。
JavaScript フレームワークはページを水和しますが、Islands フレームワークはコンポーネントを水和します。
水和の問題 - 「水和レベル 4 をお願いします」#
なぜ Islands アーキテクチャを使用しないと非常に多くの JavaScript が送信されるのでしょうか?それは、現代の「メタ」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 水和器ブラウザによって組み合わされます。これにより、食べられるピザ / 使用可能なウェブサイトが得られます(この SO の回答の類推 に感謝)。
これを行うことの問題は何ですか?水和はページを単一のコンポーネントとして扱います。水和は上から下に行われ、必要なノードを探して DOM ツリー全体を走査します。アプリをコンポーネントに分解していても、これらの情報は水和時に失われ、すべてが一緒にパッケージ化されて発行されます。
これらのフレームワークで開発されたアプリは、フレームワーク自体の JavaScript も送信します。新しい Next アプリを作成し、すべてを削除してホームページに h1
タグだけを残しても、JavaScript がクライアントに送信され、JavaScript バージョンの h1
レンダリング関数が含まれていることがわかります。ビルド段階でこのページが静的に生成できることを知っていてもです。
コード分割(code-splitting)と漸進的水和(progressive hydration)は、この基本的な問題を解決するための回避策です。これらは、元々パッケージ化されたコードと水和プロセスを個別のブロックまたはステップに分割します。これにより、ページがインタラクティブな機能を得る速度が向上します。なぜなら、残りの部分がダウンロードされる前に、最初のブロックの水和を開始できるからです。
しかし、あなたは依然としてすべての JavaScript を、必要ないかもしれないクライアントに送信し、それを後で使用するために処理しなければなりません。
Fresh における Islands アーキテクチャ#
もし私たちが Deno ベースの Web フレームワーク Fresh で同様のことを行うと、アプリには JavaScript がないことがわかります。
このページには JavaScript が必要なものは何もなく、すべての JavaScript が送信されていません。
では、いくつかの JavaScript を island の形式で追加してみましょう。
これで、3 つの JavaScript ファイルができました:
chunk-A2AFYW5X.js
island-counter.js
main.js
これらの JavaScript ファイルがどのように生成されるかを示すために、リクエスト受信後に何が起こったのかのタイムラインです。
Fresh アプリをレンダリングする:
- サーバー側:
- Fresh エッジサーバーが HTTP リクエストを受信;
- Fresh がマニフェストファイルから islands を特定;
- vnodes を作成し、Preact が「island」ノードを特定し、対応する HTML コメントを追加;
- 必要な JavaScript ファイルが生成され、パッケージ化され、クライアントに送信する準備が整う;
- サーバーが HTML と水和に必要な JavaScript ファイルを送信;
- クライアント:
- ブラウザが HTML を受信し、すべての静的リソース(JavaScript を含む)をキャッシュ;
- ブラウザが
main.js
を実行し、すべての islands を走査し、DOM ツリーを走査して HTML コメントを探し、それらを水和; - Islands は現在インタラクティブになりました。
このタイムラインは Fresh アプリの初回リクエストに対するものです。キャッシュされた静的リソースに対する後続のリクエストは、単にキャッシュから取得するだけで済みます。
いくつかの重要なステップを詳しく見て、islands がどのように機能するかを見てみましょう。
fresh.gen.ts
から islands のためにマニフェストをチェック#
最初のステップは、すべての islands を fresh.gen.ts
からマニフェストをチェックすることです。これはアプリが自動生成するドキュメントで、アプリ内のすべてのページと 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 フレームワークはマニフェストを異なるページ(ここでは表示されていません)とコンポーネントに処理します。任意の islands は islands 配列に渡されます。
// context.ts
// 例のために過度に単純化されています。
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 の再開可能性機能です。これにより、水和ステップが完全に削除され、JavaScript がパッケージ化された HTML にシリアル化されます。一度 HTML がクライアントに送信されると、アプリ全体が使用可能になり、すべてのインタラクティブな機能が含まれます。
Islands アーキテクチャのまとめ#
islands アーキテクチャと再開可能性機能を組み合わせることで、より少ない JavaScript を送信し、水和ステップを削除できる可能性があります。
しかし、islands アーキテクチャがもたらすのは、単にパッケージサイズが小さくなることだけではありません。Islands アーキテクチャの大きな利点は、開発プロセスにおけるメンタルモデルを提供することです。islands を使用すると、JavaScript が送信されるかどうかを選択する必要があります。不要な JavaScript をクライアントに送信することは決してありません。開発者がアプリを構築する際、各インタラクティブ機能の有無は、開発者の選択の結果であるべきです。
したがって、より少ない JavaScript を送信することは、アーキテクチャやフレームワークの責任ではなく、あなた自身が開発者としての責任です。