🦕

Denoのフロントエンド開発の動向【2022年春】

2022/05/09に公開

半年程前に、以下のような記事を書きました。

https://zenn.dev/uki00a/articles/frontend-development-in-deno-2021-autumn

Denoでのフロントエンド開発に関して、ここ半年程でまた大きな動きがあったため、改めてまとめていきたいと思います。

Aleph.js v1.0.0のアルファバージョンがリリース

Deno製のフレームワークであるAleph.jsのv1.0.0 アルファバージョンが公開されました。

現在、v1.0.0のリリースに向けて積極的に開発が進められています。

https://github.com/alephjs/aleph.js/issues/461

Aleph.jsやesm.shなどの作者であるJe Xia氏がDeno社に加わったこともあり、ここ半年で大幅に開発が進んでいる印象です。

https://twitter.com/jexia_/status/1473131210776150017

また、Aleph.jsは元々はNext.jsに影響を受けたフレームワークという位置づけでしたが、ここ最近ではRemixなどのフレームワークの影響も徐々に受けているような印象を感じています。

ここでは、Aleph.js v1に向けて行われている大きな変更などについて解説していきます。

Deno Deployサポート

Deno DeployとはCDN Edge上でJavaScriptやTypeScriptなどを実行するための分散ホスティングサービスです。

現在、Aleph.jsではこのDeno Deployをサポートするための対応が進められています。

実際に公式で公開されているデモアプリもDeno Deployに移行されつつあります。

例)

この対応が進めば、将来的にはCDN Edge上でSSRの実行などができるようになりそうです。

deno.jsonのサポート

deno.jsonはDenoの設定ファイルで、これによってリンタ(deno lint)やタスクランナ(deno task)などの挙動をカスタマイズすることができます。

aleph initコマンドでプロジェクトを作成した際に、このdeno.jsonが自動で作成されるようになりました。

$ deno run -A https://deno.land/x/aleph@1.0.0-alpha.27/cli.ts init

生成されたdeno.jsonには開発に必要な各種タスクがあらかじめ定義されており、今後はdeno taskコマンドによって様々な処理を行うことができます。

# devサーバの起動
$ deno task dev

# ビルド
$ deno task build

ディレクトリ構成の変更

後述するAPIエンドポイントに関する変更にも関連しますが、pages/api/ディレクトリが廃止され、routes/ディレクトリに統合されています。

また、従来までに存在していたaleph.config.tsという設定ファイルが廃止され、代わりにserver.tsxというファイルが導入されました。

今後はこのserver.tsxでAleph.jsの設定をカスタマイズする必要があります。

server.tsx
import { Router } from "aleph/react";
import { serve } from "aleph/server";
import { renderToReadableStream } from "react-dom/server";

serve({
  config: {
    // ルーティングに関する設定
    routes: "./routes/**/*.{tsx,ts}",
  },
  // SSRに関する設定
  ssr: {
    suspense: true,
    render: (ctx) => renderToReadableStream(<Router ssrContext={ctx} />, ctx),
  },
});

その他にも、main.tsxというファイルが追加されており、このファイルはクライアントのエントリポイントとして振る舞います。

APIエンドポイントの定義方法が変更

今まで、Aleph.jsではapi/ディレクトリにハンドラを用意することでAPIエンドポイントを作成することができました。

このAPIエンドポイントの定義方法に大きな変更が行われました。

具体的には、下記のようにroutes/配下のコンポーネントファイル内でdataexportする必要があります。

import { useData } from "aleph/react";
import { useState } from "react";
import type { FormEvent } from "react";

interface User {
  id: number;
  name: string;
}

let nextUserId = 0;
const users: Array<User> = [
  { id: nextUserId++, name: "foo" },
  { id: nextUserId++, name: "bar" },
];
export const data: Data = {
  get(_req, ctx) {
    return ctx.json({ users });
  },
  async post(req, ctx) {
    const { name } = await req.json();
    const id = nextUserId++;
    const user = { id, name };
    users.push(user);
    return ctx.json({ users });
  },
};

