🦕

Denoのフロントエンド開発の動向【2024年冬】

2024/02/26に公開

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

https://zenn.dev/uki00a/articles/frontend-development-in-deno-2023-summer

この記事では、上記の記事から半年程の間で起きたDenoでのフロントエンド開発に関して影響がありそうな内容などをまとめていきます。

Deno本体のアップデート

直近半年ほどでDenoに導入された機能からフロントエンド開発に影響しそうなものについていくつか紹介します。

npmパッケージの対応状況について

大きな点として、Deno v1.35からAstroが動作するようになったようです。

https://twitter.com/astrodotbuild/status/1677075022622621696

https://zenn.dev/cybozu_frontend/articles/deno-use-astro

それ以外にはDenoでNext.jsを動かすための試みがいくつか進んでいるようです。next buildnext devを動かすための対応がいくつか入っています。

https://github.com/denoland/deno/pull/21527
https://github.com/denoland/deno/issues/22189
https://github.com/denoland/deno/pull/22247

また、直近で公開されたロードマップではSvelteKit, VuePress, Qwik, Remixなどを動かすための対応を進めていくことが検討されているようです。

https://github.com/denoland/deno/issues/22337

Remixについては公式でRemix製アプリをDenoで動かすための@remix-run/denoが提供されています。Viteサポートが安定化されたこともあり、今後、Denoで開発も含めて行えるようになる可能性がより高くなりそうな気がしています。

https://remix.run/blog/remix-vite-stable

Node.js製プロジェクトのサポートの強化

ここ半年程で、Node.jsで書かれたプロジェクトをそのままDenoで動かすまたは移行をサポートするための機能がいくつか導入されています。

その一つとしてBYONM (Bring your own node_modules)という機能がDeno v1.38で導入されました。Denoには独自にnode_modulesやロックファイル(deno.lock)などを管理する仕組みが存在します。しかし、既存のNode.jsプロジェクトではこれらの仕組みをスムーズに利用することは難しい可能性もあります。また、npmのpreinstallpostinstallスクリプトなどに依存したパッケージを利用したい場合、Denoではこれらの機能はまだサポートされていないため、利用することが難しいです。

https://github.com/denoland/deno/issues/16164

BYONMはこういった課題などを解消することを目的に導入されました。BYONMはnpmpnpmなどのパッケージマネージャーによって作成されたnode_modulesディレクトリから直接パッケージを読み込むことができる機能です。パッケージ管理は既存のパッケージマネージャーに任せて、Denoからはそれらでインストールされたパッケージをそのまま利用するようなイメージです。

deno.jsonc
{
  "unstable": [
    // `--unstable-byonm`オプションでも有効化可能です
    "byonm"
  ]
}

このBYONMを利用したい場合は、現在は上記のようにdeno.jsonなどで明示的に有効化する必要があります。ただし、将来的にはpackage.jsonが検出された際は自動的にBYONMを有効化することも検討されているようで、今後、利用方法などが変わる可能性もありそうです。

このBYONM以外にもNode.jsプロジェクトをDenoで動かすまたは移行をサポートするための機能がいくつか導入されています。もし既存のNode.jsプロジェクトをDenoで動かしてみたい場合は、BYONMとセットで有効化してみるとよいかもしれません。

deno.jsonc
{
  "unstable": [
    "byonm", // BYONMを有効化します
    "bare-node-builtins", // `node:`なしでNode.js組み込みモジュールを読み込めるようにします
    "sloppy-imports" // index.jsの自動探索など、Node.jsライクなモジュールの読み込みを有効化します
  ]
}

precompiled JSX transform

compilerOptions.jsxで指定できるDeno独自の設定としてprecompileがサポートされました。

deno.json
{
  "compilerOptions": {
    "jsx": "precompile",
    "jsxImportSource": "preact"
  },
  "imports": {
    "preact": "https://esm.sh/preact@10.19.0",
    "preact/": "https://esm.sh/preact@10.19.0/"
  }
}

