EY-Office ブログ

Deno用WebフレームワークFreshは良い感じですね

AI系の記事が続きましたが、久しぶりにフロントエンド系の記事です。最近になりFreshというDeno用のWebフレームワークを知りましたが、触れてみると良い感じのフレームワークなので記事を書くことにしました。

Fresh Gemini AI image generatorが生成した画像を使っています

(念のため)Denoとは

ご存知とは思いますが、DenoはNode.jsの開発者であるライアン・ダールによって、新たに開発されたJavaScriptおよびTypeScriptのためのランタイム環境です。

DenoはNode.jsの反省から新たに開発されたもので、一部の開発者から熱狂的に指示されました。しかし当初は大量にあるnpmライブラリーとの互換性がなくシェアはあまり増えませんでした。
ただし2022年11月のV1.28よりnpmライブラリーをサポートするようになり、ダウンロード数も増えているようです。

Freshとは

Freshは大ざっぱに言うとモダンなExpress.jsのようなWebフレームワークです。
以下のような特徴を持っています。

  • Expressと同様にサーバーサイドでHTMLを生成するフレームワークです
  • 動的なHTMLの生成にはReactに似たPrecatを使っています
    • PrecatはReact同様のJSXやHooksをサポートしています
    • Reactにある一部の高度な機能はありませんが、Precatはコンパクトなライブラリーで高速です
  • アイランドアーキテクチャを採用し、ブラウザー上のPreactコンポーネント(JavaScript)を実行できます
    • アイランドアーキテクチャとはHTMLの海に複数のJavaScriptの島が浮いているイメージです
  • Express同様にGET, POST等のHTTPプロトコルを扱うハンドラーが定義できます
  • シンプルで手軽に扱えるフレームワークで、TypeScriptにも対応していています

いつものジャンケンアプリを書いてみました(1)

Fresh用のプロジェクトは以下のように簡単に作れます、denoがインストールされている前提です(インストール手順はdenoのインストールページを見てください)。

  • ① denoコマンドでFreshのURLを指定して実行します
  • ② プロジェクト名の指定
  • ③ スタイリング・ライブラリーにTailwindCSSを指定
$ deno run -A -r https://fresh.deno.dev            ← ①

 🍋 Fresh: The next-gen web framework.

Project Name: fresh-project                        ← ②
Let's set up your new Fresh project.

Do you want to use a styling library? [y/N] y      ← ③

1. tailwindcss (recommended)
2. Twind

Which styling library do you want to use? [1]      ← ③
Do you use VS Code? [y/N] y
The manifest has been generated for 5 routes and 1 islands.

Project initialized!

Enter your project directory using cd fresh-project.
Run deno task start to start the project. CTRL-C to stop.

Stuck? Join our Discord https://discord.gg/deno

Happy hacking! 🦕

プロジェクトのディレクトリー構造

さて完成したジャンケンアプリのディレクトリー構造です。

  • componentsディレクトリーにはPreactのコンポーネントを置きます、PreactはReactのサブセットなので過去に作ったReactコンポーネントがそのまま使えました
  • routesディレクトリーにはサーバーサイドで動作するPreactのコンポーネントを置きます、またNextと同様にディレクトリー/ファイル名がURLのパス名に対応します
    • routes/scores/index.tsxファイルは、アクセスURLの/scoresに対応します
  • islandsディレクトリーには、ブラウザーに送られ動作するPreactのコンポーネントを置きます

今回は設定ファイル等の解説は省略しますが、deno.jsonがNode.jsのpackage.json相当のファイルになります。

├── components                ← コンポーネント
│   ├── ApplicationBar.tsx
│   ├── JyankenBox.tsx
│   ├── ScoreList.tsx
│   └── StatusBox.tsx
├── deno.json
├── dev.ts
├── fresh.config.ts
├── fresh.gen.ts
├── islands                  ← ブラウザーで動くJS
│   └── JyankenApp.tsx
├── libs
│   └── jyanken.ts
├── main.ts
├── README.md
├── routes                   ← サーバーサイドで動くJS
│   ├── _404.tsx
│   ├── _app.tsx
│   ├── index.tsx
│   ├── scores
│   │   └── index.tsx
│   └── status
│       └── index.tsx
├── static
│   ├── favicon.ico
│   ├── logo.svg
│   └── styles.css
└── tailwind.config.ts