export default function Users() {
  const { data, isMutating, mutation } = useData<{ users: Array<User> }>();
  const [userName, setUserName] = useState("");
  const onSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    mutation.post({ name: userName }, { replace: true }).then(() => {
      setUserName("");
    });
  };
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {data.users.map((x) => {
          return <li key={x.id}>{x.name}</li>;
        })}
      </ul>
      <form
        onSubmit={onSubmit}
      >
        <input
          type="text"
          name="name"
          value={userName}
          onChange={(e) => setUserName(e.target.value)}
          disabled={Boolean(isMutating)}
        />
      </form>
    </div>
  );
}

このようにページコンポーネントとAPIを同一ファイル内で定義します。

定義したエンドポイントとやり取りするにはuseData()を使います。

  const { data, isMutating, mutation } = useData<{ users: Array<User> }>();

useData()によって返却されるdataには、getエンドポイントから取得されたデータが自動で設定されます。

  mutation.post({ name: userName }, { replace: true });

また、postdeleteなどのエンドポイントとやり取りする際は、上記コードのようにuseData()から返却されるmutationオブジェクトを利用します。

React以外のフレームワークのサポート

Aleph.jsにはloaderという機能があり、これによって独自のファイルタイプをサポートすることができます。

現在、Aleph.jsにはVue.js向けのloaderが存在し、これによりSFCの読み込みがサポートされています。

https://github.com/alephjs/aleph.js/tree/1.0.0-alpha.20/examples/vue-app

ただし、Vue.js向けにはルーティングなどの機能がまだサポートされていないようです。現在のVue.jsサポートに関する状況については、下記ロードマップを参照ください。

https://github.com/alephjs/aleph.js/issues/461

UnoCSSの組み込みサポート

Aleph.jsにUnoCSSの組み込みサポートが加わっています。
UnoCSSを有効化する際は、server.tsxで例えば、以下のように設定します。

import presetUno from "@unocss/preset-uno.ts";
import { Router } from "aleph/react";
import { serve } from "aleph/server";
import { renderToReadableStream } from "react-dom/server";

serve({
  config: {
    routes: "./routes/**/*.{tsx,ts}",
    // UnoCSSの設定
    unocss: {
      presets: [presetUno()]
    }
  },
  ssr: {
    suspense: true,
    render: (ctx) => renderToReadableStream(<Router ssrContext={ctx} />, ctx),
  },
});

これにより@unocss/preset-unoが利用できるようになります

また、必要に応じてリセットCSSも読み込ませるとよいでしょう。

<link rel="stylesheet" href="https://esm.sh/@unocss/reset@0.32.1/tailwind.css">

その他の変更点

Fresh

FreshはDeno Deployで動作させることを想定したPreactベースのフレームワークです。

事前のビルドステップを必要とせず、CDNエッジ(Deno Deploy)上でオンデマンドでのビルド及びレンダリングを提供するのが大きな特徴です。

このFreshについてもここ半年程で大きな動きがありました。

Island Architectureのサポート

FreshでIsland Architecture[1]がサポートされました。

https://github.com/lucacasonato/fresh/pull/97

この変更に合わせて、ディレクトリ構造にも大きな見直しが行われています。

  • pages/ディレクトリがroutes/にリネーム
    • routes/にはコンポーネントまたはAPIエンドポイントを配置します。
    • routes/ディレクトリのコンポーネントは従来通りSSRされますが、Hydrationは行われません。
  • islands/ディレクトリが追加
    • このディレクトリに配置したコンポーネントはクライアント上でレンダリング及びHydrationされます。
  • routes.gen.tsfresh.gen.tsへリネーム
    • fresh.gen.tsfresh manifestコマンドで自動生成または更新されます。

これらの変更に合わせて、useData()フックや<Suspense>コンポーネントなどが一時的に削除されています(これらは近い将来に再度実装される予定のようです)

Custom App及びCustom Error Pageのサポート

routes/_app.tsxを用意することで、カスタムのAppコンポーネントを定義できるようになりました。

routes/_app.tsx
/** @jsx h */
/** @jsxFrag Fragment */
import { AppProps, h, Fragment, Head } from "../client_deps.ts";

export default function App(props: AppProps) {
  return (
    <>
      <Head>
        <meta name="description" content="Sample Fresh App" />
        <meta property="og:type" content="website" />
        <meta property="og:description" content="Sample Fresh App" />
      </Head>
      <props.Component />
    </>
  );
}

また、カスタムのエラーページもサポートされています。

例えば、routes/_404.tsxを用意すると、404エラー発生時にレンダリングされるページを定義することができます。

