Denoのフロントエンド開発の動向【2023年夏】
半年程前に、以下のような記事を書きました。
この記事では、上記の記事から半年程の間で起きたDenoでのフロントエンド開発に関して影響がありそうな内容などをまとめていきます。
Deno本体のアップデート
node:
URLのサポート
Deno v1.30でNode.js組み込みパッケージのimport
がサポートされています。
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
後述するesm.shの?target=denonext
などと併用すると、効果を発揮しそうです。
注意点として、node:
を付与しなければNode.js組み込みパッケージは読み込むことができません。具体的には、以下のような読み込み方はサポートされていません。
import { EventEmitter } from "events"; // => エラー
package.json
のサポート
Deno v1.25でDeno本体にnpmパッケージのサポートが追加されました。
これにより、以下のようにnpm:<パッケージ名>
のような形式でimport
を記述することで、対象のパッケージをDenoから利用することができます。
import chalk from "npm:chalk@5.1.2";
chalk.yellow("foobar");
今年の2月にリリースされたDeno v1.31では、このnpmパッケージサポートをさらに拡張し、Deno本体にpackage.json
のサポートが追加されました。
これが具体的にどういうものなのかというと、Denoはpackage.json
があればそれを解析し、dependencies
/devDependencies
の内容を元にbare specifierを解釈してくれます。
例えば、以下のような内容でpackage.json
が存在したとします。
{
"dependencies": {
"chalk": "^5.2.0",
"koa": "2"
},
"devDependencies": {
"cowsay": "^1.5.0"
},
"scripts": {
"hello": "cowsay Hello"
}
}
この場合、Node.jsと同じようにimport
文にbare specifierを記述することができます。(Denoはpackage.json
を依存パッケージに関するマッピング情報が定義されたImport Mapsであるかのように解釈してくれます。)
import chalk from "chalk"; // => `npm:chalk@^5.2.0`と指定された場合と同様に振る舞います
import Koa from "koa"; // => `npm:koa@2`と指定された場合と同様に振る舞います
const app = new Koa();
app.use((ctx) => {
ctx.body = "Hello world";
});
app.listen(3000, () => {
console.log(chalk.blue.bold("Listening on port 3000"));
});
このスクリプトは通常通り、deno run
コマンドで実行できます。
$ deno run --allow-net --allow-read --allow-env main.js
package.json
で指定されたnpmパッケージはnpm:
URLで指定されたものと同様に、deno run
コマンドを実行すると、自動でダウンロードされてローカルにキャッシュされます。
npm:
URL経由でnpmパッケージを読み込んだ場合との違いとして、package.json
がある場合、--node-modules-dir
を指定しなくてもDenoは自動でnode_modules
ディレクトリを作成します。(node_modules
が不要な場合は、--node-modules-dir=false
を明示すると、この挙動を無効化できます。)
また、package.json
のscripts
で定義された内容はdeno task
コマンドで実行することも可能です。
$ deno task hello
Task hello cowsay Hello
_______
< Hello >
-------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
Viteなどのフロントエンド開発で使用されるnpmパッケージの中には、package.json
やnode_modules
がカレントディレクトリに存在することを前提として動作するパッケージがあります。
Deno本体でpackage.json
がサポートされることで、こういったパッケージとの互換性がより高まることが期待されます。
package.json
サポート導入への背景について
Deno本体にpackage.json
が導入された背景についてDenoの公式ブログで解説されています。
要約としては、以下の課題を解消することが期待されているようです。(それぞれの課題の詳細については補足としてまとめているため、もし興味ございましたらご覧いただければと思います。)
- 依存関係の重複問題
- 既存のリモートモジュール管理手法(
deps.ts
/Import Maps)における課題 - esm.shなどの既存のCDNにおける課題
また、package.json
をサポートすることで、既存のNode.jsプロジェクトを変更せずにそのままDenoで動かせるようにすることも期待されているようです。
注意点として、package.json
などのサポートが入ったとしても、既存のhttps:
URL経由でのパッケージのimport
などは引き続きサポートされ続けることが言及されています。
Deno v2について
現在、Denoではv2のリリースに向けて開発が進められています。
Deno v2で予定されている大きな計画として、依存関係の重複問題の解消などを目的に、新しくdeno:
URLの導入が検討されているようです。
これを活用することで、deno.land/xからライブラリを読み込む際にsemverの解決を柔軟に行うことができたり、より簡潔にimport
を記述できるようにすることなどが期待されているようです。
import $ from "deno:dax@24.0/mod.ts"; // => https://deno.land/x/dax@0.24.x/mod.ts
await $`echo foobar`;
v2に向けたロードマップは以下で公開されています。
補足
(補足) 依存関係の重複問題について
Denoでサードパーティライブラリを読み込みたい場合、import
をする際に対象ライブラリのURLを指定する必要があります。
import { join } from "https://deno.land/std@0.192.0/path/mod.ts";
これによる課題の一つとして、例えば、モジュールAとモジュールBの2つのサードパーティライブラリがあったとします。
これらのライブラリはそれぞれstd/path/mod.ts
のv0.192.0
とv0.191.0
に依存していたとします。
- モジュールA:
https://deno.land/std@0.192.0/path/mod.ts
- モジュールB:
https://deno.land/std@0.191.0/path/mod.ts
現状の仕組み上、std/path/mod.ts
のv0.192.0
とv0.191.0
はたとえコードがまったく同じであったとしても、両方のバージョンが読み込まれてしまう問題があります。
package.json
のsemver resolutionの仕組みにより、こういった依存関係の重複に関して部分的に解決を図ることが期待されているようです。
また、Denoで書かれたサードパーティライブラリについては、deno:<パッケージ名>
形式のURLをサポートすることで、この問題への解消が検討されているようです。
(補足) 既存のリモートモジュール管理手法(`deps.ts`/Import Maps)における課題について
Denoではサードパーティライブラリへの依存関係を管理する目的で、deps.ts
とImport Mapsという2つの手段がよく使用されます。
deps.ts
とは、以下のようにサードパーティライブラリをdeps.ts
というファイルで一元的に管理する手法です。
export { deferred } from "https://deno.land/std@0.187.0/async/deferred.ts";
export type { Deferred } from "https://deno.land/std@0.187.0/async/deferred.ts";
export { default as chalk } from "npm:chalk@5.1.2";
アプリケーションまたはライブラリからサードパーティライブラリを読み込む際は、このdeps.ts
経由で読み込みます。
import { chalk, deferred } from "./deps.ts";
package.json
と比較した場合、deps.ts
での依存管理はやや煩雑になりがちです。
もう一つのImport Mapsとは、以下のような形式のJSONファイルを用意しておくことで、その定義内容に基づいてDenoにbare specifierを解釈させることができる機能です。
{
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.1.6/",
"preact": "https://esm.sh/preact@10.13.1",
"preact/": "https://esm.sh/preact@10.13.1/",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.6",
"@preact/signals": "https://esm.sh/*@preact/signals@1.1.3",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.2.3",
"twind": "https://esm.sh/@twind/core@1.1.3",
"@twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.3",
"$gfm": "https://deno.land/x/gfm@0.2.3/mod.ts",
"awesome-lint": "npm:awesome-lint@0.18.2"
}
}
このようなファイルを用意しておくことで、以下のようにしてサードパーティライブラリを読み込むことができます。
// `import_map.json`で定義された`https://deno.land/x/gfm@0.2.3/mod.ts`が読み込まれます。
import { CSS, render as renderGFM } from "$gfm";
const url = new URL("../README.md", import.meta.url);
const markdown = await Deno.readTextFile(url);
const content = renderGFM(markdown, {});
await Deno.writeTextFile("index.html", content);
$ deno run -A --import-map=import_map.json script.ts
このImport Mapsの欠点としてプロセスごとに一つしか指定できないという制限があります。
そのため、Deno製のサードパーティライブラリを作成する際は、基本的にImport Mapsを利用することが難しくなります。
(補足) esm.shなどの既存のCDNにおける課題について
esm.shなどのCDNを活用すると、npm:<パッケージ>
を使わずにnpmパッケージを利用することができます。
import { cleanup, fireEvent, render } from "https://esm.sh/@testing-library/preact@3.2.3/pure?external=preact&pin=v126";
ただし、esm.shなどを使用したとしても、あらゆるnpmパッケージを動かすことは困難です。
例えば、npmパッケージのtarball内に特定のテキストファイルを含んでおり、動作させるためにそれを読み込むパッケージは動かすことが出来ません。
一例を挙げると、bullmqはコマンドを定義するために.lua
ファイルをパッケージ内に含んでおり、esm.sh経由だと動かすことが難しくなります。
fresh
freshはDeno公式のWebフレームワークです。
Preactをベースとしており、Island Architectureの採用やJust-in-timeレンダリングなどを提供することなどが大きな特徴です。
今後の開発について
PreactのメンテナーであるMarvin Hagemeister氏がDeno社に入社されたことが発表されました。
今後、Marvin Hagemeister氏を中心にフルタイムでfreshの開発が進められていくそうです。
Twind v1のサポート
今まで、freshではTwindのv0.16系のみをサポートしていました。($fresh/plugins/twind.ts)
fresh v1.1.4でTwind v1のサポートが追加されました。
公式のtwindv1プラグインを使うことで、Twind v1を利用できます。
セットアップ方法などについては、以下の記事などを参照いただければと思います。
npm:
)のサポート
npmパッケージ(fresh v1.2.0でnpmパッケージがサポートされました。
以下のように、Islandコンポーネントからnpmパッケージが利用できます。
import truncate from "npm:lodash.truncate@4.4.2";
interface Props {
text: string;
}
export default function Example({ text }: Props) {
return <span>{ truncate(text) }</span>;
}
(補足) Denoにnpmパッケージのサポートが入ったのに、どうしてfreshでは独自にnpmパッケージのサポートが必要なの?
freshにはIslandコンポーネントがあるためです。
Islandコンポーネントが実行されるのはDenoではなくブラウザであるため、npmパッケージに依存したIslandコンポーネントを作るには、フレームワーク側でのサポートが必要でした。
freshの内部で使用されているesbuild_deno_loaderにサポートが入ったことで、freshでもnpmパッケージが利用できるようになりました。
Islandコンポーネントに関する改善
Islandコンポーネントのprops
として、children
やUint8Array
, Preact Signalsなどを渡せるようになりました。
特にprops.children
のサポートは便利で、以下のようにIslandコンポーネントに通常のコンポーネントを混在させることなどもできます。
import Collapse from "../islands/Collapse.tsx";
import Content from "../components/Content.tsx";
export default function Index(props: PageProps) {
return (
<Collapse>
<Content />
</Collapse>
);
}
また、Preact Signalsもサポートされているため、同じシグナルを複数のIslandコンポーネントに渡すこともできるようになりました。
import type { PageProps } from "$fresh/server.ts";
import { useSignal } from "@preact/signals";
import Counter from "../islands/Counter.tsx";
import Double from "../islands/Double.tsx";
export default function Index(props: PageProps<string>) {
const count = useSignal(0);
return (
<>
<Counter count={count} />
<Double count={count} />
</>
);
}
その他にも、islands
ディレクトリにサブディレクトリを作れるようになりました。
今まではIslandコンポーネントはislands
ディレクトリの直下に配置する必要がありましたが、今後はより柔軟にコンポーネントを配置できそうです。(例: islands/sub_dir/Counter.tsx
)
renderAsync
フックがサポート
プラグインシステムでSSRの実行前後のタイミングで非同期処理を仕込めるよう、renderAsync
フックがサポートされました。
これによる恩恵として、UnoCSSなどの非同期で動作するCSSエンジンをプラグイン経由でサポートする余地が生まれました。
現在、このrenderAsync
フックを活用してUnoCSS向けのプラグインを追加するPRがfreshのリポジトリで作成されているため、近いタイミングで利用できるようになる可能性がありそうです。
(補足) freshのプラグインシステムについて
freshのライフサイクルにおける様々なタイミングに対してフックを提供することで、ユーザーがfreshを拡張できるようにするための仕組みです。
v1.2.0の時点では、SSRの実行前後のタイミングに対するフック(render
/renderAsync
フック)のみがサポートされています。
例えば、render
フックを活用することにより、SSRによって生成されたHTMLに基づいてTwindなどのCSSランタイムにスタイルシートを生成させることなどが出来ます。
SaaSKit
Deno公式からfreshやSupabase, Stripeなどを活用したSaaSプロジェクトのテンプレートが公開されています。
ソースコードやロードマップなどが以下のリポジトリで公開されています。
前述のtwindv1
プラグインや最近発表されたDeno KVなども活用されているため、興味がありましたら中身を見てみると面白いかもしれません。
今後の新機能について
今後、freshで開発が検討されているものなどについていくつか紹介いたします。
Fresh Devtools
まず、Fresh Devtoolsというものの開発が計画されているようです。
これはNuxt DevToolsのような開発者ツールをフレームワークから提供することで、開発やデバッグなどをサポートすることが目的のようです。
Islandコンポーネントの事前ビルド
その他には、Oakの作者であるKitson Kelly氏によって、Islandコンポーネントを事前ビルドできるようにすることが提案されています。
この提案にはfreshを開発したLuca Casonato氏も賛成されており、また活発に議論もされていることから、今後実装が行われる可能性があるかもしれません。
Preact Actions
現在、Marvin Hagemeister氏によって、actionブランチでPreact Actionsという機能の開発が進められているようです。
現時点での大雑把なイメージとして、まずactions/
ディレクトリ内でアクションを定義します。
import { action } from "$fresh/src/preact-actions/mod.ts";
export default action(
function helloAction(element: HTMLElement, count: number) {
// `use`が指定された要素のマウント時に呼ばれる
const selector = element.getAttribute("data-selector")!;
const message = document.querySelector(selector)!;
message.textContent = `count: ${count}, is_even: ${count % 2 === 0}`;
return {
update(newCount: number) {
// `use`が指定された要素の更新時に呼ばれる
message.textContent = `count: ${newCount}, is_even: ${newCount % 2 === 0}`;
},
destroy() {
// `use`が指定された要素の削除時に呼ばれる
message.textContent = " destroyed";
},
}
},
);
そして、props.use
にアクションの戻り値を渡します。
import exampleAction from "../actions/example.ts";
import { useSignal } from "@preact/signals";
export default function Counter() {
const count = useSignal(0);
return (
<div>
{
count.value < 10
? <div use={exampleAction(count.value)} data-selector="#message">{ count }</div>
: null
}
<div id="message" />
<button onClick={() => count.value += 1}>+</button>
</div>
);
}
このようにして、props.use
が指定された要素の各ライフサイクルにおいて様々な処理を実行できる仕組みのようです。
これは推測ですが、現状、Islandコンポーネントには関数をprops
として渡すことができません。
その制限を緩和する目的などでこの機能が実装されているのではないかと推測しています。
Aleph.js
Aleph.jsはDenoで実装されたNext.jsライクなフレームワークです。
以下にロードマップが公開されていますが、引き続き、v1のリリースに向けて開発が進められています。
今後は、v1に向けてReact Server Componentsのサポートなども検討されているようです。
Vue.js/Solid.jsの公式サポートについて
Aleph.jsでのVue.jsとSolid.jsの公式サポートが削除されています。
作者のije氏はAleph.jsのみならずesm.shなども開発されているため、Vue.js/Solid.jsのサポートを削除することで、よりReactのサポートに注力しやすくしていきたいのが背景のようです。
ただし、Aleph.jsの公式exampleにはLeptosやYewなどのフレームワークでの使用例があるように、Aleph.jsでは柔軟にフレームワークを切り替えられるようにすることが意識されています。
おそらく、後述のプラグインを用意すればVue.jsやSolidなども動かすことは可能なのではないかと思います。
プラグインシステム
Aleph.jsにプラグインシステムが実装されています。
このプラグインシステムによって、.mdx
などのファイル形式をサポートするようにAleph.jsを拡張したり、SSRの挙動をカスタマイズすることなどができるようです。
ReactなどのフレームワークやUnoCSSなどのサポートもこのプラグインシステムをベースに提供されています。
例えば、以下はUnoCSSサポートを有効化した状態でReactアプリを初期化した際の設定例です (examples/with-unocss/react-app/server.ts)
import { serve } from "aleph/server";
import denoDeploy from "aleph/plugins/deploy";
import react from "aleph/plugins/react";
import unocss from "aleph/plugins/unocss";
import config from "./unocss.config.ts";
import modules from "./routes/_export.ts";
serve({
plugins: [
denoDeploy({ modules }),
react({ ssr: true }),
unocss(config),
],
});
Aleph.jsのサーバを起動する際に、serve()
のplugins
オプションでプラグインを指定できるようです。
その他、公式ではAleph.jsをDeno Deployで動かすためのプラグインやMDXプラグインなどが提供されています。
Ultra v2
Ultraのv2がリリースされています。
Honoとの統合やTwind/react-query/react-routerなどの様々なエコシステムとの連携、Islandコンポーネントのサポートなど、様々な改善が実施されています。
Islandコンポーネントのサポート
セットアップについて
試してみたところ、セットアップに少しハマってしまったため、手順を記載いたします。
Islandコンポーネントを使用する際は、build.ts
をデフォルトの状態から以下のように変更する必要があります。
const builder = createBuilder({
- browserEntrypoint: import.meta.resolve("./client.tsx"),
serverEntrypoint: import.meta.resolve("./server.tsx"),
});
+builder.entrypoint("browser", {
+ path: "./src/app.tsx",
+ target: "browser",
+});
+
次にserver.tsx
をデフォルトの状態から次のように変更します。
@@ -8,20 +8,13 @@ import { stringify, tw } from "./src/twind/twind.ts";
const server = await createServer({
importMapPath: import.meta.resolve("./importMap.json"),
- browserEntrypoint: import.meta.resolve("./client.tsx"),
});
-function ServerApp({ context }: { context: Context }) {
- const requestUrl = new URL(context.req.url);
-
- return <App />;
-}
-
server.get("*", async (context) => {
/**
* Render the request
*/
- let result = await server.render(<ServerApp context={context} />);
+ let result = await server.render(<App />);
// Inject the style tag into the head of the streamed
response
const stylesInject = createHeadInsertionTransformStream(() => {
まずIslandとして扱いたいコンポーネントを用意します。基本的には通常のReactコンポーネントと変わりませんが、重要なのが.url
プロパティを指定している部分で、これがないとエラーが発生します。
import { useState } from "react";
import { tw } from "./twind/twind.ts";
interface Props {
count: number;
}
export default function Counter(props: Props) {
const [count, setCount] = useState(props.count);
return (
<div className={tw`flex flex-col`}>
<span>{count}</span>
<button onClick={() => setCount((count) => count + 1)}>+1</button>
<button onClick={() => setCount((count) => count - 1)}>-1</button>
</div>
);
}
Counter.url = import.meta.url;
そして、useIsland
でコンポーネントをラップすると、該当コンポーネントをIslandとして扱うことができます。
import useIsland from "ultra/hooks/use-island.js";
import Counter from "./Counter.tsx";
// ...
const CounterIsland = useIsland(Counter);
export default function App() {
console.log("Hello world!");
return (
// 省略...
<CounterIsland count={3} hydrationStrategy="idle" />
// 省略...
);
}
上記のようにIslandコンポーネントにはprops.hydrationStrategy
を指定できて、hydrationが実行されるタイミングを柔軟にカスタマイズできます。
hydrationStrategy |
挙動 |
---|---|
load |
即時でhydrationされます |
idle |
対象コンポーネントのhydrationがrequestIdleCallback を使用してスケジュールされます。 |
visible |
IntersectionObserver を使い、該当コンポーネントが画面上に表示されるまでhydrationが遅延されます。 |
Nuxt 3
大きな動きとして、Deno DeployやDenoでの実行がサポートされました。
Nuxt 3は内部でNitroというサーバーエンジンを使用しています。このNitroはNuxtアプリケーションを様々な環境で動作させられるよう、Presetという抽象化レイヤーを提供しています。
Nitro v2.5.0でdeno-deploy
とdeno-server
という2種類のPresetが追加されています。
このNitro v2.5.0はNuxt v3.6.0で取り込まれているため、すでに使用できる状態にありそうです。
例えば、以下のようにしてビルドすることで、DenoでNuxtアプリケーションを動かせるようです。
$ NITRO_PRESET=deno-server npm run build
$ deno task --config .output/deno.json start
esm.sh
esm.shはDenoなどからnpmパッケージを利用できるようにしてくれるCDNです。
ビルドAPI
esm.sh
に指定したコードのビルドを依頼するための実験的なAPIが実装されています。
build()
を使うと、柔軟に設定を行うことができます。
import { build } from "https://esm.sh/build?pin=v126";
const ret = await build({
dependencies: { "chalk": "5.2.0" },
code: `
import chalk from "chalk";
export const hello = () => chalk.blue("Hello World");
`,
// 型チェック用
types: `
export function hello(): string;
`,
});
const { hello } = await import(ret.bundleUrl);
console.log(hello());
denonext
ターゲット
esm.sh
ではnpmパッケージを様々な環境で動作させるために?target
オプションというオプションを提供しています。
この?target
オプションでdenonext
という新しいターゲットがサポートされています。(Deno v1.33.2以降のバージョンを使っている場合は自動で適用されるため、明示はしなくても問題ありません)
これを指定することで、あるパッケージをesm.sh
から利用する際に、そのパッケージに含まれるNode.js組み込みパッケージ(fs
, events
など)への読み込みを、強制的にnode:
URL経由で読み込むように置き換えてくれます。
// これが...
import { EventEmitter } from "events";
// 次のように置き換わります
import { EventEmitter } from "node:events";
これにより、Node.jsの組み込みパッケージに依存したnpmパッケージをより安定して動かしやすくなりそうです。
おわりに
ここ半年ほどでfreshの開発が大きく進んだように感じました。
freshのデプロイ先として利用できるDeno Deployに関しても、npm:
パッケージのサポートがまもなく発表される計画であるのと、Deno KVのような機能なども開発が進んでおり、実用性が少しずつ向上しつつあるように感じています。
また、Deno v2に向けた開発も現在進められている最中のため、個人的には非常に楽しみにしております。
Discussion