uttk's site/articles/Vite を使ってアイランドアーキテクチャを作ってみる

Vite を使ってアイランドアーキテクチャを作ってみる

この記事について

Web アプリケーションを構築するためのアプローチとしてアイランドアーキテクチャ( Islands Architecture )というモノがあります。

Islands Architecture - JASON Format

jasonformat.com

簡単に言ってしまうと「必要な時に必要なコンポーネントを表示する」という感じで、最近だと React Server Components とか Next.js の Partial Pre-Renderin が近い感じですかね。

んで、最近このアイランドアーキテクチャを Vite を使って実装してみる動画を見つけました 👇

DIY Islands Architecture with Vite, Ben Holmes, ViteConf 2022 - YouTube

“Islands architecture” is a fast-growing trend. Let’s see how it works at a fundamental level! We’ll build our own “vite-land” web component to render JSX with Preact, and only ship client-side JS when and where it counts

www.youtube.com

内容を見ると結構簡単そうだったので、今回勉強がてら作ってみようと思います 💪

準備: Web アプリケーションを実装する

これからアイランドアーキテクチャを実装していきますが、その前にアイランドアーキテクチャを動かすための Web アプリケーションが必要になりますので、Preact + Vite で簡単な Web アプリケーションを実装していきます。

ディレクトリ構想は以下のようになります 👇

関係あるファイルのみ記述しています
./
├── src/
   ├── components/
   └── App.tsx      
   ├── main.tsx
   └── main.css 
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.vite.json
└── vite.config.ts

上記でファイル構造を構造を踏まえて、まずは Vite の設定から行います。

Vite の設定

始めに必要なパッケージをインストールします。

$> pnpm add -D vite typescript @preact/preset-vite

インストールができたら、tsconfig.json を以下のように実装します👇

./tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.vite.json" }]
}

次に tsconfig.vite.json を以下のように実装します👇

