🦕

Denoのフロントエンド開発の動向【2023年夏】

2023/07/03に公開

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

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

この記事では、上記の記事から半年程の間で起きた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が存在したとします。

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であるかのように解釈してくれます。)

main.js
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.jsonscriptsで定義された内容はdeno taskコマンドで実行することも可能です。

$ deno task hello
Task hello cowsay Hello
 _______
< Hello >
 -------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Viteなどのフロントエンド開発で使用されるnpmパッケージの中には、package.jsonnode_modulesがカレントディレクトリに存在することを前提として動作するパッケージがあります。

Deno本体でpackage.jsonがサポートされることで、こういったパッケージとの互換性がより高まることが期待されます。

package.jsonサポート導入への背景について

Deno本体にpackage.jsonが導入された背景についてDenoの公式ブログで解説されています。

https://deno.com/blog/package-json-support

要約としては、以下の課題を解消することが期待されているようです。(それぞれの課題の詳細については補足としてまとめているため、もし興味ございましたらご覧いただければと思います。)

  • 依存関係の重複問題
  • 既存のリモートモジュール管理手法(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に向けたロードマップは以下で公開されています。

https://github.com/denoland/deno/issues/17475
https://github.com/denoland/deno/milestone/26

補足

(補足) 依存関係の重複問題について

Denoでサードパーティライブラリを読み込みたい場合、importをする際に対象ライブラリのURLを指定する必要があります。

import { join } from "https://deno.land/std@0.192.0/path/mod.ts";

これによる課題の一つとして、例えば、モジュールAとモジュールBの2つのサードパーティライブラリがあったとします。

これらのライブラリはそれぞれstd/path/mod.tsv0.192.0v0.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.tsv0.192.0v0.191.0はたとえコードがまったく同じであったとしても、両方のバージョンが読み込まれてしまう問題があります。

package.jsonのsemver resolutionの仕組みにより、こういった依存関係の重複に関して部分的に解決を図ることが期待されているようです。

また、Denoで書かれたサードパーティライブラリについては、deno:<パッケージ名>形式のURLをサポートすることで、この問題への解消が検討されているようです。

(補足) 既存のリモートモジュール管理手法(`deps.ts`/Import Maps)における課題について

Denoではサードパーティライブラリへの依存関係を管理する目的で、deps.tsとImport Mapsという2つの手段がよく使用されます。

deps.tsとは、以下のようにサードパーティライブラリを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経由で読み込みます。

main.ts
import { chalk, deferred } from "./deps.ts";

package.jsonと比較した場合、deps.tsでの依存管理はやや煩雑になりがちです。

もう一つのImport Mapsとは、以下のような形式のJSONファイルを用意しておくことで、その定義内容に基づいてDenoにbare specifierを解釈させることができる機能です。

import_map.json
{
  "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"
  }
}

このようなファイルを用意しておくことで、以下のようにしてサードパーティライブラリを読み込むことができます。

script.ts
// `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社に入社されたことが発表されました。

https://deno.com/blog/fresh-1.2

今後、Marvin Hagemeister氏を中心にフルタイムでfreshの開発が進められていくそうです。

Twind v1のサポート

今まで、freshではTwindのv0.16系のみをサポートしていました。($fresh/plugins/twind.ts)

fresh v1.1.4でTwind v1のサポートが追加されました。

公式のtwindv1プラグインを使うことで、Twind v1を利用できます。

セットアップ方法などについては、以下の記事などを参照いただければと思います。

https://qiita.com/access3151fq/items/71dcc07978b7e61fe263

https://scrapbox.io/uki00a/FreshにTwind_v1サポートが入りました

npmパッケージ(npm:)のサポート

fresh v1.2.0でnpmパッケージがサポートされました。

以下のように、Islandコンポーネントからnpmパッケージが利用できます。

islands/Example.tsx
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として、childrenUint8Array, Preact Signalsなどを渡せるようになりました。

特にprops.childrenのサポートは便利で、以下のようにIslandコンポーネントに通常のコンポーネントを混在させることなどもできます。

routes/index.tsx
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コンポーネントに渡すこともできるようになりました。

