fresh v1.4がリリースされました。

この記事では主な変更点などについて解説します。

事前ビルドのサポート

Islandコンポーネントなどの事前ビルドがサポートされました。

deno task buildを実行すると、_freshディレクトリにIslandコンポーネントなどをesbuildによってバンドルした結果が出力されます。(この_freshディレクトリは.gitignoreに含めることが推奨されます。)

# 1. ビルドを実行
$ deno task build

# 2. _freshディレクトリが作成されます
$ cat _fresh/snapshot.json

サーバの起動時にfreshは自動で_freshディレクトリを探索し、見つかればそこに格納されたバンドルを利用してくれます。これによりコールドスタート時間の短縮が期待されます。

$ deno run -A main.ts
Using snapshot found at /path/to/fresh-project/_fresh

この事前ビルド機能はオプトイン方式によるものであり、従来どおり、ビルドステップなしでの開発やデプロイは引き続きサポートされています。ローカルでは従来どおりの方法で開発をし、本番環境にデプロイするときだけ事前ビルドを行うことも可能です。

移行について (fresh.config.ts)

※fresh v1.4以降、新規に作成したプロジェクトにおいてはこの作業は不要です。

この修正の影響により、dev.tsで使用することが想定されているdev()に変更が入っており、移行が必要になる場合がありそうです。

具体的には、まずfresh.config.tsを用意します。

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

import twindv1 from "$fresh/plugins/twindv1.ts";
import twindConfig from "./twind.config.ts";

export default defineConfig({
  plugins: [twindv1(twindConfig)]
});

次に、main.tsfresh.config.tsからfreshに関する設定を読み込むように変更します。

import { start } from "$fresh/server.ts";
 import manifest from "./fresh.gen.ts";
+import config from "./fresh.config.ts";
 
-import twindv1 from "$fresh/plugins/twindv1.ts";
-import twindConfig from "./twind.config.ts";
-
-await start(manifest, { plugins: [twindv1(twindConfig)] });
+await start(manifest, config);

同様に、dev.tsに関してもfresh.config.tsから設定を読み込み、dev()関数に渡すように修正します。

import dev from "$fresh/dev.ts";
+import config from "./fresh.config.ts";
 
-await dev(import.meta.url, "./main.ts");
+await dev(import.meta.url, "./main.ts", config);

レイアウト

freshではroutes/_app.tsxを用意することで、各Routeに共通のレイアウトを定義することができました。

しかし、このファイルはアプリケーションごとに一つしか用意できません。

この課題を解消するため、レイアウトという機能が実装されました。この機能を活用することで、routes/_app.tsxと同様に、特定のRouteに対して共通のレイアウトを定義することができます。

レイアウトを使いたい際は、_layout.tsxという名前のファイルをroutes配下の任意のディレクトリに配置します。これにより、対象ディレクトリ及びその子孫のディレクトリの各Routeがレンダリングされる際に、対象のレイアウトが自動で適用されます。

// routes/admin/_layout.tsx
import type { LayoutProps } from "$fresh/server.ts";

export default function AdminLayout({ Component }: LayoutProps) {
  return (
    <section>
      <h2>Admin</h2>
      <div>
        <Component />
      </div>
    </section>
  );
}

また、レイアウトは入れ子にすることも可能です。

例えば以下のようなディレクトリ構造があったとします。

routes
├── _app.tsx
├── _layout.tsx
├── admin
│   ├── _layout.tsx
│   └── index.tsx
└── index.tsx

この場合、routes/admin/index.tsxには以下のレイアウトが適用されます。

  • (1) routes/_app.tsx
  • (2) routes/_layout.tsx
  • (3) routes/admin/_layout.tsx

また、レイアウトには後述するdefineヘルパーが提供されているため、こちらを使用して定義することも可能です。

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

export default defineLayout((_req, { Component }) => {
  return (
    <section>
      <h2>Dashboard</h2>
      <main>
        <Component />
      </main>
    </section>
  );
});

このレイアウト機能のサポートに合わせて、RouteConfigskipAppWrapper/skipInheritedLayoutsオプションが追加されています。

これらのオプションによって、特定のRouteにおいてレイアウトに関する挙動を変更することができます。