routes/_404.tsx
/** @jsx h */
import { h, UnknownPageProps } from "../client_deps.ts";

export default function NotFound({ url }: UnknownPageProps) {
  return (
    <div>
      <p>
        Not Found: {url.pathname}
      </p>
    </div>
  );
}

注意点として、これらのファイルを追加した際はfresh manifestの再実行が必要です。

deno.landがFreshで再実装された

新機能とはちょっと関係ない話なのですが、Denoの公式サイトであるdeno.landがFreshで再実装されました。

https://github.com/denoland/dotland/pull/2016

元々、deno.landはNext.js+Vercelで動作していましたが、これにより全面的にDenoに書き換えられた状況です。

また、CSSについては元々TailwindCSSが使われていましたが、Denoでも動作するTwindへ移行されています。

安定版のリリース予定について

Freshの安定版リリースは、今のところ2022年5月末付近が予定されているようです。

https://github.com/lucacasonato/fresh/pull/152

安定版がリリースされるまではまだ大きな変更が行われる可能性もあるため、本番での利用などは基本的にまだ推奨されていない状況です。

Ultra v1がリリース

Deno製のReactベースのフレームワークであるUltraのv1がリリースされました。

APIルートのサポート

UltraでAPIルートがサポートされました。

src/apiディレクトリ配下でRequestを受け取りResponseまたはPromise<Response>を返却する関数をdefault exportすることで、APIエンドポイントを定義することができます。

例えば、src/api/hello.tsに下記のようなファイルを用意しておくと、/api/helloからAPIにアクセスできます。

export default () => {
  return new Response("Hello");
};

deno.jsonとの統合

Ultraの利用時にdeno.jsonが要求されるようになりました。
今後はサーバの起動などもdeno taskで行う想定のようです。

# 例) devサーバを起動する
$ deno task dev

また、Ultraはルートディレクトリ配下のimportMap.jsonに特定の依存関係が定義されていることを前提としています。

そのため、Ultraを利用する際はcreate-ultra-appをベースにプロジェクトを起こすと安全だと思います。

サードパーティモジュールのベンダリング

Deno本体のベンダリングの仕組み(deno vendor)とは異なる独自のベンダリングの仕組みが追加されました。(おそらく、UltraがImport Mapsファイルの使用を前提としており、deno vendorとは噛み合わなかったためだと思われます)

deno task vendorコマンドを実行すると、プロジェクトの依存関係を.ultra/vendorに書き込むことができます。

$ deno task vendor

ベンダリングされた依存関係は、deno task vendorによって生成されたvendorMap.jsondeno.jsonimportMapオプションに設定することで読み込むことができます。

その他の変更点

RemixがDenoを実験的にサポート

Remix v1.2.0でDenoのアダプタ[2]が実装されました。

https://github.com/remix-run/remix/releases/tag/v1.2.0

これにより、Remixアプリのデプロイ先としてDeno Deployなどを利用する余地が生まれました。

プロジェクトの初期化

create-remixでの初期化時にdenoテンプレートを使うと、Deno向けのプロジェクトを生成できます。

$ yarn create remix --template https://github.com/remix-run/remix/tree/v1.4.3/templates/deno

これにより、プロジェクトの雛形が生成されます。

devサーバの起動

下記コマンドでdevサーバを起動できます。

$ yarn dev

このコマンドを実行すると、下記の処理が実行されます。

  1. remix buildによるサーバのビルド (build/index.js)
  2. remix watchによるファイルの変更監視及び再ビルドの有効化
  3. build/index.jsの起動 (Denoで実行されます)

本番ビルド

本番環境向けのビルドを実行する際は、下記で行えます。

$ yarn build

ディレクトリ構成について

denoテンプレートを使用してプロジェクトを生成した場合、通常のRemixテンプレートを使用したときと比べて、いくつか異なる点があります。

ここでは特筆すべき点について簡単に解説します。

remix-denoディレクトリ

このディレクトリにはDenoアダプタの実体が含まれます。

このディレクトリ内のファイルは直接編集すべきではありません。

server.ts

サーバのエントリポイントです。

ビルド後のサーバ(build/index.js)は、このファイルの内容を元に生成されています。

app/depsディレクトリ

Reactなどの依存パッケージはこのディレクトリで管理されています。

