🍋

【Fresh】DenoのWebフレームワークを試してみたら面白かった

2022/06/14に公開

https://fresh.deno.dev/
DenoのWebフレームワーク、Freshを試してみました。
次に述べる特徴にもあるように、設定ファイルをゴニョゴニョする必要が無く、使い方もとてもシンプルで分かりやすく、正にフレッシュなフレームワークという印象を受けました。
また、コードはJSXで書かれますが、Reactではなく軽量なPreactを使用しています。

特徴

公式ページに載っている特徴です。括弧の中は筆者の所感です。

  • Just-in-time rendering on the edge.
    ジャストインタイム・レンダリングをエッジで実現
    (Deno Deployで使われることを想定しているから?)
  • Island based client hydration for maximum interactivity.
    アイランドベースのクライアントハイドレーションで、最大限のインタラクティブ性を実現
    (アイランドベース?ハイドレーション?)
  • Zero runtime overhead: no JS is shipped to the client by default.
    ゼロランタイムオーバーヘッド:デフォルトでは、クライアントにJSを送りません。
    (静的ページのみの場合はJSを送らず、インタラクティブ性が必要な場所に部分的に送る)
  • No build step.
    ビルドのステップなし
  • No configuration necessary.
    コンフィグの設定必要なし
  • TypeScript support out of the box.
    タイプスクリプトをデフォルトでサポート。

試してみる

インストール

Deno CLIはインストール済みとします。
Fresh CLIをインストールします。.deno/binのPATHが通っていれば使えるようになります。

deno install -A -f --no-check -n fresh -r https://raw.githubusercontent.com/lucacasonato/fresh/main/cli.ts

プロジェクトの作成

 fresh init lemon
 cd lemon
ファイル名 説明
main.ts メインとなるエントリポイント
fresh.gen.ts ルートとアイランドに関する情報を含んだマニフェストファイル
import_map.json プロジェクトの依存関係を管理する用
deno.json package.jsonの様にコマンドを登録。import_mapの場所をDenoに教える。
ディレクトリ名 説明
routes/ この中のファイル名がそのページにアクセスするためのパスに対応する。クライアント側に直接送られることはない
islands/ アイランドと呼ばれるインタラクティブなファイルが含まれる。クライアント/サーバの両方で実行できる
static/ 静的アセット用のディレクトリ

プロジェクトの実行

deno task start

http://localhost:8000にアクセスするとページが見れるようになっているかと思います。
このtaskがnpmで言うところのrunコマンドで、deno.jsonに定義されています。

"start": "deno run -A --watch --no-check main.ts"

--watchオプションが付いているので、ファイルを変更すると自動的に更新されます。

ルートの作成

react-routerのようにパスに対応した名前のファイルに対して自動でルーティングされる。
例えば、routes/lemon.tsxを作ると、http://localhost:8000/lemonにアクセスできます。

// routes/lemon.tsx

/** @jsx h */
import { h } from "$fresh/runtime.ts";

export default function LemonPage() {
  return (
    <main>
      <h1>Lemon</h1>
      <p>Lemon is sour.</p>
    </main>
  );
}

ページを追加したら、fresh manifestを実行してマニフェストを更新しましょう。
fresh.gen.tsに新しいページが追加されて、ページにアクセスできる様になります。

fresh manifest


マニフェストの更新は、ページを追加、削除、または名前変更するたびに行う必要があります

動的ルーティング

もちろん、動的ルーティングもできます。
routes/fruits/[name].tsxというファイルを作れば、http://localhost:8000/fruits/orangehttp://localhost:8000/fruits/grapeにアクセスでき、[name]の部分に渡されたパスによって描画を変えることができます。

// routes/fruits/[name].tsx

/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";

export default function GreetPage(props: PageProps) {
  const { name } = props.params;
  return (
    <main>
      <p>Fresh {name}!</p>
    </main>
  );
}

ハンドラーの作成

ルートでは、カスタムハンドラを作ることもできます。
ハンドラとは、Request => ResponseRequest => Promise<Response>で表される関数のことです。例えばGETリクエストを受け取ったら、リクエストオブジェクトにアクセスしたり、レスポンスオブジェクトを手動で作成して返すことができます。
前の章では、カスタムハンドラを作っていなかったため、ページコンポーネントをレンダリングするだけのデフォルトハンドラが使用されています。
ここでは、カスタムハンドラを作ってUUIDを返すAPIを作っています。

import { Handlers } from "$fresh/server.ts";