オプション説明デフォルト
skipAppWrappertrueを指定すると、対象Routeに対するroutes/_appの適用が無効化されますfalse
skipInheritedLayoutstrueを指定すると、祖先のディレクトリからのレイアウトの継承が無効化されますfalse

また、レイアウトはv1.3で実装された非同期Routeコンポーネントとして実装することも可能です。この場合、defineLayoutヘルパーを使用すると便利です。

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

export default defineLayout<AppState>(async (_req, { Component, params, state }) => {
  const user = await ctx.state.db.findUser(params.id);
  return (
    <section>
      <h2>Hi {user.name}!</h2>
      <main>
        <Component />
      </main>
    </section>
  );
});

app wrapper

html, head, metaなどのタグのサポート

routes/_app.tsxからhtml, head, metaなどのタグをレンダリングできるようになりました。

これにより、htmllang属性の設定などが容易になりそうです。

// routes/_app.tsx
import { AppProps } from "$fresh/server.ts";
import Header from "../components/Header.tsx";
import Footer from "../components/Footer.tsx";

export default function App({ Component }: AppProps) {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Sample Fresh Project</title>
      </head>
      <body>
        <Header />
        <main>
          <Component />
        </main>
        <Footer />
      </body>
    </html>
  );
}

非同期Routeコンポーネント形式のサポート

routes/_app.tsxv1.3でサポートされた非同期Routeコンポーネントとして定義できるよう改善が行われています。この場合、defineAppを使用すると便利です。

// routes/_app.tsx
import { defineApp } from "$fresh/server.ts";

export default defineApp(async (_req, { Component }) => {
  const title = await getTitle();
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
      </head>
      <body>
        <Component />
      </body>
    </html>
  );
});

define*ヘルパー

freshの各種構成要素の定義を簡易化するためのヘルパーが追加されました。

すでに登場しているdefineConfigdefineLayout, defineAppに加えて、defineRouteというヘルパーが追加されています。

// routes/blog/[id].tsx
import { defineRoute } from "$fresh/server.ts";

export default defineRoute(async (req, ctx) => {
  const content = await readContent(ctx.params.id);

  return (
    <div>{content}</div>
  );
});

Route Groups

freshでNext.jsライクなRoute Groups機能がサポートされました。

Route Groupを定義したい場合、routes/ディレクトリに(<name>)というような形式のディレクトリを作成します。

このような形式で命名されたディレクトリについては、freshによって特別な扱いがなされます。 例えば、以下のような構成のプロジェクトが存在したとします。

routes
├── (_islands)
│   └── Counter.tsx
├── (dashboard)
│   ├── _layout.tsx
│   ├── _middleware.ts
│   └── account.tsx
├── _app.tsx
├── _layout.tsx
└── index.tsx

この場合、/accountにアクセスすることでroutes/(dashboard)/account.tsxがレンダリングされます。また、Route Groupごとにレイアウトやミドルウェアを配置することができます。

例外として、(_foo)というような形式で命名されたディレクトリはfreshによってファイルシステムルーティングの対象から除外されます。 そのため、例えば、(_components)ディレクトリを用意し、そこに特定のRouteに関連したコンポーネントなどの一覧をまとめておくことなどが可能です。

routes/(dashboard)
├── (_components)
│   └── Chart.tsx
├── (_utils)
│   └── index.tsx
├── _layout.tsx
└── account.tsx
└── index.tsx

その他にも、特殊なルールとして、freshは(_islands)という名前のディレクトリに配置されたコンポーネントをIslandコンポーネントとして認識します。

その他の改善について

サーバでHTTPSがサポート

start()関数にTLS関連のオプションを指定することで有効化できます。

プラグイン経由でミドルウェアを注入する際の挙動の改善

複数のプラグインから同一Routeに適用されるミドルウェアが複数存在する場合に、それらが適切にマージされるように挙動が改善されました。

文字列形式のイベントハンドラのサポート

文字列形式でイベントハンドラを設定できるようになりました。(型エラーが発生するため@ts-expect-errorの指定などが必要です)

  <button
    type="button"
    // @ts-expect-error
    onClick="alert('foobar')">
    Hello
  </button>

参考