各依存パッケージはesm.shからimportされていますが、これらの依存関係はesbuild-plugin-cacheを使用してビルド時に解決しているようです。

制限

remix CLI(@remix-run/dev)を使う必要がある関係上、開発時にはDenoに加えてNode.jsも必要になります。

また、ファイル変更時のライブリロードはまだサポートされていません。ファイルの変更時は手動でブラウザをリロードする必要があります。

AstroがDenoアダプタをサポート

AstroはNode.js製の静的サイトジェネレータです。

Island Architecture[1:1]を採用していたり、React, Vue.js, 及びSvelteなど様々なフレームワークをサポートしているところなどが大きな特徴です。

このAstroのv0.26.0Denoアダプタがサポートされました。

https://github.com/withastro/astro/pull/2934

これにより、SSRの実行環境としてDenoを利用できるようになりました。

利用方法

Denoアダプタを利用するには、まず@astrojs/denoパッケージを導入する必要があります。

このパッケージはastro addコマンドで追加することができます。

$ astro add deno

これにより、Denoアダプタのインストールと有効化を実施できます。

Denoアダプタを有効化した状態でビルドを実行すると、dist/server/entry.mjsというファイルが生成されます。

$ astro build

このdist/server/entry.mjsがサーバのエントリポイントになります。
main.jsを用意し、このファイルをimportしてみましょう。

main.js
import "./dist/server/entry.mjs";

main.jsをDenoで実行すると、サーバが起動します。

$ deno run --allow-read --allow-net main.js

そして、サーバにアクセスするとDenoによってAstroコンポーネントがSSRされます。
このようにして、Astro製のアプリをDenoで動作させることができます。

SvelteKitのDenoアダプタが公開

非公式ですがSvelteKitのDeno向けのアダプタが公開されています。

https://github.com/pluvial/svelte-adapter-deno

これを利用することで、SvelteKit製のアプリをDeno Deployなどにデプロイすることができます。

例) https://svelte-adapter-deno.deno.dev/

ちなみに、DenoでSvelteアプリを開発するためのツールとしてSnelというものも存在し、こちらを使ってDenoでSvelteアプリを開発することもできます。

https://github.com/crewdevio/Snel

FaaSの拡充

Supabase Functions

Deno社とSupabaseにより、共同でSupabase Functionsが公開されました。

https://deno.com/blog/supabase-functions-on-deno-deploy

Deno Deployをベースにしており、ローカルで記述したTypeScriptの関数をそのままSupabase Functionsへデプロイすることができます。
また、supabase-jsを使用してデータベースなどを操作することもできるようです。

実際のSupabase Functionsの利用方法などについては下記記事などで詳しく解説されています。

Netlify Edge Functions

Deno社とNetlifyにより、共同でNetlify Edge Functionsのpublic betaバージョンが公開されました。

https://deno.com/blog/netlify-edge-functions-on-deno-deploy

これを利用することで、Netlify上のサイトに様々な機能を追加することができます。

こちらもSupabase Functionsと同様にDeno Deployをベースにしており、TypeScriptによって関数を記述することができます。

また、現在、様々なフレームワークでNetlify Edge Functionsのサポートが進められています。

タスクランナ

この記事内でもすでに何度か登場していますが、Deno本体にタスクランナ(deno taskコマンド)が実装されました。

deno taskを利用するには、まずdeno.json(c)でタスクを定義する必要があります。

deno.json
{
  "tasks": {
    "test": "deno test --allow-read=tests/tmp --allow-write=tests/tmp ./tests"
  }
}

定義したタスクは次のようにして実行することができます。

# `deno test --allow-read=tests/tmp --allow-write=tests/tmp ./tests`が実行されます
$ deno task test

このdeno taskの大きな特徴として、標準でcross-env相当の機能やcdmvなどのコマンドを組み込みで提供していることが挙げられます。

そのため、WindowsやMacなどOSを問わずに統一された方法で定義したタスクを実行できるのがメリットです。

テスト

Denoの標準モジュールであるdeno_stdでテスト関連の機能の拡充が行われています。

deno_std/testing/bdd

JestやMochaライクなスタイルでテストコードを記述することができます。

import { assertEquals } from "https://deno.land/std@0.135.0/testing/asserts.ts";
import { describe, it } from "https://deno.land/std@0.135.0/testing/bdd.ts";

