🌐

メタフレームワークのサーバコンテキストの扱い方と設計思想

2024/02/29に公開

きっかけ

Xを見ていたら以下のような議論を見かけました(確か別のディスカッションだった気がするけど、以下も類似の問題)。

https://github.com/vercel/next.js/pull/59909

現在のNext.js(App Router)では、layout.tsxpage.tsx、それ以外のコンポーネントで、取得できるサーバコンテキスト(Requestオブジェクトなどのアクセス情報)が異なります。CookieやHeaderは関数を通じて全てのサーバコンポーネントで利用できますが、パス周りをサーバコンポーネントで扱おうとすると結構不便を強いられます。上記のPRはこれを解決するためのものです。

import { cookies } from 'next/headers'
 
export default function Page() {
  const cookieStore = cookies()
  const theme = cookieStore.get('theme')
  return '...'
}
import { headers } from 'next/headers'
 
export default function Page() {
  const headersList = headers()
  const referer = headersList.get('referer')
 
  return <div>Referer: {referer}</div>
}

page.tsxではパス周りのコンテキストにアクセス可能であり、Propsとしてパスパラメータ(params)やクエリパラメータ(searchParams)にアクセスできます。

export default function Page({
  params,
  searchParams,
}: {
  params: { slug: string }
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  return <h1>My Page</h1>
}

https://nextjs.org/docs/app/api-reference/file-conventions/page

しかし、layout.tsxではPropsとしてアクセスできるのはchildrenのみであり、page.tsxでアクセスできたパス周りのデータを受け取ることはできません。ページ遷移しても上位のレイアウトは使い回されるため、サーバサイドのパスに依存する処理が初回の一度しか実行されないためでしょう。クライアントコンポーネントであれば、パスの変更をイベント経由で監視可能であり、上記のような問題は発生しません。遷移時に必ず再評価されるtemplate.tsxもありますが、こちらもサーバコンポーネントだとパス周りを触れません。

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section>{children}</section>
}