ファイル説明

routes/scores/index.tsx

/scores パスでアクセスされた際にサーバー側で動くコンポーネントです。class属性がclass=で指定できる❗以外はReactと同じですね。

import ApplicationBar from "../../components/ApplicationBar.tsx";
import JyankenApp from "../../islands/JyankenApp.tsx";

export default function Home() {
  return (
    <>
      <ApplicationBar />
      <div class="mx-2 md:mx-8 md:w-1/2">
        <h1 class="my-6 text-center text-xl font-bold">
          対戦結果
        </h1>
        <JyankenApp view="scores" />
      </div>
    </>
  );
}
islands/JyankenApp.tsx

ブラウザー上で動くPreactコンポーネントです、これもReactコンポーネントとほぼ同じですね。

  • ① このコンポーネントはPropsのviewで対戦結果を表示するか対戦成績を表示するかを指定します
  • ② Preactのステート管理はSignalが使われています
  • ③ ステートの値は.valueで取得でき、設定は.valueに代入します

SignalはReactのuseStateに比べ以下のようなメリットがあります、詳しくはMedium: useState vs useSignalを読んでください。

  • コンポーネント間で状態を共有し、変更された値に依存するコンポーネントのみを更新することでレンダリングパフォーマンスを最適化できます
  • Signalのシグナル伝搬メカニズムにより、ステートが複雑になっても、アプリケーションの高速性と応答性を維持できます
import { useSignal } from "@preact/signals";
import { judge, randomHand, calcStatus, Score, Te } from "../libs/jyanken.ts";
import JyankenBox from "../components/JyankenBox.tsx";
import ScoreList from "../components/ScoreList.tsx";
import StatusBox from "../components/StatusBox.tsx";

export type ViewMode = "scores" | "status";

type JyankenAppProps = {                       // ← ①
  view: ViewMode;
}
export default function JyankenApp({view}: JyankenAppProps) {
  const scores = useSignal<Score[]>([]);       // ← ②
  const pon = (humanHand: Te) => {
    const computerHand = randomHand();
    const score: Score = {
      human: humanHand,
      computer: computerHand,
      judgment: judge(humanHand, computerHand),
      matchDate: new Date()
    };
    scores.value = [score, ...scores.value];   // ← ③
  };

  return (
    <>
      <JyankenBox pon={pon} />
      {view == "scores" && <ScoreList scores={scores.value} />}
      {view == "status" && <StatusBox status={calcStatus(scores.value)} />}
    </>
  );
}
components/JyankenBox.tsx

グー・チョキ・パーのボタンがs並ぶコンポーネントです、通常のReactコンポーネントですね。JyankenAppから呼び出されるのでブラウザー上で動作します。

import { Te } from "../libs/jyanken.ts";

type JyankenBoxProps = {
  pon: (human: Te) => void;
}
export default function JyankenBox ({pon}: JyankenBoxProps) {
  const buttonClass = `text-white text-center text-sm rounded w-16 px-2 py-2
    bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50`;
  return (
    <div class="w-[230px] mx-auto flex mb-10">
      <button type="button" onClick={() => pon(Te.Guu)} class={buttonClass}>
        グー
      </button>
      <button type="button" onClick={() => pon(Te.Choki)} class={`${buttonClass} mx-5`}>
        チョキ
      </button>
      <button type="button" onClick={() => pon(Te.Paa)} class={buttonClass}>
        パー
      </button>
    </div>
  );
}
components/ScoreList.tsx

対戦結果を表示するコンポーネントです、通常のReactコンポーネントですね。このコンポーネントもJyankenAppから呼び出されるのでブラウザー上で動作します。

import { Score } from "../libs/jyanken.ts";

const JudgmentColor = ["text-[#000]", "text-[#2979ff]", "text-[#ff1744]"];