describe("sum", () => {
  it("should return sum of numbers", () => {
    assertEquals(sum(1, 2, 5), 8)
  });

  it("should return 0 when no arguments are given", () => {
    assertEquals(sum(), 0);
  });
});

定義したテストコードは通常通りdeno testコマンドで実行できます。

deno_std/testing/mock

deno_std/testing/mockモジュールが追加されました。

このモジュールで提供されているspy()を使用することで、関数の利用を追跡することができます。

import { assertSpyCall, assertSpyCalls, spy } from "https://deno.land/std@0.133.0//testing/mock.ts";
import { assertEquals } from "https://deno.land/std@0.133.0/testing/asserts.ts";

function add(a: number, b: number): number {
  return a + b;
}

const addSpy = spy(add);
assertEquals(addSpy(1, 2), 3); // => OK
assertEquals(addSpy(2, 3), 5); // => OK
assertSpyCall(addSpy, 0, {
  args: [1, 2],
  returned: 3,
}); // => OK
assertSpyCall(addSpy, 1, {
  args: [2, 3],
  returned: 5,
}); // => OK

assertSpyCalls(addSpy, 2); // => OK

また、stub()を使用することで、特定のオブジェクトのメソッドをスタブすることができます。

import { assertSpyCalls, returnsNext, stub } from "https://deno.land/std@0.133.0/testing/mock.ts";
import { assertEquals } from "https://deno.land/std@0.133.0/testing/asserts.ts";

const randomUUID = stub(crypto, "randomUUID", returnsNext([
  "00000000-0000-0000-0000-000000000000",
  "11111111-1111-1111-1111-111111111111",
]));
try {
  assertEquals(crypto.randomUUID(), "00000000-0000-0000-0000-000000000000"); // => OK
  assertEquals(crypto.randomUUID(), "11111111-1111-1111-1111-111111111111"); // => OK
  assertSpyCalls(randomUUID, 2); // => OK
} finally {
  randomUUID.restore();
}

スナップショットテスティング(deno_std/testing/snapshot)

スナップショットテスティング用にdeno_std/testing/snapshotモジュールが追加されています。

import { assertSnapshot } from "https://deno.land/std@0.136.0/testing/snapshot.ts"

Deno.test("doSomething", async (t) => {
  const result = doSomething();
  await assertSnapshot(t, result);
});

assertSnapshotを利用することで、__snapshots__ディレクトリに保存されたスナップショットと現在の状態を比較することができます。(スナップショットを読み込む必要があるため、利用するには--allow-readの指定が必要です)

また、スナップショットを更新したいときは、テストを実行する際に--updateを指定する必要があります。

# スナップショットは__snapshots__に書き込まれます
$ deno test --allow-read --allow-write tests/some_test.js -- --update

この場合、スナップショットを書き込むために、追加で--allow-writeが必要になります。

まとめ

Deno製の各種フレームワークで安定化に向けた動きが見られました。

現在、Aleph.jsやFreshで安定版リリースに向けた動きが行われており、これらの安定版がリリースされれば、フロントエンド開発での採用も徐々に増えていくのではないかと想像しています。

また、RemixやAstroなどのNode.jsベースのフレームワークでも少しずつDenoのサポートが進んでいます。

これにより、これらフレームワークで開発したアプリのデプロイ先としてDeno Deployなどを利用するという選択肢も生まれてきそうです。

ただし、いずれもDeno単独での開発はまだ行えず、Node.jsを併用する必要があります。
Denoではこういった問題などを解消するために、Node.js互換モードの開発が進められています。

https://zenn.dev/uki00a/articles/node-compat-mode-introduced-in-deno-v1-15

このNode.js互換モードが発展していけば、例えば、Deno+Remixでアプリケーションを開発し、デプロイ先としてDeno Deployなどを利用するという選択肢も将来的には生まれてくるかもしれません。

参考

脚注
  1. 主にSSRの文脈において、ページ全体を細かなチャンク(islands)に分割し、それらのチャンクを独立してHydrationすることによりパフォーマンスの向上などが期待できるというものです。より詳しくはIslands Architectureを参照ください。 ↩︎ ↩︎

  2. アダプタとはプラットフォームに応じて差異を吸収することで、様々なプラットフォームでRemixアプリを動作させるための仕組みです。 ↩︎

Discussion