export const handler: Handlers = {
  GET(req) {
    const uuid = crypto.randomUUID();
    return new Response(JSON.stringify(uuid), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

非同期処理

動的なデータを扱うような非同期な処理を行うことができます。
外部のAPIから取得したデータを表示する場合、データを取得してからページをレンダリングしたいです。そこで、ハンドラを使うと外部APIの応答を待ってから、ページコンポーネントにデータを渡すことができます。
このサンプルでは、GitHubAPIからユーザー情報を取得した結果を表示しています。

// routes/github/[username].tsx

/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";
import { Handlers } from "$fresh/server.ts";

interface User {
  login: string;
  name: string;
  avatar_url: string;
}

export const handler: Handlers<User | null> = {
  async GET(_, ctx) {
    const { username } = ctx.params;
    const resp = await fetch(`https://api.github.com/users/${username}`);
    if (resp.status === 404) {
      return ctx.render(null);
    }
    const user: User = await resp.json();
    return ctx.render(user);
  },
};

export default function Page({ data }: PageProps<User | null>) {
  if (!data) {
    return <h1>User not found</h1>;
  }

  return (
    <div>
      <img src={data.avatar_url} width={64} height={64} />
      <h1>{data.name}</h1>
      <p>{data.login}</p>
    </div>
  );
}

取得したデータをctx.render(user)で渡していることがわかるかと思います。

インタラクティブ

freshの特徴とも言えるインタラクティブ性を実現している部分についてです。
Webアプリでユーザーと対話するための一般的な仕組みがFormです。
前の章と同様にカスタムハンドラを用いることで簡単に処理を記述することができます。

Form

先ほどの外部APIからデータを取得した方法とほとんど同じ様に書くことができ、コードも直感的で分かりやすいです。

// routes/search.tsx

/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";
import { Handlers } from "$fresh/server.ts";

const NAMES = ["Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"];

interface Data {
  results: string[];
  query: string;
}

export const handler: Handlers<Data> = {
  GET(req, ctx) {
    const url = new URL(req.url);
    const query = url.searchParams.get("q") || "";
    const results = NAMES.filter((name) => name.includes(query));
    return ctx.render({ results, query });
  },
};

export default function Page({ data }: PageProps<Data>) {
  const { results, query } = data;
  return (
    <div>
      <form>
        <input type="text" name="q" value={query} />
        <button type="submit">Search</button>
      </form>
      <ul>
        {results.map((name) => <li key={name}>{name}</li>)}
      </ul>
    </div>
  );
}

よりインタラクティブに

今までのページはクライアント側にJavaScriptを送信していませんでしたが、それでは出来ることが制限されてしまいます。ただ、大量のJavaScriptを送ってしまうとパフォーマンスの低下にも繋がります。
そこで、必要な場所にほんの少しだけJavaScriptを渡してあげることで、パフォーマンスを維持しながら出来ることも制限しないといことを実現します。
これは、静的な構成されたページの中に、小さな「島」の様なインタラクティブ性を持たせることを意味し、これを Island Archtecure(アイランドアーキテクチャ) と呼びます。
freshでは、このIsland Archtecureを採用しています。
island/というフォルダあったかと思いますが、クライアント側で使いたいJavaScriptは、このフォルダ内にファイルを作成します。ここに作られるもののことをアイランドコンポーネントと呼びます。

アイランドアーキテクチャについて詳しく知りたい場合は、こちらの記事を参考にしてください。
https://jasonformat.com/islands-architecture/

次のコードではカウントダウンタイマーを作っています。

// islands/Countdown.tsx

/** @jsx h */
import { h, useEffect, useMemo, useState } from "$fresh/runtime.ts";

const timeFmt = new Intl.RelativeTimeFormat("en-US");

// アイランド・コンポーネントに渡す引数はでシリアライズされている必要があるため、
// targetは文字列型になっています。
export default function Countdown(props: { target: string }) {
  const target = new Date(props.target);
  const [now, setNow] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => {
      setNow(new Date());
      if (now > target) {
        clearInterval(timer);
      }
    }, 1000);
    return () => clearInterval(timer);
  }, [props.target]);

  if (now > target) {
    return <span>🎉</span>;
  }
  
  const secondsLeft = Math.floor((target.getTime() - now.getTime()) / 1000);
  return <span>{timeFmt.format(secondsLeft, "seconds")}</span>;
}

また、island/フォルダに作成したアイランド・コンポーネントは、ページ・コンポーネントで普通に呼び出すことで使うことができます。

// routes/countdown.tsx

/** @jsx h */
import { h } from "$fresh/runtime.ts";
import Countdown from "../islands/Countdown.tsx";

export default function Page() {
  const date = new Date();
  date.setHours(date.getHours() + 1);
  return (
    <p>
      The big event is happening <Countdown target={date.toISOString()} />.
    </p>
  );
}

アイランド・コンポーネントの追加、削除、名前変更を行った場合は、fresh manifestを実行する必要があります。

Deno Deploy

Deno Deployにデプロイするには、GitHubにリポジトリを作成する方法が簡単です。
GitHubにリポジトリをプッシュしたら、Deno Deployのダッシュボードからプロジェクトを作成します。
プロジェクト設定の「Git」タブで、リポジトリ、プロダクションブランチ(main)、エントリポイント・ファイル(main.ts)を選択します。
ここまですると、自動的にデプロイされhttps://$PROJECT_NAME.deno.devアクセス出来る様になります。

感想

軽い気持ちで触ってみると、アイランド・アーキテクチャやプログレッシブ・ハイドレーションなど知らない単語の嵐で少し大変でしたが、フレームワーク自体は非常にシンプルで分かりやすく、使いやすかったです。
まだまだ成長段階ですが、deno版のnextのようにも見えるので今後に期待です。
ホビー用途には十分使えると思うので、ぜひみなさんも触ってみてください。

Discussion