type ScoreBoxProps = {
  scores: Score[];
}
export default function ScoreBox ({scores}: ScoreBoxProps)  {
  const header=["時間", "人間", "コンピュータ", "結果"];

  return (
    <table className="w-full text-sm text-left text-gray-500">
      <thead className="bg-slate-100 border">
        <tr>
          {header.map((title, ix) => (
            <th key={ix} scope="col" className="px-6 py-3">
              {title}
            </th>
          ))}
        </tr>
      </thead>
      <tbody className="bg-white border">
        {scores.map((score, ix) => (
          <ScoreListItem key={ix} score={score} />
        ))}
      </tbody>
    </table>
  );
}

type ScoreListItemProps = {
  score: Score;
};
function ScoreListItem({score}: ScoreListItemProps) {
  const teString = ["グー", "チョキ", "パー"];
  const judgmentString = ["引き分け", "勝ち", "負け"];
  const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8);
  const tdClass = `px-2 md:px-6 py-4 ${JudgmentColor[score.judgment]}`;

  return (
    <tr className="bg-white border-b">
      <td className={tdClass}>{dateHHMMSS(score.matchDate)}</td>
      <td className={tdClass}>{teString[score.human]}</td>
      <td className={tdClass}>{teString[score.computer]}</td>
      <td className={tdClass}>{judgmentString[score.judgment]}</td>
    </tr>
  );
};
components/StatusBox.tsx

対戦生成を表示するコンポーネントです。

import { Judgment, Status } from "../libs/jyanken.ts";

const JudgmentColor = ["text-[#000]", "text-[#2979ff]", "text-[#ff1744]"];

type StatusBoxProps = {
  status: Status;
};
export default function StatusBox({ status }: StatusBoxProps){
  return (
    <table class="w-full text-sm text-left text-gray-500">
      <tbody class="bg-white border">
        <StatusItem title="勝ち" judge={Judgment.Win} count={status.win} />
        <StatusItem title="負け" judge={Judgment.Lose} count={status.lose} />
        <StatusItem title="引き分け" judge={Judgment.Draw} count={status.draw} />
      </tbody>
    </table>
  );
};

type StatusItemProps = {
  title: string;
  judge: Judgment;
  count: number
}
function StatusItem({title, judge, count}: StatusItemProps) {
  return (
    <tr key={judge} class="bg-white border-b">
      <th scope="row" class="pl-16 py-4">{title}</th>
      <td class={`text-right pr-16 py-4 ${JudgmentColor[judge]}`}>{count}</td>
    </tr>
  )
}
components/ApplicationBar.tsx

アプリケーションバーのコンポーネントです。routes/scores/index.tsxから呼び出されるのでサーバーサイドで実行されます。

export default function ApplicationBar() {
  const linkCSS = "py-2 px-3 text-blue-100 rounded hover:bg-blue-700";
  return (
    <nav class="bg-blue-600 border-gray-50">
      <div class="max-w-screen-xl flex flex-wrap items-center mx-auto p-3">
        <h1 class="ml-5 text-2xl font-bold text-white">じゃんけん ポン!</h1>
          <ul class="font-medium flex p-2 bg-blue-600">
            <li>
              <a href="/scores" class={linkCSS}>対戦結果</a>
            </li>
            <li>
              <a href="/status" class={linkCSS}>対戦成績</a>
            </li>
          </ul>
      </div>
    </nav>
  );
}
routes/status/index.tsx

/status パスでアクセスされた際にサーバー側で動くコンポーネントです。

import ApplicationBar from "../../components/ApplicationBar.tsx";
import JyankenApp from "../../islands/JyankenApp.tsx";

export default function Home() {
  return (
    <>
      <ApplicationBar />
      <div class="mx-2 md:mx-8 md:w-1/2">
        <h1 class="my-6 text-center text-xl font-bold">
          対戦成績
        </h1>
        <JyankenApp view="status" />
      </div>
    </>
  );
}
routes/index.tsx

その2で説明します。

libs/jyanken.ts

ジャンケンの勝敗決定関数や型の書かれたファイルです。

export const Te = {
  Guu: 0,
  Choki: 1,
  Paa: 2
} as const;
export const Judgment = {
  Draw: 0,
  Win: 1,
  Lose: 2
} as const;

export type Te = (typeof Te)[keyof typeof Te];
export type Judgment = (typeof Judgment)[keyof typeof Judgment];

export type Score = {
  human: Te;
  computer: Te;
  judgment: Judgment;
  matchDate: Date;
};

