Closed15

【Deno】Fresh + TypeScriptで高速なWEBサイトを作る方法

NanaoNanao

Deno とは?

Deno(ディーノ) は JavaScript および TypeScript のランタイム環境です。
Node.js の作者であるライアン・ダール氏によって開発されました。
デフォルトで TypeScript を使えたり ES Module としてモジュールが扱えたり URL で import ができたりと Node.js とは違ったアプローチで設計されています。
最新バージョンの Deno ランタイムは Rust で作られており高速に動作します。

デフォルトで TypeScript をサポートしており、さらにコンパイラ、パッケージマネージャ、フォーマッター、リンター、テストフレームワーク、タスクランナーが組み込まれているので Deno をインストールするだけですぐに開発を開始できます。
組み込みのライブラリも充実しているのでさくっと WEB サーバーやコマンドラインツールなどが作成できるのも魅力です。

NanaoNanao

Fresh とは?

Fresh は Deno ランタイムで動作するフルスタックの WEB フレームワークです。
Deno のコア開発チームであるLuca Casonato 氏によって開発されました。
高いパフォーマンスとシンプルさが特徴です。

特徴としては Fresh は Astro などと同じくアイランドアーキテクチャを採用しています。
アイランドアーキテクチャはよく静的 HTML の海に動的なコンポーネント(アイランド)が浮かんでいるイメージで解説されます。

Fresh のページは基本的に JavaScript を含まない純粋な HTML としてレンダリングされ、るため特に何もしなくても高速に表示されます。
クライアント側でインタラクティブにしたい場合は JavaScript が必要なコンポーネントを部分的に含めることもできます。
つまりページ全体ではなく必要な部分だけがインタラクティブになるのでロード時間が短く済みます。

現在はコンポーネントを Preact というほぼ React と同じ記述で軽量に動作するライブラリで作成できます。
useState や useEffect などの React とほぼ同等の API が利用できるので React の知識があれば簡単にコンポーネントを作成できます。


Preact に関しては以前スクラップにまとめているので少しでも参考になれば幸いです。

https://zenn.dev/7oh/scraps/517d0fdabc2454

NanaoNanao

インストール

次のコマンドを実行して Fresh をインストールします。
以下は「fresh-example」というプロジェクト名でインストールするコマンド例です。

deno run -A -r https://fresh.deno.dev fresh-example

途中で Twind や VS Code を使用するかどうか質問されるので y/n で答えます。
今回はどちらも使用するので全て y を入力しました。


ちなみに Twind はほぼ Tailwind CSS と同じ記述ができる CSS フレームワークです。
とても軽量な上にビルドが不要なため Fresh と相性が良いです。

https://twind.dev/

NanaoNanao

実行コマンド

次にプロジェクト内へ移動して開発サーバーを起動します。

cd fresh-example
deno task start

WEB ブラウザからlocalhost:8000へアクセスします。
デフォルトの Counter アプリが表示されたら成功です。

NanaoNanao

アップデート方法

Fresh は Import Maps を利用してパッケージ管理されています。
手動で個別にアップデートもできますが公式で用意されているアップデートツールを使うと便利です。
全てのパッケージを最新にアップデートするには以下のコマンドを実行します。

 deno run -A -r https://fresh.deno.dev/update .
NanaoNanao

ビルドについて

Fresh にビルドは必要ありません。
プロジェクト内で記述したコードはそのままデプロイされ、Deno ランタイム上で実行されます。

NanaoNanao

デプロイについて

現在は Deno 社が運営するホスティングサービスであるDeno Deployに対応しているようです。
エッジサービスによりリクエスト元に物理的に近いサーバーと通信できるためとても高速に動作します。

NanaoNanao

ルーティングについて

ルーティングは URL などのクライアントからのリクエストを解析して適切なルートに処理を振り分ける仕組みです。
Fresh ではルートの作成にファイルシステムルーティングが利用できます。

ファイルシステムルーティングはファイル名とルートが対応しており、例えば「/routes/about.tsx」は「/about」でアクセスできます。
(Next.js や Astro など他のフレームワークでも採用されている仕組みですね)

ルートはページコンポーネントとハンドラーで構成された JSX/TSX ファイルです。
ルートのファイルは全てプロジェクト内の「routes」ディレクトリ配下に配置されます。

ルートには静的ルートと動的ルートの 2 種類があります:

  • 静的ルート: URL とルートファイルへのパスが対応している
  • 動的ルート: URL の一部が動的パラメータになっており、対応するルートファイルにパラメータが渡される(詳しくは後述)