./tsconfig.vite.json
{
  "compilerOptions": {
    "composite": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

TypeScript の設定ファイルが実装できたら、次は Vite の設定を実装します。以下のように設定しましょう👇

./vite.config.ts
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";

// https://vitejs.dev/config/
export default defineConfig({
	plugins: [preact()],
});

はい、これで Vite を使って Preact をプレビューできるようになりました 🙌
次は Preact を使って Web アプリケーションを実装してきます🍢

Preact を使って Web アプリケーションを実装する

まずは必要なパッケージをインストールします。

$> pnpm add preact

パッケージがインストール出来たら、次は App コンポーネントを実装してきましょう 👇

./src/components/App.tsx
import { useState } from "preact/hooks";

export default function App() {
  const [count, setCount] = useState(0);

  console.log("<App />が表示されました");

  return (
    <button onClick={() => setCount((count) => count + 1)}>
      count is {count}
    </button>
  );
}

実装すると Preact の JSX 型が解決できずに型エラーが発生していると思いますので、preact.d.ts を追加して型エラーを解消しましょう 👇

./src/preact.d.ts
import JSX = preact.JSX

はい。App コンポーネントが実装できたら、次は App コンポーネントを描画するための main.tsx を実装します 👇

./src/main.tsx
import { render } from "preact";
import App from "./components/App";

const root = document.getElementById("root");

if (!root) {
  throw new Error("ルート要素がありません");
}

render(<App />, root);

また、このままだと見栄えが悪いので簡単な CSS も実装します 👇

./src/main.css
:root {
  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 24px;
  font-weight: 400;

  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  margin-block: 4rem;
  display: grid;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

.margin-section {
  display: grid;
  place-content: center;
  min-height: 100vh;
}

button {
  color: rgba(255, 255, 255, 0.87);
  border-radius: 8px;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #4983f0;
  cursor: pointer;
  transition: background-color 0.25s;
  border: none;
}

button:hover {
  background-color: #2f5cb1;
}

これで描画に必要なファイルを全て実装できたので、最後に実装してきたファイルを描画するための index.html を以下のように実装します👇

./index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Islands Architecture Example</title>

    <!-- ここで上で実装した CSS ファイルを読み込む -->
    <link rel="stylesheet" href="./src/main.css" />
  </head>

  <body>
    <h1>Vite + Preact Examples</h1>

    <!-- <App /> を画面外に出すための余白を作る要素 -->
    <div class="margin-section">スクロールするとボタンがあります👇</div>

    <!-- この要素に <App /> を描画します -->
    <div id="root"></div>
  </body>

  <!-- 上で実装したコンポーネントを描画するためのスクリプト -->
  <script type="module" src="./src/main.tsx"></script>
</html>

動作確認

ここまで実装できたら、pnpm を使っている場合は pnpm dev を実行すると開発サーバーが立ちあがるので、
以下のようなボタンを押してカウントアップできる Web アプリケーションが表示されていれば成功です🎉

はい、ここまでで準備は完了です。次はアイランドアーキテクチャを実装して行きます✨

アイランドアーキテクチャを実装する

Web アプリケーションの実装ができたら、次はいよいよアイランドアーキテクチャを実装します。

実装内容としては以下の通りです 👇

  • Web Components を実装する
  • Web Components と IntersectionObserver を使って表示するタイミングを計算する
  • 表示するタイミングがきたら指定されたコポーネントを描画する

上記の内容を踏まえて ./src/main.tsx を以下の内容で上書きします 👇

./src/main.tsx
import { render } from "preact";

/**
 * Island Architecture で表示するコンポーネント
 */
const islands: Record<string, () => any> = {
  App: () => import("./components/App"),
} as const;

/**
 * 指定された要素が画面内に表示されたタイミングで Promise.resolve() します
 */
function visible({ element }: { element: HTMLElement }) {
  return new Promise((resolve) => {
    const observer = new IntersectionObserver(async (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          observer.disconnect();
          resolve(true);
        }
      }
    });
    observer.observe(element);
  });
}

/**
 * src で指定されたコンポーネントを描画する Web Components
 */
class MyIsland extends HTMLElement {
  async connectedCallback() {
    const src = this.getAttribute("src") ?? "";
    const componentLoader = islands[src];

    if (!componentLoader) {
      throw new Error(`${src} is not a component! Check your islands/index.`);
    }

    // 表示されるタイミングになるまで待つ
    if (this.hasAttribute("client:visible")) {
      await visible({ element: this });
    }

    const Component = (await componentLoader()).default;

    render(<Component />, this);
  }
}

// Web Components を登録する
customElements.define("my-island", MyIsland);

はい、これだけでアイランドアーキテクチャ(簡易版)を実装することができました。
あとは実装した Web Component を index.html で使用するだけです 👇

index.html
 <!doctype html>
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>Islands Architecture Example</title>
 
     <!-- ここで上で実装した CSS ファイルを読み込む -->
     <link rel="stylesheet" href="./src/main.css" />
   </head>
 
   <body>
     <h1>Vite + Preact Examples</h1>
 
     <!-- <App /> を画面外に出すための余白を作る要素 -->
     <div class="margin-section">スクロールするとボタンがあります👇</div>
 
     <!-- この要素に <App /> を描画します -->
-      <div id="root"></div>
+      <my-island src="App" client:visible></my-island>
   </body>
 
   <!-- 上で実装したコンポーネントを描画するためのスクリプト -->
   <script type="module" src="./src/main.tsx"></script>
 </html>

以上で実装は終了です。

挙動の確認

ここまで実装して、開発サーバーを立ち上げてブラウザで Web アプリを表示してみましょう🔭

スクロールすると <App /> が遅れて表示され、コンソールに <App />が表示されました と表示されていれば OK 👌 です。

これによって、初期表示では <App /> が表示されず、スクロールして <my-island src="App" /> が画面内に入って来た時に初めて <App /> が描画されるというアイランドアーキテクチャの挙動を実装することができました 🎉

あとがき

はい、ここまで読んでくれてありがとうございます 🙏

今回作ったサンプルではとても簡単な描画処理しかしていませんでしたが、アイランドアーキテクチャの雰囲気を少しは感じられたと思います。

もっと本格的なところもやってみたいですが、そうなると Vite がやってくれている部分を実装しなきゃいけなくなるのでやるなら時間がたっぷりある時にやりたいですね。( あわよくば勉強会でもやりたい😎 )

記事に間違いなどがあれば、X(旧:Twitter) などで教えて頂けると嬉しいです。
これが誰かの参考になれば幸いです。

それではまた 👋