export type Status = {
  draw: number,
  win: number,
  lose: number
}

export const randomHand = (): Te => {
  return Math.floor(Math.random() * 3) as Te;
}

export const judge = (humanHand: Te, computerHand: Te): Judgment => {
  return (computerHand - humanHand + 3) % 3 as Judgment;
}

export const calcStatus = (scores: Score[]): Status => {
  const jugdeCount = (judge: Judgment) =>
    scores.reduce((count, score) => score.judgment === judge ? count + 1 : count, 0);
  return {
    draw: jugdeCount(Judgment.Draw),
    win:  jugdeCount(Judgment.Win),
    lose: jugdeCount(Judgment.Lose)
  };
}

いつものジャンケンアプリを書いてみました(2)

さて、先ほどのアプリは対戦成績と対戦結果を/scores/statusで別ルートで実行するので、ステート(Signal)が別になってしまいまいます。
またジャンケン・ボタンを<form>にする事で、全てサーバー・コンポーネントでアプリを実現できます。
そこで、全てサーバー・コンポーネントで動作し、かつジャンケンの結果をRDB(sqlite3)に格納するジャンケンアプリを作ってみましょう。

追加・変更されたファイル

lib/scores.ts

データベースSqlite3を扱う関数のファイルです。

  • Deno v2.2でNode.jsのsqliteモジュールと同等モジュールがサポートされました
  • Node.jsでは良く使われているPrismaはDenoで現時点では正式にはサポートされてないようです
import { DatabaseSync } from "node:sqlite";
import { judge, randomHand, Score, Te } from "./jyanken.ts";

const db = new DatabaseSync("db/jyanken.db");
db.exec(`
	CREATE TABLE IF NOT EXISTS scores (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    human INTEGER NOT NULL,
    computer INTEGER NOT NULL,
    judgment INTEGER NOT NULL,
    matchDate DATETIME NOT NULL
	);`
);

export const scores = (): Score[] => {
  const rows = db.prepare("SELECT * FROM scores ORDER BY id DESC").all();

  return rows.map(row => ({...row, matchDate: new Date(row.matchDate as string)} as Score));
}
export const pon =  (humanHandString: string | undefined) => {
  if (humanHandString === undefined) {
    throw new Error("* humanHandString is null");
  }

  const humanHand = Number(humanHandString) as Te;
  const computerHand = randomHand();

  db.prepare(
    `INSERT INTO scores (human, computer, judgment, matchDate) 
       VALUES (?, ?, ?, datetime('now', 'localtime'));`
  ).run(humanHand, computerHand, judge(humanHand, computerHand));
}
components/JyankenBox.tsx

グー・チョキ・パーのボタンが並ぶコンポーネントです。

  • ボタン毎に<form>になっています、また手の値は<input type="hidden" name="hand" value={値} />で指定しています
  • ボタンを押すと/scores/statusに手の値がPOSTされます
  • Propsは無くなりました
  • routes/scores/index.tsxから呼び出されるのでサーバーサイドで実行されます
import { Te } from "../libs/jyanken.ts";

export default function JyankenBox () {
  const buttonClass = `text-white text-center text-sm rounded w-16 px-2 py-2
    bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50`;

  return (
    <div class="w-[230px] mx-auto flex mb-10">
      <form method="post">
        <button type="submit" class={buttonClass}>グー</button>
        <input type="hidden" name="hand" value={Te.Guu} />
      </form>
      <form method="post">
        <button type="submit" class={`${buttonClass} mx-5`}>チョキ</button>
        <input type="hidden" name="hand" value={Te.Choki} />
      </form>
      <form method="post">
        <button type="submit" class={buttonClass}>パー</button>
        <input type="hidden" name="hand" value={Te.Paa} />
      </form>
    </div>
  );
}
routes/scores/index.tsx

/scores パスでアクセスした際に実行されるコンポーネントであり、ジャンケンボタンを押した際のPOSTリクエストを受け付けます。

