🧭

next/link の Prefetching と型安全

2021/12/08に公開

本記事は前回投稿「Next.js の API Routes から SWR の型推論を導く」の続編であり、事前にご覧いただいている前提で解説を一部簡略化しています。あらかじめご了承ください。動くサンプルは以下のブランチにアップしています。

https://github.com/takefumi-yoshii/nextjs-typesafe-api-route/tree/next

React Location の Prefetching と比較

routing ライブラリ React Location は、React Query と併用することで Routing / Prefetching / Caching が連携し、In-Memory の Client Cache を効率的に取得することができます。React Location ではライブラリが提供する<Link>コンポーネントを使用しますが、当然 Next.js のファイルシステム routing や<Link>コンポーネントの競合になるので、Next.js と相性は良くありません。

先日の Next.js 勉強会でもこのトピックを取り上げたように、SPA の Navigation をさらに速くするアプローチとして、Prefetching は一層着目されると予想されます。

https://speakerdeck.com/takefumiyoshii/nextjs-make-the-web-faster?slide=83

個人的には Vercel 製ライブラリの SWR に、この観点で今後改善があることを期待しています(Next.js に組み込まれてはいないため、しばらくは期待できないと思いますが…)現状では「React Location & React Query」が実現する Prefetching 相当の機能を実現するためには自前で用意しなければいけません。そこで、今回実験として <Link>コンポーネント拡張に挑戦してみました。

next/link の課題

本稿で解決していく next/link の課題は次のとおりです。

  • ファイルシステム routing となっており型安全ではない
    • pathpida など、サードパーティライブラリで補強する必要がある
  • Prefetch が ISR 向けで、User Specific なデータは事前取得できない(CSR 向けではない)
  • In-Memory の Client Cache 機構(SWR など)と連動していない
    • SWR などと連携し、手動で mutate を実行する必要がある

これら課題解決のため、できあがったものが以下の<Link>コンポーネントです。通常のnext/link挙動を残しつつ、props を拡張しました。SWR の Programmatically Prefetch を、next/linkの ISR Prefetch と同様に Intersection Observer・マウスオーバーで発火しています。

<Link
  path="/users/[id]" // リンク先 page の path文字列
  query={{ id: user.id }} // リンク先 page の PathPram/QueryParam
  swrPrefetch={{
    // 組み込みの prefetch と同タイミングで実行する SWR の Programmatically Prefetch (mutate)
    path: "/api/users/[id]", // API Routes に実装している API への path文字列
    query: { id: user.id }, // API Routes リクエストに与える PathPram/QueryParam
  }}
>
  {user.name}
</Link>

前回投稿と同様に、型安全を導くのは固定の path 文字列です。Pages path 文字列 / API Routes path 文字列を識別、引数として選択することで、各々対応する query を絞り込むことが可能になっています。

Pages 型を自動生成する

Next.js のルーティングは、モジュールシステム観点からは透過的参照がないため、TypeScript の型推論と相性が悪いです。この課題は前回投稿内容と同様に「実装ファイルに定義した型への参照」を繋ぐことで解決可能です。

npm run gen:apitypeで、型定義への参照型をsrc/types/pagesに自動生成します。生成された型定義は、module 宣言空間"@/types/pages"においてPages型定義の宣言結合が発生します。このPages型のプロパティ名称が、ファイルシステム routing の path 文字列相当になります。

// src/types/pages/users/[id].d.ts
declare module "@/types/pages" {
  interface Pages {
    "/users/[id]": Query & {
      [k in "id"]: string;
    };
  }
}
// src/types/pages/articles/[id]/index.d.ts
declare module "@/types/pages" {
  interface Pages {
    "/articles/[id]": {
      [k in "id"]: string;
    };
  }
}
// src/types/pages/articles/[id]/details/[detail].d.ts
declare module "@/types/pages" {
  interface Pages {
    "/articles/[id]/details/[detail]": {
      [k in "id" | "detail"]: string;
    };
  }
}

"/articles/[id]/details/[detail]"といった文字列をランタイムで asPath 相当に置換する処理を挟むため、"id"および"detail"の値が必要になります。ここで生成している MappedType は、その Query を要求するために出力されています。Optional Query が必要な場合、既定名称Queryで型定義を page から export。自動生成時に収集され Intersection Type の一部になります。

// src/pages/users/[id].tsx
export type Query = PageQuery<"from">;

Page 間遷移を型安全にする

参照を繋ぐ型ができたので、このプロジェクト内で使用する<Link>コンポーネントを拡張します。③ で行っている処理の挙動は、こちらのテストの通りです。以上で内部リンクにおける遷移が型安全になりました。

// src/components/Link.tsx
import type { Pages } from "@/types/pages";
import NextLink from "next/link";
import { mapPathParamFromQuery } from "@/utils/mapPathParamFromQuery";

// ① href 相当文字列は ③ で内部構築するため、props として受け付けない
type LinkBase = Omit<React.ComponentPropsWithoutRef<typeof NextLink>, "href">;

// ② 宣言結合された`Pages`型を Lookup に利用
export function Link<
  PagePath extends keyof Pages,
  PageQuery extends Pages[PagePath]
>({
  path,
  query,
  children,
  ...props
}: LinkBase & {
  path: PagePath;
  query?: PageQuery;
}) {
  // ③ `[id]`など PathParam 相当の文字列を内部で置換
  const href = mapPathParamFromQuery(path, query);
  return (
    <NextLink {...props} href={href}>
      <a>{children}</a>
    </NextLink>
  );
}

Programmatically Prefetch を加える

SWR で Prefetch を行う場合、アプローチがいくつかあります。
以下の前提条件で解説を進めていきます。

  • 公式ドキュメントで紹介されているとおり、グローバルミューテートを用います
  • SWR が接続する API は、API Routes に限定しています