これは主にSSRにおけるパフォーマンス最適化のために導入された機能で、DenoがJSXファイルなどをトランスパイルする際に、JSXテンプレートに含まれるHTMLノードをあらかじめ静的な文字列を含む配列に変換しておくことでオブジェクトの割り当てを減らします。

https://github.com/denoland/deno_ast/releases/tag/0.31.0

現状、この機能はPreacthono/jsxでサポートされています。また、Freshでも今後、サポートが検討されているようです。

Fresh

FreshはIslandアーキテクチャをサポートするDeno公式のWebフレームワークです。Deno/Preact/esbuildなどをベースに実装されています。

Partials

Freshでは必要がない限りはクライアントサイドJavaScriptが一切使用されません。[1]もしJavaScriptを使用した動的なインタラクションなどを実現したい場合、Islandコンポーネントを実装する必要があります。

Fresh v1.5ではこのIslandコンポーネントの仕組みに加えてPartialsという新機能が導入されました。PartialsはFreshでクライアントサイドナビゲーションを実現するための仕組みです。Partialsを利用することで、特定のリンクのクリックやフォームの送信時に、ページ全体を再読込みせずにページ内の特定の領域のみを部分的に更新することができます。

この機能はhtmxに影響を受けて導入されたようです。

https://github.com/denoland/fresh/pull/1824

利用方法

Partialsを有効化するためには、まずクライアントサイドナビゲーションを有効化したいリンクやフォームなどを含むコンテナ要素に対してf-client-nav属性を指定する必要があります。

例えば、以下の例では<nav>要素にf-client-nav属性を指定しています。この<nav>要素の配下の<a>がクリックされると、ページ全体が再読込みされず、クライアントサイドのみでナビゲーションが行われます。

components/Sidebar.tsx
interface SidebarProps {
  docs: Array<{ title: string; link: string }>;
}