ページコンポーネントとハンドラーについては次から解説します。

NanaoNanao

ページコンポーネントの作成

ページコンポーネントはルートの構成要素のひとつであり、 HTML ノードを返す関数です。
ルートファイルにページコンポーネントを定義するとアクセス時にコンポーネントがレンダリングされ HTML がレスポンスとして返されます。

以下はページコンポーネントをレンダリングする静的ルートの例です。

/routes/about.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>About Page</h1>
      <p>ここは概要ページです</p>
    </main>
  );
}

開発サーバーを起動してhttp://localhost:8000/aboutへアクセスすると概要ページが表示されているはずです。
前述の通りファイル名とルートが対応していることが分かりますね。

NanaoNanao

動的ルートの作成

ルートファイルを「/routes/profile/[name].tsx」のように配置すると「/profile/ExampleUser」のように[name]の部分を動的パラメータにできます。
URL から渡された動的パラメータは書きコードのように PageProps インタフェースを使用してルートファイル内で取得できます。

/routes/profile/[name].tsx
import { PageProps } from "$fresh/server.ts";

export default function ProfilePage({ params }: PageProps) {
  const { name } = params;

  return (
    <main>
      <h1>{name}さんのプロフィール</h1>
      <p>ここはプロフィールページです</p>
    </main>
  );
}

WEB ブラウザからhttp://localhost:8000/profile/ExampleUserへアクセスすると「ExampleUser さんのプロフィール」と表示されるはずです。
URL の「ExampleUser」の部分を変更するとページの表示も動的に変わります。

NanaoNanao

ハンドラーの作成

ハンドラーはルートのもうひとつの構成要素で、GET や POST などの HTTP メソッドと対応した関数です。
クライアントからリクエストを受け取って最終的に HTML や JSON や XML などの何らかのレスポンスを返します。

例えば以下のコードでは GET アクセスされた時にユーザーデータを作成してコンポーネントに渡しています。
コンポーネントからは PageProps インターフェイスを通してデータを受け取れます。

/routes/about.tsx
import { Handlers, PageProps } from "$fresh/server.ts";

type User = {
  id: string;
  name: string;
};

export const handler: Handlers<User> = {
  // GETアクセスされた時の処理
  GET(req, ctx) {
    // ページコンポーネントにデータを渡してレンダリングする
    return ctx.render({
      id: "@exampleuser",
      name: "Example User",
    });
  },
};

// コンポーネントはハンドラーからデータを受け取ることができる
export default function AboutPage({ data }: PageProps<User>) {
  return (
    <main>
      <h1>About Page</h1>
      <div>{data.name}</div>
      <div>{data.id}</div>
    </main>
  );
}


ちなみにルートファイル内でハンドラーを定義しなかった場合はページコンポーネントをレンダリングするデフォルトのハンドラーが使用されます。
そのため今までの例でも問題なくレンダリングされていたという事です。


ハンドラーではコンポーネントをレンダリングする以外にも JSON や XML や画像ファイルなどの任意のレスポンスも作成できます。
以下は先程のコードを簡易 API 化した例です。

/routes/about.tsx
import { Handlers, PageProps } from "$fresh/server.ts";

type User = {
  id: string;
  name: string;
};