Freshではあるパスへのアクセスがあった際の処理をハンドラー(handler関数)で定義できます。ハンドラーがないとGETアクセスではexport defaultされている関数コンポーネントが実行されます。POSTアクセスが来ると405エラーになります。

  • /scores パスでアクセスされた際のハンドラーです
  • ② GETアクセスではctx.render()でコンポーネントが実行
  • ③ POSTアクセスのハンドラーです
    • POSTされたhandの値を取り出して、ジャンケンを行いデータベースに書き込むpon()関数を実行
    • レスポンスコード302を戻し、/scoresへリダイレクトします
  • /scores パスでアクセスで実行されるコンポーネントです
    • ScoreListコンポーネントには、scores()関数でデータベースからジャンケン結果を取り出して渡しています
import { Handlers } from "$fresh/server.ts";
import ApplicationBar from "../../components/ApplicationBar.tsx";
import JyankenBox from "../../components/JyankenBox.tsx";
import ScoreList from "../../components/ScoreList.tsx";
import  { pon, scores } from "../../libs/scores.ts";

export const handler: Handlers = {              // ← ①
  async GET(_req, ctx) {                        // ← ②
    return await ctx.render();
  },
  async POST(req, _ctx) {                       // ← ③
    const form = await req.formData();
    pon(form.get("hand")?.toString());

    const headers = new Headers();
    headers.set("location", "/scores");
    return new Response(null, {
      status: 302,
      headers,
    });
  },
};

export default function Home() {              // ← ④
  return (
    <>
      <ApplicationBar />
      <div class="mx-2 md:mx-8 md:w-1/2">
        <h1 class="my-6 text-center text-xl font-bold">
          対戦結果
        </h1>
        <JyankenBox />
        <ScoreList scores={scores()} />
      </div>
    </>
  );
}
routes/status/index.tsx

routes/scores/index.tsxとほぼ同じですね。

import { Handlers } from "$fresh/server.ts";
import ApplicationBar from "../../components/ApplicationBar.tsx";
import JyankenBox from "../../components/JyankenBox.tsx";
import StatusBox from "../../components/StatusBox.tsx";
import { calcStatus } from "../../libs/jyanken.ts";
import { pon, scores } from "../../libs/scores.ts";

export const handler: Handlers = {
  async GET(_req, ctx) {
    return await ctx.render();
  },
  async POST(req, _ctx) {
    const form = await req.formData();
    pon(form.get("hand")?.toString());

    const headers = new Headers();
    headers.set("location", "/status");
    return new Response(null, {
      status: 302,
      headers,
    });
  },
};

export default function Home() {
  return (
    <>
      <ApplicationBar />
      <div class="mx-2 md:mx-8 md:w-1/2">
        <h1 class="my-6 text-center text-xl font-bold">
          対戦成績
        </h1>
        <JyankenBox />
        <StatusBox status={calcStatus(scores())} />
      </div>
    </>
  );
}
routes/index.tsx

ハンドラーで、レスポンスコード302を戻し/scoresへリダイレクトします。

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

export const handler: Handlers = {
  GET(_req, _ctx) {
    return new Response(null, {
      status: 302,
      headers: {
        Location: "/scores"
      },
    });
  },
};
islands/JyankenApp.tsx

無くなりました

まとめ

Freshはサーバーサイドで動くWebフレームワークです。さらに、アイランドアーキテクチャでブラウザー上で動くJavaScript(Preact)コンポーネントをサーバーサイドで生成するHTMLの中に埋め込めます。
たとえば、コメント付きのブログ・サイトなら、ヘッダーやメニュー、ブログ文書はサーバーコンポーネントで作り、コメント入力欄・表示はブラウザーで動くコンポーネントで作る事ができます。

なんだか、React Server Component(RSC)に似ているような気もしますが、RSCはクライアントとサーバーで動くコンポーネントを自由な組み合わせで使えます(一部制限はありますが)、またサーバー関数をクライアントコンポーネントから呼び出せたりと自由度が高いです。
したがって、アーキテクチャを適切に設計しないとメンテナンス性や性能の低いアプリになってしまいます。

それに対して、Freshはサーバーサイドが基本でブラウザーで実行する部分(アイランド)を追加するというシンプルな構成で実現できるのが良い点だと思います。
さらに、このブログで使っているAstroアイランドアーキテクチャを採用していてFreshと似たように使えます。AIに聞いたところアイランドアーキテクチャのWebフレームワークにはMarko, Enhance, is-land などがあるようです。流行なのでしょうか 😊

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