export function Sidebar(props: SidebarProps) {
  return (
    <nav f-client-nav>
      <ul class="flex flex-col gap-2">
        {props.docs.map((x) => (
          <li>
            <a
              class="[data-current]:font-bold"
              href={x.link}
            >
              {x.title}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Partialsを利用する上でf-client-navに加えてもう一つ重要な要素があります。それが<Partial>コンポーネントです。f-client-navが適用されたリンクによりクライアントナビゲーションが発生した際に更新したい領域を<Partial>コンポーネントによって囲みます。

例えば、以下の例では<main>の配下で<Partial>コンポーネントが利用されています。<Partial>を利用する際にはnameを指定する必要があり、これはページ内で一意である必要があります。

routes/[...path].tsx
import { Partial } from "$fresh/runtime.ts";
import type { Handlers, PageProps } from "$fresh/server.ts";
import { Sidebar } from "../components/Sidebar.tsx";

interface Data {
  content: string;
}

export default function Page(props: PageProps<Data>) {
  return (
    <>
      <aside class="min-w-[14rem] p-4">
        <Sidebar
          docs={[
            { title: "TOP", link: "/docs" },
            { title: "Permissions", link: "/docs/permissions" },
          ]}
        />
      </aside>
      <main>
        {/*
          * <Sidebar>内部のf-client-navが適用されたリンクをクリックすると、
          * 下記の領域のみが更新されます
          */}
        <Partial name="docs-content">
          <article
            data-color-mode="auto"
            data-dark-theme="dark"
            class="p-4 mx-auto w-full markdown-body"
            dangerouslySetInnerHTML={{ __html: props.data.content }}
          />
        </Partial>
      </main>
    </>
  );
}

export const handler: Handlers<Data> = {
  async GET(_, ctx) {
    const content = await readContent(ctx.params.path);
    const data: Data = { content };
    return ctx.render(data);
  },
};

f-client-navが適用されたリンクをクリックした際は、ページ全体は再レンダリングされずに上記の<Partial>が適用された領域のみが更新されます。

このようにf-client-nav<Partial>コンポーネントを併用することでクライアントサイドでのナビゲーションが実現されています。

仕組みについて

Freshはf-client-navが適用された要素に対するクリックやサブミットなどを検出した際に、該当要素のhrefなどで指定されたリンクに対してfetchでHTTPリクエストを送信します。

例えば、以下のPermissionsリンクがクリックされた場合、/docs/permissionsに対してfetchでリクエストが送信されます。

<nav f-client-nav>
  ...
      <a
        class="[data-current]:font-bold"
        href="/docs/permissions"
      >
        Permissions
      </a>
  ...
</nav>

すると、Freshのサーバー上でルートコンポーネントがSSRされ、HTMLがレスポンスとして返却されます。Freshはレスポンスに含まれるHTMLを解析し、その中から<Partial>によってラップされた領域に関するHTMLのみを抽出します。<Partial>によってラップされた領域を抽出されたHTMLによって書き換えることで部分的な更新が実現されています。

最適化について

FreshはPartialsによるナビゲーションを実現するために、fetchでサーバーに対してページ全体のSSRを要求します。これは小さなページでは気にならないかもしれませんが、ある程度の規模以上になるとボトルネックとなる可能性も出てくるかもしれません。

そういったケースに備えて最適化方法が2つ提供されています。

まず、f-client-navが適用された<a><form>要素などにはf-partialという独自の属性を設定することができます。例えば、<a>要素にこの属性が設定されている場合、Freshはfetchによってリクエストを送信する際にhrefではなくf-partialで指定されたエンドポイントへ問い合わせを行います。その後は、該当のエンドポイントから返却されたHTMLの内容を元に、通常通り<Partial>で囲まれた領域を更新した後、hrefで指定されたURLへアドレスが更新されます。

<a
   href={`/posts/${post.id}`}
   f-partial={`/partials/posts/${post.id}`}
   class="font-bold"
>
  {post.title}
</a>

もう一つはisPartialというプロパティがあって、こちらを元に最適化することも可能です。クライアントサイドナビゲーションのためにfetchによるリクエストが送信された場合、このプロパティにはtrueが設定されるため、必要最小限のHTMLのみを返すことでオーバーヘッドの削減が期待されます。

routes/[...path].tsx
import { Partial } from "$fresh/runtime.ts";
import type { Handlers, PageProps } from "$fresh/server.ts";
import { Sidebar } from "../components/Sidebar.tsx";

interface Data {
  content: string;
}

export default function Page(props: PageProps<Data>) {
  const partial = (
    <Partial name="docs-content">
      <article
        data-color-mode="auto"
        data-dark-theme="dark"
        class="p-4 mx-auto w-full markdown-body"
        dangerouslySetInnerHTML={{ __html: props.data.content }}
      />
    </Partial>
  );

  if (props.isPartial) {
    return partial;
  }

  return (
    <>
      <aside class="min-w-[14rem] p-4">
        <Sidebar
          docs={[
            { title: "TOP", link: "/docs" },
            { title: "Permissions", link: "/docs/permissions" },
          ]}
        />
      </aside>
      <main>
        {partial}
      </main>
    </>
  );
}

export const handler: Handlers<Data> = {
  async GET(_, ctx) {
    const content = await readContent(ctx.params.path);
    const data: Data = { content };
    return ctx.render(data);
  },
};

事前ビルド (AOTビルド)

Freshの特徴としてJust-in-time renderingを提供しています。これは事前にビルドフェーズ(例: next build)を要求せず、必要に応じてフレームワークがビルドを行ってくれるものです。FreshはIslandコンポーネントやPartialsなどを利用しない限りはクライアントサイドJavaScriptが有効化されない[1:1]ため、それらの機能を利用しない限りはビルドが不要になります。

ただし、Just-in-time renderingの欠点としてプロジェクトに含まれるIslandコンポーネントの数が多くなった場合などに、レスポンスに応答するまでの時間が長くなってしまうケースが考えられます。この問題をカバーするために、Fresh v1.4で事前ビルド(AOTビルド)の仕組みが導入されました。

Fresh v1.4以降のバージョンで新規プロジェクトを作成すると、deno task buildコマンドを利用することができます。

$ deno task build

このコマンドを実行すると、プロジェクトで利用されているIslandコンポーネントなどをesbuildによってバンドルし、その結果が_freshディレクトリに出力されます。(このディレクトリは.gitignoreに含めることが推奨されます。)

Freshはサーバーの起動時に_freshディレクトリが存在していれば、Just-in-time renderingを行わずに_freshに配置してあるバンドルをそのまま利用します。これによりサーバーの起動が高速化されます。

この事前ビルドを利用する際は_freshディレクトリも含めてDeno Deployにデプロイする必要があります。その場合、Deno DeployによるGitHubリポジトリの自動デプロイ機能を無効化し、GitHub Actionsなどでdeno task build+deployctlを使用してデプロイを行う必要があります。

Tailwind CSSの公式サポート

Fresh v1.6でTailwind CSSの公式サポートが追加されました。

Fresh公式によりTailwind CSS向けのプラグイン ($fresh/plugins/tailwind.ts)が提供されており、これを有効化することでTailwind CSSのサポートを有効化できます。

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

import tailwind from "$fresh/plugins/tailwind.ts";

export default defineConfig({
  plugins: [tailwind()],
});

Freshの初期化スクリプトにより新規プロジェクトを作成する際にTailwind CSSを使用するかどうか質問されます。「はい(y)」と答えるとFreshが自動でプラグインの設定などを行ってくれるため、そちらでセットアップすると便利かと思います。

# Freshの初期化スクリプトを実行
$ deno run -A -r https://fresh.deno.dev

 🍋 Fresh: The next-gen web framework. 

Let's set up your new Fresh project.

Fresh has built in support for styling using Tailwind CSS. Do you want to use this? [y/N] 

...

新規プロジェクトではFreshの初期化スクリプトを使うとTailwind CSSに関する諸々の設定を行ってくれるため、そちらを使うと確実です。もしTwindなどを使用している既存プロジェクトでの移行方法や設定方法などについてはこちらのページを参照ください。

非同期Routeコンポーネント

非同期RouteコンポーネントはFresh v1.3で導入された新機能です。

今まで、ハンドラーでデータベースなどの外部のリソースから非同期にデータの取得を行い、その結果をPageコンポーネントでレンダリングしたい場合、以下のようにハンドラーとページコンポーネントをそれぞれ記述する必要がありました。

routes/[...path].tsx
import type { Handlers, PageProps } from "$fresh/server.ts";

// 外部のリソースから非同期でデータを読み込むためにハンドラーを定義します
export const handler: Handlers<Data> = {
  async GET(_, ctx) {
    const content = await readContent(ctx.params.path);
    const data: Data = { content };
    return ctx.render(data);
  },
};

// ハンドラーから受け取ったデータをレンダリングします
export default function Page(props: PageProps<Data>) {
  return (
    <>
      {/* ... */}
      <main>
        <Partial name="docs-content">
          <article
            data-color-mode="auto"
            data-dark-theme="dark"
            class="p-4 mx-auto w-full markdown-body"
            dangerouslySetInnerHTML={{ __html: props.data.content }}
          />
        </Partial>
      </main>
    </>
  );
}

新しく導入された非同期Routeコンポーネントを利用することでこのパターンを簡略化できます。非同期RouteコンポーネントとはRequestRouteContextを引数として受け取り、vnodeまたはResponseを非同期で返却する関数です。非同期Routeコンポーネントを定義したい場合はdefineRouteを使うと便利です。

routes/[...path].tsx
import type { Handlers, PageProps } from "$fresh/server.ts";
import { defineRoute } from "$fresh/server.ts";

export default defineRoute(async (_req, ctx) => {
  // 外部のリソースから非同期でデータを読み込みます
  const content = await readContent(ctx.params.path);

  // 読み込んだ結果を元にページをレンダリングします
  return (
    <>
      <aside class="min-w-[14rem] p-4">
        <Sidebar
          docs={[
            { title: "TOP", link: "/docs" },
            { title: "Permissions", link: "/docs/permissions" },
          ]}
        />
      </aside>
      <main>
        <Partial name="docs-content">
          <article
            data-color-mode="auto"
            data-dark-theme="dark"
            class="p-4 mx-auto w-full markdown-body"
            dangerouslySetInnerHTML={{ __html: content }}
          />
        </Partial>
      </main>
    </>
  );
}

このように非同期Routeコンポーネントを利用することで記述を簡略化できます。従来のハンドラー+Pageコンポーネントを利用した形式は引き続きサポートされており、従来の形式にもハンドラーを個別にテストしやすいなどのメリットもあります。好みや状況などに応じて使い分けるとよさそうです。

その他新機能

上記で紹介した機能以外にも便利そうな機能がたくさん導入されています。

プラグインでのMiddlewareやIslandコンポーネントなどのサポートを提供できるようになったことに伴い、これらの機能を活用したライブラリなどもいくつか作成されています。

https://github.com/Octo8080X/fresh-session

https://github.com/cbinzer/deno-kv-insights

今後について

Deno公式から2023年のまとめ記事が公開されています。

https://deno.com/blog/deno-in-2023

Freshについても記述されており、今後、機能としてView Transitions APIやprecompiled JSX transformのサポートなどを検討されているようです。

また、生産性の改善のためHMR (--unstable-hmr)のサポートなども検討されているようです。HMRの対応が入ると、さらに使い勝手が良くなりそうな気がしています。

Hono

HonoはCloudflare Workers/Bun/Node.jsなどをサポートする高速なWebフレームワークです。

HonoはDenoやDeno Deployでも動作し、フロントエンド開発に関連しそうな機能がいくつか導入されているため紹介します。

Hono v4

Hono v4がリリースされています。

https://zenn.dev/yusukebe/articles/b20025ebda310a

Hono v4ではSSGのサポートが導入されています。hono/jsxと併用することでJSXをテンプレートエンジンとして活用ができて便利そうです。このSSGサポートについてはDeno向けのアダプターも提供されていて、以下のようなファイルを用意してdeno runで実行することでstaticディレクトリにHTMLを出力できます。

build.ts
import { toSSG } from "hono/deno";
import { app } from "./app.tsx";

toSSG(app);

また、hono/jsx/domというモジュールが新しく追加されています。hono/jsxによって構築されたコンポーネントをブラウザーで動作させることができるようです。後述のHonoXでもデフォルトで採用されているようです。

HonoX

Hono v4に合わせて、HonoとViteをベースにしたHonoXというメタフレームワーク[2]が公開されています。ファイルシステムベースのルーティングやIslandアーキテクチャーのサポートなどが提供されており、もしかしたらDenoにおいてもFreshなどの競合となる可能性も出てくるかもしれません。また、Hono v4で導入されたhono/jsx/domも採用されているようです。HonoXについては作者のyusukebeさんによる詳細な記事が公開されているためそちらを参照いただければと思います。

https://zenn.dev/yusukebe/articles/724940fa3f2450

少し試してみたところ、DenoでHonoXを動かすのはまだ少し難しそうな印象ではありました。ただ、ViteとHonoがベースのため、DenoにおけるNode.js互換性の改善に伴い、将来的には動作するようになる可能性は高そうな気がしています。

Astro

前述したように、DenoにおけるNode.js互換性の改善によってDeno v1.35からAstroが動かせるようになったようです。

その他には、AstroのDenoアダプターがdenolandオーガニゼーションに移管されています。

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

今後は、以下のリポジトリで開発が進みそうです。

https://github.com/denoland/deno-astro-adapter

Lume

LumeはDenoで実装されたスタティックサイトジェネレーターです。おそらく、Deno製のスタティックサイトジェネレーターとしては最も活発に開発が行われています。また、Denoの公式ブログでも紹介されていたりします。

https://deno.com/blog/build-a-static-site-with-lume

LumeCMS

Lumeに関する大きな内容として、LumeCMSというOSSが公開されています。

https://lume.land/blog/posts/lume-cms/

Honoをベースに実装されていて、現時点ではLume向けにコンテンツのプレビューなどの機能が提供されているようです。

また、名前にLumeとついているもののアダプターを実装することで様々なフレームワークをサポートできるようにすることが意識されているようです。そのため、例えば、先程のHonoのSSG向けのアダプターを実装することなどももしかしたら可能かもしれません。

Lume v2

LumeCMS以外に関しては、Lumeのv2がリリースされています。

https://lume.land/blog/posts/lume-2/

大きな変更点としてはデフォルトのテンプレートエンジンがNunjucksからVentoへ移行されています。(Nunjucksについてはプラグイン経由で引き続き利用が可能です)

また、Windi CSSプラグインが削除されてUnoCSSへ移行されるなどの変更も行われています。

その他フレームワーク・ライブラリについて

Ree.js

Ree.jsというフレームワークが公開されています。

https://github.com/rovelstars/reejs

Ree.jsはBun/Deno/Node.jsで動作するようで、URLインポートのサポートやPackitという独自のビルドシステムを提供していることなどが特徴のようです。以下に解説記事が公開されています。

https://dev.to/renhiyama/welcome-to-the-dark-side-reejs-awaits-you-1e4p

vite-deno-plugin

vite-deno-pluginというViteをDenoから使いやすくするためのプラグインが公開されています。

https://github.com/anatoo/vite-deno-plugin

比較的新しいライブラリですが、これによりImport MapsやURLインポートなどの機能がViteで使えるようになるようです。

create-vite-extraではDeno向けのテンプレートが提供されているため、こちらもセットで使うと便利かもしれません。

https://github.com/bluwy/create-vite-extra

おわりに

直近で公開されたDenoのNode.js互換性に関するロードマップでは、SvelteKitやQwik, Remixなどの各種フレームワークに関する対応が挙げられており、Deno開発チームの内部でも比較的優先度が高く考えられているように見えました。

https://github.com/denoland/deno/issues/22337

DenoのNode.js互換性については現在も色々な改善が行われているため、今後も動かせるパッケージが少しずつ増えていきそうな気がします。

FreshについてはPartialsが入ったことにより柔軟性が大分向上したよう感じています。また、プラグインシステムが大きく拡張されています。これにより、他のライブラリなどとの連携や拡張がしやすくなりそうです。今後、HMRのサポートなどが入るとFreshはさらに使い勝手が良くなりそうな気がしています。

HonoはExpressやOakなどのようなマイクロフレームワークとしての機能も残しつつ、HonoXによりフルスタックフレームワークやメタフレームワーク[2:1]としての選択肢も広がりそうで、Denoに限らず、今後はさらに人気が増していきそうな気がしました。

LumeについてはおそらくDeno製では最も長期的・活発に開発が進んでおり、シンプルなスタティックサイトジェネレーターを使いたい場合は選択肢としてとてもよさそうに見えます。


脚注
  1. 開発時はdevサーバーが使用されるため、Islandなどを使用していなくてもJavaScriptが送信されます。 ↩︎ ↩︎

  2. https://prismic.io/blog/javascript-meta-frameworks-ecosystem ↩︎ ↩︎

Discussion