Denoのフロントエンド開発の動向【2024年冬】
半年程前に、以下のような記事を書きました。
この記事では、上記の記事から半年程の間で起きたDenoでのフロントエンド開発に関して影響がありそうな内容などをまとめていきます。
Deno本体のアップデート
直近半年ほどでDenoに導入された機能からフロントエンド開発に影響しそうなものについていくつか紹介します。
npmパッケージの対応状況について
大きな点として、Deno v1.35からAstroが動作するようになったようです。
それ以外にはDenoでNext.jsを動かすための試みがいくつか進んでいるようです。next build
やnext dev
を動かすための対応がいくつか入っています。
また、直近で公開されたロードマップではSvelteKit, VuePress, Qwik, Remixなどを動かすための対応を進めていくことが検討されているようです。
Remixについては公式でRemix製アプリをDenoで動かすための@remix-run/denoが提供されています。Viteサポートが安定化されたこともあり、今後、Denoで開発も含めて行えるようになる可能性がより高くなりそうな気がしています。
Node.js製プロジェクトのサポートの強化
ここ半年程で、Node.jsで書かれたプロジェクトをそのままDenoで動かすまたは移行をサポートするための機能がいくつか導入されています。
その一つとしてBYONM (Bring your own node_modules)という機能がDeno v1.38で導入されました。Denoには独自にnode_modules
やロックファイル(deno.lock
)などを管理する仕組みが存在します。しかし、既存のNode.jsプロジェクトではこれらの仕組みをスムーズに利用することは難しい可能性もあります。また、npmのpreinstall
やpostinstall
スクリプトなどに依存したパッケージを利用したい場合、Denoではこれらの機能はまだサポートされていないため、利用することが難しいです。
BYONMはこういった課題などを解消することを目的に導入されました。BYONMはnpm
やpnpm
などのパッケージマネージャーによって作成されたnode_modules
ディレクトリから直接パッケージを読み込むことができる機能です。パッケージ管理は既存のパッケージマネージャーに任せて、Denoからはそれらでインストールされたパッケージをそのまま利用するようなイメージです。
{
"unstable": [
// `--unstable-byonm`オプションでも有効化可能です
"byonm"
]
}
このBYONMを利用したい場合は、現在は上記のようにdeno.json
などで明示的に有効化する必要があります。ただし、将来的にはpackage.json
が検出された際は自動的にBYONMを有効化することも検討されているようで、今後、利用方法などが変わる可能性もありそうです。
このBYONM以外にもNode.jsプロジェクトをDenoで動かすまたは移行をサポートするための機能がいくつか導入されています。もし既存のNode.jsプロジェクトをDenoで動かしてみたい場合は、BYONMとセットで有効化してみるとよいかもしれません。
{
"unstable": [
"byonm", // BYONMを有効化します
"bare-node-builtins", // `node:`なしでNode.js組み込みモジュールを読み込めるようにします
"sloppy-imports" // index.jsの自動探索など、Node.jsライクなモジュールの読み込みを有効化します
]
}
precompiled JSX transform
compilerOptions.jsx
で指定できるDeno独自の設定としてprecompile
がサポートされました。
{
"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ノードをあらかじめ静的な文字列を含む配列に変換しておくことでオブジェクトの割り当てを減らします。
現状、この機能はPreactとhono/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に影響を受けて導入されたようです。
利用方法
Partialsを有効化するためには、まずクライアントサイドナビゲーションを有効化したいリンクやフォームなどを含むコンテナ要素に対してf-client-nav
属性を指定する必要があります。
例えば、以下の例では<nav>
要素にf-client-nav
属性を指定しています。この<nav>
要素の配下の<a>
がクリックされると、ページ全体が再読込みされず、クライアントサイドのみでナビゲーションが行われます。
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
を指定する必要があり、これはページ内で一意である必要があります。
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のみを返すことでオーバーヘッドの削減が期待されます。
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のサポートを有効化できます。
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コンポーネントでレンダリングしたい場合、以下のようにハンドラーとページコンポーネントをそれぞれ記述する必要がありました。
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コンポーネントとはRequest
とRouteContext
を引数として受け取り、vnodeまたはResponse
を非同期で返却する関数です。非同期Routeコンポーネントを定義したい場合はdefineRouteを使うと便利です。
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コンポーネントを利用した形式は引き続きサポートされており、従来の形式にもハンドラーを個別にテストしやすいなどのメリットもあります。好みや状況などに応じて使い分けるとよさそうです。
その他新機能
上記で紹介した機能以外にも便利そうな機能がたくさん導入されています。
- レイアウト(
_layout.tsx
)のサポート - プラグインからのMiddleware/Handlerの注入がサポート
- プラグインからのIslandコンポーネントの提供がサポート
- Route Groups - Next.jsにおけるRoute Groupsと同様の機能です。
プラグインでのMiddlewareやIslandコンポーネントなどのサポートを提供できるようになったことに伴い、これらの機能を活用したライブラリなどもいくつか作成されています。
今後について
Deno公式から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がリリースされています。
Hono v4ではSSGのサポートが導入されています。hono/jsx
と併用することでJSXをテンプレートエンジンとして活用ができて便利そうです。このSSGサポートについてはDeno向けのアダプターも提供されていて、以下のようなファイルを用意してdeno run
で実行することでstatic
ディレクトリにHTMLを出力できます。
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さんによる詳細な記事が公開されているためそちらを参照いただければと思います。
少し試してみたところ、DenoでHonoXを動かすのはまだ少し難しそうな印象ではありました。ただ、ViteとHonoがベースのため、DenoにおけるNode.js互換性の改善に伴い、将来的には動作するようになる可能性は高そうな気がしています。
Astro
前述したように、DenoにおけるNode.js互換性の改善によってDeno v1.35からAstroが動かせるようになったようです。
その他には、AstroのDenoアダプターがdenoland
オーガニゼーションに移管されています。
今後は、以下のリポジトリで開発が進みそうです。
Lume
LumeはDenoで実装されたスタティックサイトジェネレーターです。おそらく、Deno製のスタティックサイトジェネレーターとしては最も活発に開発が行われています。また、Denoの公式ブログでも紹介されていたりします。
LumeCMS
Lumeに関する大きな内容として、LumeCMSというOSSが公開されています。
Honoをベースに実装されていて、現時点ではLume向けにコンテンツのプレビューなどの機能が提供されているようです。
また、名前にLumeとついているもののアダプターを実装することで様々なフレームワークをサポートできるようにすることが意識されているようです。そのため、例えば、先程のHonoのSSG向けのアダプターを実装することなどももしかしたら可能かもしれません。
Lume v2
LumeCMS以外に関しては、Lumeのv2がリリースされています。
大きな変更点としてはデフォルトのテンプレートエンジンがNunjucksからVentoへ移行されています。(Nunjucksについてはプラグイン経由で引き続き利用が可能です)
また、Windi CSSプラグインが削除されてUnoCSSへ移行されるなどの変更も行われています。
その他フレームワーク・ライブラリについて
Ree.js
Ree.jsというフレームワークが公開されています。
Ree.jsはBun/Deno/Node.jsで動作するようで、URLインポートのサポートやPackitという独自のビルドシステムを提供していることなどが特徴のようです。以下に解説記事が公開されています。
vite-deno-plugin
vite-deno-pluginというViteをDenoから使いやすくするためのプラグインが公開されています。
比較的新しいライブラリですが、これによりImport MapsやURLインポートなどの機能がViteで使えるようになるようです。
create-vite-extra
ではDeno向けのテンプレートが提供されているため、こちらもセットで使うと便利かもしれません。
おわりに
直近で公開されたDenoのNode.js互換性に関するロードマップでは、SvelteKitやQwik, Remixなどの各種フレームワークに関する対応が挙げられており、Deno開発チームの内部でも比較的優先度が高く考えられているように見えました。
DenoのNode.js互換性については現在も色々な改善が行われているため、今後も動かせるパッケージが少しずつ増えていきそうな気がします。
FreshについてはPartialsが入ったことにより柔軟性が大分向上したよう感じています。また、プラグインシステムが大きく拡張されています。これにより、他のライブラリなどとの連携や拡張がしやすくなりそうです。今後、HMRのサポートなどが入るとFreshはさらに使い勝手が良くなりそうな気がしています。
HonoはExpressやOakなどのようなマイクロフレームワークとしての機能も残しつつ、HonoXによりフルスタックフレームワークやメタフレームワーク[2:1]としての選択肢も広がりそうで、Denoに限らず、今後はさらに人気が増していきそうな気がしました。
LumeについてはおそらくDeno製では最も長期的・活発に開発が進んでおり、シンプルなスタティックサイトジェネレーターを使いたい場合は選択肢としてとてもよさそうに見えます。
-
開発時はdevサーバーが使用されるため、Islandなどを使用していなくてもJavaScriptが送信されます。 ↩︎ ↩︎
-
https://prismic.io/blog/javascript-meta-frameworks-ecosystem ↩︎ ↩︎
Discussion