routes/index.tsx
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のリポジトリで作成されているため、近いタイミングで利用できるようになる可能性がありそうです。

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

(補足) freshのプラグインシステムについて

freshのライフサイクルにおける様々なタイミングに対してフックを提供することで、ユーザーがfreshを拡張できるようにするための仕組みです。

v1.2.0の時点では、SSRの実行前後のタイミングに対するフック(render/renderAsyncフック)のみがサポートされています。

例えば、renderフックを活用することにより、SSRによって生成されたHTMLに基づいてTwindなどのCSSランタイムにスタイルシートを生成させることなどが出来ます。

SaaSKit

Deno公式からfreshSupabase, Stripeなどを活用したSaaSプロジェクトのテンプレートが公開されています。

https://deno.com/blog/announcing-deno-saaskit

ソースコードやロードマップなどが以下のリポジトリで公開されています。

前述のtwindv1プラグインや最近発表されたDeno KVなども活用されているため、興味がありましたら中身を見てみると面白いかもしれません。

今後の新機能について

今後、freshで開発が検討されているものなどについていくつか紹介いたします。

Fresh Devtools

まず、Fresh Devtoolsというものの開発が計画されているようです。

https://github.com/denoland/fresh/issues/1321

これはNuxt DevToolsのような開発者ツールをフレームワークから提供することで、開発やデバッグなどをサポートすることが目的のようです。

Islandコンポーネントの事前ビルド

その他には、Oakの作者であるKitson Kelly氏によって、Islandコンポーネントを事前ビルドできるようにすることが提案されています。

https://github.com/denoland/fresh/issues/1062

この提案にはfreshを開発したLuca Casonato氏も賛成されており、また活発に議論もされていることから、今後実装が行われる可能性があるかもしれません。

Preact Actions

現在、Marvin Hagemeister氏によって、actionブランチでPreact Actionsという機能の開発が進められているようです。

https://github.com/denoland/fresh/commit/058d0f6daee7167579f0b2e901346bea0e939bf9

現時点での大雑把なイメージとして、まずactions/ディレクトリ内でアクションを定義します。

actions/example.ts
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にアクションの戻り値を渡します。

islands/Counter.tsx
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のリリースに向けて開発が進められています。

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

今後は、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にはLeptosYewなどのフレームワークでの使用例があるように、Aleph.jsでは柔軟にフレームワークを切り替えられるようにすることが意識されています。

おそらく、後述のプラグインを用意すればVue.jsやSolidなども動かすことは可能なのではないかと思います。


https://github.com/alephjs/aleph.js/compare/1.0.0-beta.42...1.0.0-beta.43

プラグインシステム

Aleph.jsにプラグインシステムが実装されています。

このプラグインシステムによって、.mdxなどのファイル形式をサポートするようにAleph.jsを拡張したり、SSRの挙動をカスタマイズすることなどができるようです。

ReactなどのフレームワークやUnoCSSなどのサポートもこのプラグインシステムをベースに提供されています。

例えば、以下はUnoCSSサポートを有効化した状態でReactアプリを初期化した際の設定例です (examples/with-unocss/react-app/server.ts)

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がリリースされています。

https://github.com/exhibitionist-digital/ultra/releases/tag/v2.0.0

Honoとの統合やTwind/react-query/react-routerなどの様々なエコシステムとの連携、Islandコンポーネントのサポートなど、様々な改善が実施されています。

Islandコンポーネントのサポート

セットアップについて

試してみたところ、セットアップに少しハマってしまったため、手順を記載いたします。

Islandコンポーネントを使用する際は、build.tsをデフォルトの状態から以下のように変更する必要があります。

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をデフォルトの状態から次のように変更します。

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プロパティを指定している部分で、これがないとエラーが発生します。

src/Counter.tsx
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-deploydeno-serverという2種類のPresetが追加されています。

https://github.com/unjs/nitro/releases/tag/v2.5.0

このNitro v2.5.0はNuxt v3.6.0で取り込まれているため、すでに使用できる状態にありそうです。

https://github.com/nuxt/nuxt/releases/tag/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が実装されています。

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

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