export default function Template({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}

https://nextjs.org/docs/app/api-reference/file-conventions/layout

layout.tsxと同様にサーバサイドコンポーネント自体もパス周りのコンテキストに触る事はできません。クライアントコンポーネントで実装するか、Props渡しを頑張る他ありません。

'use client'
 
import { useParams } from 'next/navigation'
 
export default function ExampleClientComponent() {
  const params = useParams<{ tag: string; item: string }>()
 
  // Route -> /shop/[tag]/[item]
  // URL -> /shop/shoes/nike-air-max-97
  // `params` -> { tag: 'shoes', item: 'nike-air-max-97' }
  console.log(params)
 
  return <></>
}

'use client'
 
import { useSearchParams } from 'next/navigation'
 
export default function SearchBar() {
  const searchParams = useSearchParams()
 
  const search = searchParams.get('search')
 
  // URL -> `/dashboard?search=my-project`
  // `search` -> 'my-project'
  return <>Search: {search}</>
}

https://nextjs.org/docs/app/building-your-application/rendering/server-components

Next.jsとしては、クライアント(ブラウザ)の状態に依存するパス周りは原則クライアントコンポーネントとして扱えという方針なんだと思います。useParamsuseSearchParamsがあるため、おそらくハイドレーションによる事前生成は可能だと思いますが、できれば純粋なサーバコンポーネントとして扱えるようになると嬉しいですね。

ここから本題

では他のメジャーなフレームワークではどうなっているのでしょうか?筆者の偏見と経験から以下を比較することにします。

  • Astro
  • Remix
  • Qwik City

Astro

基本的には非常にシンプルです。Astroコンポーネントであれば、どの階層にいてもAstroグローバルオブジェクト経由で生のRequestやパスパラメータ、クエリパラメータを取得できます。これはNext.jsなどのように特殊なルーティング(に見せかけた履歴操作)ではなく、古典的なMPAとして都度HTMLを生成する方針のため、クライアントサイドのパスの切り替えをケアする必要がないためでしょう。

Astroコンポーネントであれば、サーバサイドの処理もコンテキスト絡めて自由に記述できるため、コンポーネントというよりも小さな完結したHTMLを組み合わせる方が近いと思います。

<h1>The current URL is: {Astro.url}</h1>
<h1>The current URL pathname is: {Astro.url.pathname}</h1>
<h1>The current URL origin is: {Astro.url.origin}</h1>

一方で、Next.jsのようなクライアントサイドも巻き込んだサポートは基本ありません(後述の場合を除く)。クライアントサイドのUIライブラリは、Reactを始め様々なものが選択可能であり、それぞれをサポートする必要があることや、ブラウザの標準APIが存在するため、余計なレイヤーは取り除いていんだと思います(Qwikのサンプルではイベントによる状態の共有が提案されています)。

なお、クライアントディレクティブ次第で初回レンダリングの環境が変わるため、useEffect()のようなクライアントサイドで動作することが確実な処理を使った方が無難ですが、client:onlyであれば絶対にクライアントサイドで実行されるため、useEffectの外でブラウザのAPIを直接叩くことも可能です。

https://docs.astro.build/en/guides/typescript/#extending-window-and-globalthis

RemixとQwik City

このあたりはフレームワークの思想的にも利用側の実装的にも割と似ていそうな感じです。Next.jsと異なり非同期コンポーネントは存在しません。また、routes/配下(たまたま同じ)のページコンポーネントになにか特別な値が渡されることはありません。そのため、基本的にはNext.jsにおけるクライアントコンポーネントベースであり、Hook経由でパスやサーバサイドの値を取得する事ができます。

内部の実装は見ていませんが、おそらくサーバサイドではRequest、クライアントサイドではlocationオブジェクトをラップしてるんだと思います(Next.jsも同じ?)。

// Remix
import { useLocation } from "@remix-run/react";

function SomeComponent() {
  const location = useLocation();
  // ...
}

// Qwik
import { component$ } from '@builder.io/qwik';
import { useLocation } from '@builder.io/qwik-city';
 
export default component$(() => {
  const loc = useLocation();
  return <div>Hello {loc.params.username}!</div>;
});

サーバサイドの処理はLoader(BFF + Fetch + Tanstack Queryみたいなものを自動生成する機能)を経由して取得することが可能であり、そちらであればAstroと同じ用にRequestのようなコンテキストにフルアクセス可能です。Loaderを独立したHookとして定義するQwikと、基本的にはページに紐づかせるRemixで違いはありますが、使用感は似たものになると思います。

https://remix.run/docs/en/main/route/loader

https://qwik.dev/docs/route-loader/

これはNext.jsのPages RouterのgetServerSidePropsと同じようなものではないか?という意見もあると思いますが、前述したようにTanstackのようなMutation後の自動更新も含まれています(App RouterのrevalidatePathが自動で実行される感じ)。加えて、型定義なども非常に綺麗に統合されているため、扱いやすさはかなり改善されていると感じます。

まとめ

ここ1、2年ほどはサーバサイドに力を入れているメタフレームワークですが、調べてみると結構仕様が異なります。現時点では仕様の理解しやすさ、コードの書き易さ、パフォーマンスの観点で以下のように感じています。

扱いやすさ:Remix > Qwik > App Router > Pages Router
最適化:App Router => Qwik > Remix > Pages Router

以下のスライドで、App RouterはF1である、というのは言いえて妙だなと。カリカリにチューニングできれば最高のパフォーマンスを発揮できそうですが、個人的にはそこまでする必要のアプリケーションってほとんど無いと感じています。(落とし穴が多い印象、技術力不足を感じるところではありますが…)

https://www.docswell.com/s/ashphy/KM1NQ6-you-dont-need-nextjs#p9

慣れたフレームワークで!ツールチェーン重視で!というのも否定はしませんが、近年ではViteベースが主流になりつつある気がしますし、必ずしもApp Routerを使う必要はない気がします(Reactを使いたいだけならRemixでもいい)。このあたり、様々な要因を含めて技術選択していきたいですね。

Discussion