prefetchApiData

過度な prefetch は API サーバーの負荷を高めます。そのため、必要最小限の prefetch に留めるよう工夫することが大事です。<Link>コンポーネントが画面に表示されたり・マウスオーバーする度にデータを取得していては、必要最小限の取得とはいえません。この課題に対応するため、今回独自に cache 済みのデータをいつ取得したのかを管理するモジュールを追加しました。

// src/utils/swr.ts
import type { GetReqBody, GetReqQuery, GetResBody } from "@/types/pages/api";
import { getApiData } from "@/utils/fetcher";
import { mapPathParamFromQuery } from "@/utils/mapPathParamFromQuery";
import { mutate } from "swr";

const defaultRevalidate = 24 * 60 * 60;
const prefetchTimestamp = new Map<string, number>();
export function clearPrefetchTimestamp() {
  prefetchTimestamp.clear();
}

// Api Routes の型を Lookup する
export function prefetchApiData<
  ApiPath extends keyof GetResBody,
  ReqQuery extends GetReqQuery[ApiPath],
  ResBody extends GetResBody[ApiPath],
  ReqBody extends GetReqBody[ApiPath]
>({
  path,
  revalidate,
  query,
  requestInit,
}: {
  path: ApiPath;
  revalidate?: number;
  query?: ReqQuery;
  requestInit?: Omit<RequestInit, "body"> & { body?: ReqBody };
}): Promise<ResBody | void> {
  // revalidate 秒数の指定を受け付ける (デフォルトは 1日)
  const r = revalidate ?? defaultRevalidate;
  if (r < 1) throw new Error("invalid revalidate value.");
  // revalidate 秒数が過ぎていた場合、Prefetch を再試行する
  const url = mapPathParamFromQuery(path, query);
  const now = Date.now();
  const timestamp = prefetchTimestamp.get(url);
  const shouldPrefetch = !timestamp ? true : timestamp - (now - r * 1000) < 0;
  if (!shouldPrefetch) return Promise.resolve();
  prefetchTimestamp.set(url, now);
  // Programmatically Prefetch を実行する
  return mutate(url, () => getApiData(path, { query, requestInit }), false);
}

useApiPrefetch

<Link>コンポーネント本来の挙動と同様に、Intersection Observer・マウスオーバーを契機に先の prefetchApiData を実行する hooks を作ります。next/link では useIntersection という custom hooks を内部で利用していますが、アプリケーションコードで利用する想定のものではないはずなので、今回は react-intersection-observer というライブラリで実装しました。

// src/utils/swr.ts
import type { Pages } from "@/types/pages";
import type { GetReqBody, GetReqQuery, GetResBody } from "@/types/pages/api";
import { prefetchApiData } from "@/utils/swr";
import { useRouter } from "next/dist/client/router";
import React from "react";
import { useInView } from "react-intersection-observer";

export function useApiPrefetch<
  PagePath extends keyof Pages,
  ApiPath extends keyof GetResBody,
  ReqQuery extends GetReqQuery[ApiPath],
  ReqBody extends GetReqBody[ApiPath]
>({
  ignoreRoute,
  ...options
}: {
  path: ApiPath;
  revalidate?: number;
  query?: ReqQuery;
  requestInit?: Omit<RequestInit, "body"> & { body?: ReqBody };
  ignoreRoute?: PagePath;
}) {
  const { pathname } = useRouter();
  const { ref, inView } = useInView({
    rootMargin: "200px",
  });
  const prefetch = React.useCallback(
    () => prefetchApiData(options),
    // eslint-disable-next-line
    []
  );
  React.useEffect(() => {
    if (!inView || ignoreRoute === pathname) return;
    prefetchApiData(options);
    // eslint-disable-next-line
  }, [inView, pathname]);
  return [prefetch, ref] as const;
}

完成

最後に独自 Link コンポーネントで useApiPrefetch を使用する分岐を加えれば完成です。main ブランチと比較すると、キャッシュが保持される前の...loading表示のチラつきが減っていることが確認できます。

function Prefetch<
  PagePath extends keyof Pages,
  ApiPath extends keyof GetResBody,
  ReqQuery extends GetReqQuery[ApiPath],
  ReqBody extends GetReqBody[ApiPath]
>({
  children,
  ...options
}: Prefetch<ApiPath, ReqQuery, ReqBody, PagePath> & {
  children: React.ReactNode;
}) {
  const [prefetch, setIntersectionRef] = useApiPrefetch(options);
  return (
    <span onMouseEnter={prefetch} ref={setIntersectionRef}>
      {children}
    </span>
  );
}

export function Link<
  PagePath extends keyof Pages,
  PageQuery extends Pages[PagePath],
  ApiPath extends keyof GetResBody,
  ReqQuery extends GetReqQuery[ApiPath],
  ReqBody extends GetReqBody[ApiPath]
>({
  path,
  query,
  swrPrefetch,
  children,
  ...props
}: LinkBase & {
  path: PagePath;
  query?: PageQuery;
  swrPrefetch?: Prefetch<ApiPath, ReqQuery, ReqBody, PagePath>;
}) {
  const href = mapPathParamFromQuery(path, query);
  if (!swrPrefetch) {
    return (
      <NextLink {...props} href={href}>
        <a>{children}</a>
      </NextLink>
    );
  }
  return (
    <NextLink {...props} href={href}>
      <a>
        <Prefetch {...swrPrefetch}>{children}</Prefetch>
      </a>
    </NextLink>
  );
}

SWR を使ったサンプルでしたが、出力された型定義を使えば、React Query でも同じ様に実施できるはずです。Routing / Prefetching / Caching / TypeSafe は、今後も不可分な関係になるのではないかと考えています。

Discussion