【Deno】Fresh + TypeScriptで高速なWEBサイトを作る方法
Deno とは?
Deno(ディーノ) は JavaScript および TypeScript のランタイム環境です。
Node.js の作者であるライアン・ダール氏によって開発されました。
デフォルトで TypeScript を使えたり ES Module としてモジュールが扱えたり URL で import ができたりと Node.js とは違ったアプローチで設計されています。
最新バージョンの Deno ランタイムは Rust で作られており高速に動作します。
デフォルトで TypeScript をサポートしており、さらにコンパイラ、パッケージマネージャ、フォーマッター、リンター、テストフレームワーク、タスクランナーが組み込まれているので Deno をインストールするだけですぐに開発を開始できます。
組み込みのライブラリも充実しているのでさくっと WEB サーバーやコマンドラインツールなどが作成できるのも魅力です。
Fresh とは?
Fresh は Deno ランタイムで動作するフルスタックの WEB フレームワークです。
Deno のコア開発チームであるLuca Casonato 氏によって開発されました。
高いパフォーマンスとシンプルさが特徴です。
特徴としては Fresh は Astro などと同じくアイランドアーキテクチャを採用しています。
アイランドアーキテクチャはよく静的 HTML の海に動的なコンポーネント(アイランド)が浮かんでいるイメージで解説されます。
Fresh のページは基本的に JavaScript を含まない純粋な HTML としてレンダリングされ、るため特に何もしなくても高速に表示されます。
クライアント側でインタラクティブにしたい場合は JavaScript が必要なコンポーネントを部分的に含めることもできます。
つまりページ全体ではなく必要な部分だけがインタラクティブになるのでロード時間が短く済みます。
現在はコンポーネントを Preact というほぼ React と同じ記述で軽量に動作するライブラリで作成できます。
useState や useEffect などの React とほぼ同等の API が利用できるので React の知識があれば簡単にコンポーネントを作成できます。
Preact に関しては以前スクラップにまとめているので少しでも参考になれば幸いです。
インストール
次のコマンドを実行して Fresh をインストールします。
以下は「fresh-example」というプロジェクト名でインストールするコマンド例です。
deno run -A -r https://fresh.deno.dev fresh-example
途中で Twind や VS Code を使用するかどうか質問されるので y/n で答えます。
今回はどちらも使用するので全て y を入力しました。
ちなみに Twind はほぼ Tailwind CSS と同じ記述ができる CSS フレームワークです。
とても軽量な上にビルドが不要なため Fresh と相性が良いです。
実行コマンド
次にプロジェクト内へ移動して開発サーバーを起動します。
cd fresh-example
deno task start
WEB ブラウザからlocalhost:8000へアクセスします。
デフォルトの Counter アプリが表示されたら成功です。
アップデート方法
Fresh は Import Maps を利用してパッケージ管理されています。
手動で個別にアップデートもできますが公式で用意されているアップデートツールを使うと便利です。
全てのパッケージを最新にアップデートするには以下のコマンドを実行します。
deno run -A -r https://fresh.deno.dev/update .
ビルドについて
Fresh にビルドは必要ありません。
プロジェクト内で記述したコードはそのままデプロイされ、Deno ランタイム上で実行されます。
デプロイについて
現在は Deno 社が運営するホスティングサービスであるDeno Deployに対応しているようです。
エッジサービスによりリクエスト元に物理的に近いサーバーと通信できるためとても高速に動作します。
ルーティングについて
ルーティングは URL などのクライアントからのリクエストを解析して適切なルートに処理を振り分ける仕組みです。
Fresh ではルートの作成にファイルシステムルーティングが利用できます。
ファイルシステムルーティングはファイル名とルートが対応しており、例えば「/routes/about.tsx」は「/about」でアクセスできます。
(Next.js や Astro など他のフレームワークでも採用されている仕組みですね)
ルートはページコンポーネントとハンドラーで構成された JSX/TSX ファイルです。
ルートのファイルは全てプロジェクト内の「routes」ディレクトリ配下に配置されます。
ルートには静的ルートと動的ルートの 2 種類があります:
- 静的ルート: URL とルートファイルへのパスが対応している
- 動的ルート: URL の一部が動的パラメータになっており、対応するルートファイルにパラメータが渡される(詳しくは後述)
ページコンポーネントとハンドラーについては次から解説します。
ページコンポーネントの作成
ページコンポーネントはルートの構成要素のひとつであり、 HTML ノードを返す関数です。
ルートファイルにページコンポーネントを定義するとアクセス時にコンポーネントがレンダリングされ HTML がレスポンスとして返されます。
以下はページコンポーネントをレンダリングする静的ルートの例です。
export default function AboutPage() {
return (
<main>
<h1>About Page</h1>
<p>ここは概要ページです</p>
</main>
);
}
開発サーバーを起動してhttp://localhost:8000/aboutへアクセスすると概要ページが表示されているはずです。
前述の通りファイル名とルートが対応していることが分かりますね。
動的ルートの作成
ルートファイルを「/routes/profile/[name].tsx」のように配置すると「/profile/ExampleUser」のように[name]の部分を動的パラメータにできます。
URL から渡された動的パラメータは書きコードのように PageProps インタフェースを使用してルートファイル内で取得できます。
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」の部分を変更するとページの表示も動的に変わります。
ハンドラーの作成
ハンドラーはルートのもうひとつの構成要素で、GET や POST などの HTTP メソッドと対応した関数です。
クライアントからリクエストを受け取って最終的に HTML や JSON や XML などの何らかのレスポンスを返します。
例えば以下のコードでは GET アクセスされた時にユーザーデータを作成してコンポーネントに渡しています。
コンポーネントからは PageProps インターフェイスを通してデータを受け取れます。
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 化した例です。
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"
}
データフェッチを利用した Github リポジトリ検索アプリの例
さらにハンドラー内に API を fetch する処理を記述すれば動的なページが作れます。
以下のコードは Github API を使用してリポジトリを検索するルートの例です。
処理の流れ:
- ページへ GET アクセスすると GET ハンドラー内で null がセットされるため入力フォームが表示されます。入力フォームは HTML の form タグを使用できます。
- フォームが POST として送信されると今度は POST ハンドラー内でフォームデータを取得できます。
- そのフォームデータを使用して非同期で Github API を呼び出しています。
- 結果データをページコンポーネントへ渡してレンダリングしています。クライアントには検索結果の HTML が返されます。
- 「入力フォームへ戻る」というリンクをクリックすると再び GET ハンドラー内で null がセットされるため入力フォームが表示されるはずです。
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 タグでページ遷移するのも初心に返ったような気分で新鮮ですね。
ミドルウェアの作成
ミドルウェアはルートハンドラーの前後処理を定義できるハンドラーです。
使い道はリクエストのバリデーション処理、認証処理、ログへの記録など様々なことに応用できます。
ミドルウェアは _middleware.ts
という固定のファイル名でフレームワークから認識されます。
ファイル名先頭の「_」を忘れないように注意してください。
また、ミドルウェアは routes ディレクトリの階層順に呼び出されます。
例えば「/routes/admin/index.tsx」を呼び出す際に「/routes/_middleware.ts」があれば最初に呼び出され、次に「/routes/admin/_middleware.ts」があれば呼び出されます。
そして最後に「/routes/admin/index.tsx」のハンドラーが呼び出されます。
途中でミドルウェアのコンテキスト(MiddlewareHandlerContext)に値やヘッダーをセットすると後のミドルウェアやハンドラーに引き継がれます。
以下はレスポンスヘッダーをセットするミドルウェアの例です。
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;
},
];
アイランド(動的コンポーネント)の作成
Fresh のルートは基本的に JavaScript を含まない純粋な HTML を返します。
しかし実際の開発ではトグルボタンや検索のサジェスト機能などのような動的なコンポーネントを追加したい場合があります。
その場合は動的にしたいコンポーネントをプロジェクト内の「islands」ディレクトリに配置するだけで OK です。
あとはルートファイルから呼び出すだけで該当のコンポーネントのみがインタラクティブになります。
アイランドアーキテクチャにおいてはこのような動的コンポーネントをアイランドと呼ばれています。
以下はクリックする度に有効無効を切り替えるアイランドの例です。
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 としてレンダリングされます。
import SwitchButton from "../islands/SwitchButton.tsx";
export default function AboutPage() {
return (
<main>
<h1>About Page</h1>
<SwitchButton />
</main>
);
}
WEB ブラウザから確認するとボタンをクリックする度に有効無効が切り替わるはずです。
見た目には分かりにくいですがページ全体ではなくボタンのみがインタラクティブになっています。
コンポーネントを islands ディレクトリに配置するだけでインタラクティブにできるのは便利ですね。
サーバーサイドとうまく組み合わせれば色々なアプリに対応できそうです。
Signals を利用してアイランド間の値を共有する
ルート間の値はサーバーサイドを利用すれば共有できますが、ページ内の動的コンポーネント間で値を共有したい場合はどうすればいいでしょうか?
Preact には Signals というグローバル/ローカルに対応した状態管理機能が用意されています。
Signals については以前にスクラップをまとめています。
基本的な 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 として定義します。
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 コンポーネントを作成します。
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 コンポーネントを作成します。
フィルタに関するロジックがコンポーネントの外にあるためコードの見通しが良いですね。
import { filterdTodos } from "../util/TodoState.ts";
export default function TodoList() {
return (
<ul>
{filterdTodos.value.map((todo) => <li key={todo}>{todo}</li>)}
</ul>
);
}
そして最後にアイランドを呼び出すルートファイルを作成します。
2 つのアイランドはクライアント側でインタラクティブになり、値は Signal によって共有されます。
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 が使えるようになっているのもありがたいですね。