export const handler: Handlers<User> = {
  // GETアクセスされた時の処理
  GET(req) {
    const user: User = {
      id: "@exampleuser",
      name: "Example User",
    };

    // JSONに変換してレスポンスを返す
    return new Response(JSON.stringify(user), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

WEB ブラウザでアクセスすると以下のような JSON レスポンスが確認できます。

{
  "id": "@exampleuser",
  "name": "Example User"
}
NanaoNanao

データフェッチを利用した Github リポジトリ検索アプリの例

さらにハンドラー内に API を fetch する処理を記述すれば動的なページが作れます。
以下のコードは Github API を使用してリポジトリを検索するルートの例です。


処理の流れ:

  1. ページへ GET アクセスすると GET ハンドラー内で null がセットされるため入力フォームが表示されます。入力フォームは HTML の form タグを使用できます。
  2. フォームが POST として送信されると今度は POST ハンドラー内でフォームデータを取得できます。
  3. そのフォームデータを使用して非同期で Github API を呼び出しています。
  4. 結果データをページコンポーネントへ渡してレンダリングしています。クライアントには検索結果の HTML が返されます。
  5. 「入力フォームへ戻る」というリンクをクリックすると再び GET ハンドラー内で null がセットされるため入力フォームが表示されるはずです。
/routes/search.tsx
import { Handlers, PageProps } from "$fresh/server.ts";

type Repository = {
  total_count: number;
  items: RepositoryItem[];
};

type RepositoryItem = {
  id: number;
  full_name: string;
  description: string;
};

export const handler: Handlers<Repository | null> = {
  // GETアクセスされた時の処理
  GET(req, ctx) {
    return ctx.render(null);
  },
  // POSTアクセスされた時の処理
  async POST(req, ctx) {
    // フォームデータを取得する
    const form = await req.formData();
    const query = form.get("query") || "";

    // フォームデータを使用してAPIリクエストする
    const resp = await fetch(
      `https://api.github.com/search/repositories?q=${query}`,
    );
    if (resp.status !== 200) {
      return ctx.render(null);
    }
    const repo: Repository = await resp.json();

    // ページコンポーネントにデータを渡してレンダリングする
    return ctx.render(repo);
  },
};

export default function SearchPage({ data }: PageProps<Repository | null>) {
  // データがnullの場合は入力フォームを表示、それ以外は一覧を表示する
  return data == null
    ? (
      <main>
        <h1>Search Repository</h1>
        <form method="POST" action="/search">
          <input type="text" name="query" placeholder="検索キーワード" />
          <button type="submit">Search</button>
        </form>
      </main>
    )
    : (
      <main>
        <a href="/search">入力フォームへ戻る</a>
        <h1>Search Repository</h1>
        <p>Total: {data.total_count}</p>
        <div>
          {data.items.map((item) => (
            <div key={item.id}>
              <h2>{item.full_name}</h2>
              <div>{item.description}</div>
            </div>
          ))}
        </div>
      </main>
    );
}


HTTP メソッド毎の処理とコンポーネントを分けて記述できるのは面白いですね。
個人的には Remix の loader/action 関数に影響を受けているように感じました。
a タグや form タグでページ遷移するのも初心に返ったような気分で新鮮ですね。

NanaoNanao

ミドルウェアの作成

ミドルウェアはルートハンドラーの前後処理を定義できるハンドラーです。
使い道はリクエストのバリデーション処理、認証処理、ログへの記録など様々なことに応用できます。

ミドルウェアは _middleware.ts という固定のファイル名でフレームワークから認識されます。
ファイル名先頭の「_」を忘れないように注意してください。

また、ミドルウェアは routes ディレクトリの階層順に呼び出されます。
例えば「/routes/admin/index.tsx」を呼び出す際に「/routes/_middleware.ts」があれば最初に呼び出され、次に「/routes/admin/_middleware.ts」があれば呼び出されます。
そして最後に「/routes/admin/index.tsx」のハンドラーが呼び出されます。

途中でミドルウェアのコンテキスト(MiddlewareHandlerContext)に値やヘッダーをセットすると後のミドルウェアやハンドラーに引き継がれます。
以下はレスポンスヘッダーをセットするミドルウェアの例です。

/routes/_middleware.tsx
import { MiddlewareHandlerContext } from "$fresh/server.ts";

export const handler = [
  async function addHeaderMiddleware(
    req: Request,
    ctx: MiddlewareHandlerContext,
  ) {
    const resp = await ctx.next();
    if (!req.headers.has("my-header")) {
      resp.headers.set("my-header", "my-value");
    }
    return resp;
  },
];

NanaoNanao

アイランド(動的コンポーネント)の作成

Fresh のルートは基本的に JavaScript を含まない純粋な HTML を返します。
しかし実際の開発ではトグルボタンや検索のサジェスト機能などのような動的なコンポーネントを追加したい場合があります。

その場合は動的にしたいコンポーネントをプロジェクト内の「islands」ディレクトリに配置するだけで OK です。
あとはルートファイルから呼び出すだけで該当のコンポーネントのみがインタラクティブになります。

アイランドアーキテクチャにおいてはこのような動的コンポーネントをアイランドと呼ばれています。
以下はクリックする度に有効無効を切り替えるアイランドの例です。

/islands/SwitchButton.tsx
import { useEffect, useState } from "preact/hooks";

export default function SwitchButton() {
  const [value, setValue] = useState(false);
  const [label, setLabel] = useState("");

  useEffect(() => {
    const newLabel = value ? "Enabled" : "Disabled";
    setLabel(newLabel);
  }, [value]);

  return <button onClick={() => setValue((v) => !v)}>{label}</button>;
}

コンポーネントを呼び出すルートファイルの例は以下の通りです。
SwitchButton.tsx は islands ディレクトリに配置されているのでインタラクティブになります。
それ以外の HTML 要素は Javascript を含まない静的 HTML としてレンダリングされます。

/routes/about.tsx
import SwitchButton from "../islands/SwitchButton.tsx";

export default function AboutPage() {
  return (
    <main>
      <h1>About Page</h1>
      <SwitchButton />
    </main>
  );
}

WEB ブラウザから確認するとボタンをクリックする度に有効無効が切り替わるはずです。
見た目には分かりにくいですがページ全体ではなくボタンのみがインタラクティブになっています。


コンポーネントを islands ディレクトリに配置するだけでインタラクティブにできるのは便利ですね。
サーバーサイドとうまく組み合わせれば色々なアプリに対応できそうです。

NanaoNanao

Signals を利用してアイランド間の値を共有する

ルート間の値はサーバーサイドを利用すれば共有できますが、ページ内の動的コンポーネント間で値を共有したい場合はどうすればいいでしょうか?

Preact には Signals というグローバル/ローカルに対応した状態管理機能が用意されています。
Signals については以前にスクラップをまとめています。

https://zenn.dev/7oh/scraps/517d0fdabc2454

基本的な Signal の使い方は以下の通りです。
(他にも Signals には effect や batch など便利な API が用意されています。)

import { computed, signal } from '@preact/signals';

// Signalを作成する
const count = signal(0);

// Signalの値を変更する
count.value = 1;

// Signalの値を取得する
count.value;

// Signalの値を使用して新しい値を作る
const doubleCount = computed(() => {
  // Signalの値が変更される度に再計算された値を返す
  return count.value * 2;
});

サンプルとして TODO リストをフィルタリングするルートを作成します。
まずは以下のように TODO リストとフィルタ文字列を Signal として定義します。

/util/TodoState.ts
import { computed, signal } from "@preact/signals";

export const todos = signal<string[]>([
  "JavaScriptを学習する",
  "TypeScriptを学習する",
  "ちょっと休憩する",
  "Denoを学習する",
  "Freshを学習する",
]);

export const filterValue = signal<string>("");

// Signalが変更される度にフィルタ済みのTODOリストが再計算される
export const filterdTodos = computed(() => {
  return todos.value.filter((todo) => todo.includes(filterValue.value));
});

次にこの Signal を利用する 2 つのアイランドを作成します。
まずはフィルタ文字列を入力フォームから受け取って Signal にセットする FilterForm コンポーネントを作成します。

/islands/FilterForm.tsx
import { useRef } from "preact/hooks";
import { filterValue } from "../util/TodoState.ts";

export default function FilterForm() {
  const valueRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: Event) => {
    e.preventDefault();
    if (valueRef.current?.value) {
      // Signalの値を変更する
      filterValue.value = valueRef.current.value;
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={valueRef} placeholder="キーワードで絞り込み" />
      <button type="submit">Submit</button>
    </form>
  );
}

次にフィルタ済みの TODO リストを表示する TodoList コンポーネントを作成します。
フィルタに関するロジックがコンポーネントの外にあるためコードの見通しが良いですね。

/islands/TodoList.tsx
import { filterdTodos } from "../util/TodoState.ts";

export default function TodoList() {
  return (
    <ul>
      {filterdTodos.value.map((todo) => <li key={todo}>{todo}</li>)}
    </ul>
  );
}

そして最後にアイランドを呼び出すルートファイルを作成します。
2 つのアイランドはクライアント側でインタラクティブになり、値は Signal によって共有されます。

/routes/todo.tsx
import FilterForm from "../islands/FilterForm.tsx";
import TodoList from "../islands/TodoList.tsx";

export default function TodoPage() {
  return (
    <main>
      <h1>Todo</h1>
      <FilterForm />
      <TodoList />
    </main>
  );
}

WEB ブラウザから確認すると、最初は TODO リストが全件表示されているはずです。
そしてキーワードを入力してフォーム送信するとキーワードを含む TODO リストのみに絞り込まれます。


Signal をうまく使えばコンポーネントから状態とロジックを切り離せるのでコードの見通しが良くなります。
最新バージョンでは Fresh をインストールするとすぐに Signal が使えるようになっているのもありがたいですね。

このスクラップは2023/01/24にクローズされました