paint-brush
独自のタイプセーフな React Router を 500 行で書く方法@olegwock
549 測定値
549 測定値

独自のタイプセーフな React Router を 500 行で書く方法

OlegWock39m2024/03/11
Read on Terminal Reader

長すぎる; 読むには

そこで、ブラック ボックスを開いて内部の仕組みを理解するために、独自のタイプセーフ ルーターをゼロから構築するこの投稿に参加してください。この記事は、読者が React をすでに理解しており、TypeScript に慣れていることを前提としています。
featured image - 独自のタイプセーフな React Router を 500 行で書く方法
OlegWock HackerNoon profile picture
0-item

私が約 6 年前に React を学習していたとき、react-router は私が最初に手に取ったサードパーティ ライブラリの 1 つでした。つまり、これは当然のことです。ルーティングは、最新のシングル ページ アプリケーションの重要な側面の 1 つです。私はそれを当然のことだと思っていました。


私にとって、react-router は魔法の箱でした。内部でどのように動作するのか全く分かりませんでした。それで、しばらくして、ルーティングが一般的にどのように機能するかを自然に理解したとき、それは少し悲しいことでした。もう魔法は必要ありませんね:(


しかし、「魔法のブラック ボックス」という概念を残しておいてよかったと思います。それは本当に有害な考えだと思います。これまでのテクノロジーのすべてがあなたや私と同じエンジニアによって構築されたことを理解すると、私はとても刺激を受けます。そして、それがあなたにも同じように役立つことを願っています。


そこで、この投稿に参加して、独自のタイプセーフ ルーターをゼロから構築して、ブラック ボックスを開いて内部の仕組みを理解しましょう。この記事は、読者が React をすでに理解しており、TypeScript に慣れていることを前提としています。

機能と制限事項

この記事ではルーターのどの機能について説明しますか。


私たちのキラー機能はタイプセーフティです。つまり、事前にルートを定義する必要があり、TypeScript は、URL の代わりに意味のないものを渡したり、現在のルートに欠落しているパラメーターを取得しようとしたりしていないかをチェックします。これにはいくつかの体操が必要ですが、心配しないでください。手順を説明します。


それに加えて、当社のルーターは、URL へのナビゲーション、ルート マッチング、ルート パラメーターの解析、戻る/進むナビゲーション、ナビゲーション ブロックなど、平均的なルーターに期待されるすべてをサポートします。


さて、制限について。私たちのルーターはブラウザーでのみ動作し (申し訳ありませんが React Native!)、SRR はサポートしません。 SSR のサポートは比較的簡単なはずですが、この記事はすでに膨大なので取り上げません。

用語

作りたいもののビジョンが見えてきたので、構造と用語について話す必要があります。同様の概念が非常に多く存在するため、それらを事前に定義することが重要です。


ライブラリには、生のルートと解析されたルートの 2 種類のルートがあります。生のルート/user/:id/infoまたは/loginのような単なる文字列です。 URL のテンプレートです。生のルートには、 :idなどのコロンで始まるセクションであるパラメータを含めることができます。


この形式は開発者にとっては使いやすいですが、プログラムにとってはそれほど使いにくいものです。これらのルートをよりマシンに適した形式に変換し、それを解析済みルートと呼びます。


ただし、ユーザーはルートを開くのではなく、URL を開きます。 URL はリソース (この場合はアプリ内のページ) の識別子です。 http://example.app/user/42/info?anonymous=1#bioのようになります。私たちのルーターは主に URL の 2 番目の部分 ( /user/42/info?anonymous=1#bio ) (ここではpathと呼びます) を気にします。


パスは、パス名( /user/42/info )、検索パラメータ( ?anonymous=1 )、およびハッシュ( #bio ) で構成されます。これらのコンポーネントを別個のフィールドとして格納するオブジェクトはlocationと呼ばれます。

API の概要

React ライブラリを構築するとき、私は 3 つのステップで進めることを好みます。まずは命令型APIです。その関数はどこからでも呼び出すことができ、通常は React にまったく関連付けられていません。ルーターの場合、それはnavigategetLocation 、またはsubscribeような関数になります。


次に、それらの関数に基づいて、 useLocationuseCurrentRouteのフックが作成されます。そして、それらの関数とフックはコンポーネントを構築するためのベースとして使用されます。私の経験では、これは非常にうまく機能し、簡単に拡張可能で多用途なライブラリを作成できます。


Router の API は、 defineRoutes関数から始まります。ユーザーはすべての生のルートのマップを関数に渡すことになっており、関数はルートを解析して同じ形状のマッピングを返します。 URL 生成やルート マッチングなどの開発者向け API はすべて、生ではなく解析されたルートを受け入れます。


 const routes = defineRoutes({ login: '/login', user: { me: '/user/me', byId: '/user/:id/info', } });


次のステップでは、解析されたルートをcreateRouter関数に渡します。これがルーターの核心です。この関数は、すべての関数、フック、コンポーネントを作成します。これは珍しいように見えるかもしれませんが、このような構造により、受け入れられる引数と props のタイプを、 routesで定義された特定のルートのセットに合わせて調整することができ、タイプセーフが確保されます (そして DX が向上します)。


 const { Link, Route, useCurrentRoute, navigate, /* etc... */ } = createRouter(routes);


createRouterアプリ内のどこでも使用できる関数 (命令型 API)、コンポーネントが位置の変更に反応できるようにするフック、および 3 つのコンポーネント ( LinkRouteNotFoundを返します。これはほとんどのユースケースをカバーするのに十分であり、これらの API に基づいて独自のコンポーネントを構築できます。

楽しみと利益のためのタイプレベルのプログラミング

まずはピッチのタイプセーフティの部分に取り組むことから始めます。前に述べたように、タイプセーフ ルーターを使用すると、TypeScript は次のような状況について事前に警告します。

 <Link href="/logim" />


または次のようにします。

 const { userld } = useRoute(routes.user.byId);


これらの何が問題なのかすぐに分からない場合は、間違いなくタイプセーフルーターが必要です:)

TypeScript の型システムは非常に強力です。つまり、タイプレベルのプログラミングを使用して、チェス エンジンアドベンチャー ゲーム、さらにはSQL データベースさえも作成できるのです。


値を操作する (2 つの文字列を連結するなど) 「値レベル プログラミング」についてはすでにご存知でしょう。

 function concat(a, b) { return a + b; } concat('Hello, ', 'World!'); // 'Hello, World!'


しかし、型を使ってそれを行うこともできます。

 type Concat<A extends string, B extends string> = `${A}${B}`; type X = Concat<'Hello, ', 'World!'>; // ^? type X = "Hello, World!"


はい、通常の関数ほど強力ではなく、見た目も異なります。しかし、これを使用すると、非常にクールで便利なことができるようになります。


タイプレベルのプログラミングを使用して生のルートからパラメータを抽出し、開発者がLinkや URL を構築する関数に不正な値を渡そうとしていないかをチェックできる新しいタイプを構築します。


TS を使用した型レベルのプログラミングは、すぐに読み取り不能になる可能性があります。幸いなことに、この複雑さをすべて隠して、次のようなきれいなコードを書くことを可能にするプロジェクトがすでに複数あります。


 export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;


かなりきれいですね。ちなみに、生のルートからパラメータを解析するために必要なコードはこれですべてです。このプロジェクトでは、ホットスクリプトライブラリを使用します。これは、複雑さと型レベルのコードの量を軽減するのに役立ちます。


ただし、必須ではありません。冒険してみたい場合は、これらすべてのタイプを自分で実装してみることもできます。 Chicane ルーターでは、サードパーティのタイプ ライブラリを使用せずに同様の機能を実装することでインスピレーションが得られます。


これに従う場合は、お気に入りのスターター (私はViteを使用しています) を使用して新しい React プロジェクトを作成し、そこでコーディングを開始することをお勧めします。こうすることで、ルーターをすぐにテストできるようになります。


Next.js のようなフレームワークは、このプロジェクトに干渉する可能性がある独自のルーティングを提供しており、代わりに「バニラ」 React を使用することに注意してください。問題がある場合は、完全なコードをここで見つけることができます。


まず、サードパーティのパッケージをインストールします。タイプレベルのユーティリティ用のhotscriptと、URL/生のルートからパラメータを解析するためのregexparam です


 npm install hotscript regexparam


私たちのタイプの最初の建築用レンガは生のルートです。生のルートは/で始まる必要があります。それをTSでどのようにコーディングしますか?このような:

 export type RawRoute = `/${string}`;


簡単ですよね?ただし、 defineRoutes単一の生のルートを受け入れず、ネストされたマッピングを受け入れるため、それをコード化しましょう。次のようなことを書きたくなるかもしれません。

 export type RawRoutesMap = { [key: string]: RawRoute | RawRoutesMap };


これはうまくいきます。ただし、このタイプは無限に深くなる可能性があるため、場合によっては TS が計算するのが困難になります。 TS の作業を容易にするために、許可されるネストのレベルを制限します。 20 のネスト レベルはすべてのアプリに十分なはずであり、TS はそれを簡単に処理できます。


しかし、再帰型の深さを制限するにはどうすればよいでしょうか?私はこのSOの回答からこのトリックを学びました。これは要件に合わせて変更されたバージョンです。

 export type RecursiveMap<T, MaxDepth extends number> = { [key: string]: RecursiveMap_<T, MaxDepth, []>; }; type RecursiveMap_<T, MaxDepth extends number, Stack extends unknown[]> = MaxDepth extends Stack["length"] ? T : T | { [key: string]: RecursiveMap_<T, MaxDepth, [1, ...Stack]> };


初めての複合型なので説明します。ここには 2 つのタイプがあります。RecursiveMap RecursiveMapエントリ ポイントとして機能し、追加のタプル パラメーターを渡してRecursiveMap_呼び出します。このタプルはマッピングの深さを追跡するために使用され、呼び出しごとにこの配列に 1 つの要素が追加されます。


そして、このタプルの長さがMaxDepthに等しくなるまで呼び出しを続けます。 TS では、リテラルとも呼ばれる特定の値 (たとえば、 numberではなく具体的に42 ) でextendsが使用される場合、それは「等しい」を意味します。


MaxDepthStack["length"]両方とも特定の数値であるため、このコードはMaxDepth === Stack["length"]として読み取ることができます。この構造がよく使われているのがわかります。


単純に数値を加算する代わりにタプルを使用するのはなぜでしょうか? TypeScript で 2 つの数値を加算するのはそれほど簡単ではありません。そのためのライブラリ全体があり、Hotscript で数値を追加することもできますが、(表示されない場合でも) 大量のコードが必要となるため、過度に使用すると TS サーバーとコード エディターの速度が低下する可能性があります。


したがって、私の経験則では、複雑な型は可能な限り避けることです。


このユーティリティ タイプを使用すると、次のように単純にマッピングを定義できます。

 export type RawRoutesMap = RecursiveMap<RawRoute, 20>;


raw ルート タイプについては以上です。キューの次は解析されたルートです。解析されたルートは、いくつかの追加フィールドと 1 つの関数を備えた単なる JavaScript オブジェクトです。見た目はこんな感じです:

 export type ParsedRoute<R extends RawRoute> = { keys: RouteParam<R>[]; build(...params: PathConstructorParams<R>): Path<R>; raw: R; ambiguousness: number, pattern: RegExp; };


これをkeysフィールドから解凍してみましょう。これは、このルートに必要なパラメータの配列にすぎません。その方法は次のとおりです。

 import { Pipe, Strings, Tuples } from "hotscript"; export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;


Hotscript では、関数を呼び出す方法が 2 つあります: CallまたはPipeCall 1 つの関数を呼び出す必要がある場合に便利ですが、この例では関数が 4 つあります。 Pipe入力を受け入れ、それを提供されたタプルの最初の関数にパイプします。


戻り値は 2 番目の関数などに入力として渡されます。この例では、たとえば生のルート/user/:userId/posts/:postIdがある場合、次のように変換されます。

 export type Beep = Pipe< "/user/:userId/posts/:postId", [ Strings.Split<"/">, // ["user", ":userId", "posts", ":postId"] Tuples.Filter<Strings.StartsWith<":">>, // [":userId", ":postId"] Tuples.Map<Strings.TrimLeft<":">>, // ["userId", "postId"] Tuples.ToUnion // "userId" | "postId" ] >;


見る?これが型レベルプログラミングの魔法です。それでは、そのbuild関数に取り組んでみましょう。ルート パラメーター ( userIdpostIdなど) とオプションの検索パラメーター/ハッシュを受け入れ、それらをパスに結合します。 PathConstructorParamsの実装を見てみましょう。

 // Allows us to also accept number and // any other type which can be converted into string export type StringLike = { toString: () => string }; export type SearchAndHashPathConstructorParams = { hash?: string, search?: string | { [key: string]: string, } }; export type RouteParamsMap< Route extends RawRoute, Val extends string | StringLike = string, > = { [key in RouteParam<Route>]: Val }; export type PathConstructorParams<R extends RawRoute> = | [RouteParamsMap<R, StringLike>] | [RouteParamsMap<R, StringLike>, SearchAndHashPathConstructorParams];


関数のパラメータは配列として定義されます (これは後で ...定義の中に広がります)

build関数)、最初の要素はRouteParamsMap 、2 番目の要素はオプションのSearchAndHashPathConstructorParamsです。 buildの値を返すのはどうですか?パスはすでに確立されていますが、TypeScript ではどのように記述するのでしょうか?


これはRouteParamに非常に似ていますが、もう少し型の体操が必要です。

 import { Fn } from "hotscript"; interface ReplaceParam extends Fn { return: this["arg0"] extends `:${string}` ? string : this["arg0"]; } // Leading slashes will be removed by Split<"/">, so we need to // add them back after our manipulations type Pathname< Route extends RawRoute, > = `/${Pipe< Route, [ Strings.Split<"/">, Tuples.Map<ReplaceParam>, Tuples.Join<"/"> ] >}${Route extends `${string}/` ? '/' : ''}`; export type Path< Route extends RawRoute, > = Pathname<Route> | `${Pathname<Route>}?${string}` | `${Pathname<Route>}#${string}`;


ここで行うことは、ルートをセグメントに分割し、各セグメントにマップし、それぞれでカスタム関数ReplaceParamを呼び出すことです。現在のセグメントがパラメータであるかどうかを確認し、 stringに置き換えるか、セグメントをそのまま返します。 ReplaceParam '関数' は少し奇妙に見えるかもしれませんが、これが Hotscript でカスタム関数を定義する方法です。


パスは、パスのみ、パスの後に疑問符が続くもの(これは検索パラメータとハッシュを含む URL を対象とします)、またはハッシュ記号(これは検索パラメータを持たないがハッシュを含む URL を対象とします)のいずれかから構成されることを明示的に示します。


一致したルート、つまり URL から取得したパラメータを含む解析されたルートを記述するタイプも必要です。

 // Interface (and not type) because we need to use `this` export interface RouteWithParams<R extends RawRoute> { route: ParsedRoute<R>, params: RouteParamsMap<R>, // TS can't properly infer type of route object with simple // check like currentRoute.route === routes.user.byId, so we // need our custom type guard matches: <T extends RawRoute>(route: ParsedRoute<T>) => this is RouteWithParams<T>, }


最後のタイプはParsedRoutesMapです。これはRawRoutesMapに似ていますが、解析されたルート用です。

 // This accepts RawRoutesMap and transforms it into // mapping of parsed routes of same shape export type ParsedRoutesMap<RM extends RawRoutesMap> = { [Key in keyof RM]: RM[Key] extends RawRoute ? ParsedRoute<RM[Key]> : RM[Key] extends RawRoutesMap ? ParsedRoutesMap<RM[Key]> : never; };


その上で、型については終了します。他にもいくつかありますが、それらはより単純なので、実装を進めながら説明します。型レベルのプログラミングにもっと挑戦したい場合は、型レベルの Typescript をチェックして詳細を学び、型の課題を解決してみることができます ( リソースの優れたリストもあります)。

ルートパーサー

最後に、通常の値レベルのコーディングに戻ります。 defineRoutesを実装して、ボールを転がしてみましょう。

 export const typedKeys = <const T extends {}> (obj: T) => { return Object.keys(obj) as Array<keyof T>; }; export const defineRoutes = <const T extends RawRoutesMap>(routesMap: T): ParsedRoutesMap<T> => { const entries = typedKeys(routesMap).map((key) => { const entry = routesMap[key]; if (typeof entry === 'string') { return [key, parseRoute(entry)] as const; } else { // Nested map return [key, defineRoutes(entry)] as const; } }); return Object.fromEntries(entries); };


ここでは複雑なことは何もありません。 parseRoute関数をさらに詳しく見てみましょう。

 import { parse, inject, type RouteParams as RegexRouteParams } from "regexparam"; export class InvalidRoute extends Error { }; export class InvalidRouteParams extends Error { }; const parseRoute = <const R extends RawRoute>(route: R): ParsedRoute<R> => { if (!route.startsWith('/')) { throw new InvalidRoute('route should start with slash (/)') } const { keys, pattern } = parse(route); const hasRequiredParams = keys.length > 0; const parsedRoute: ParsedRoute<R> = { build(...args) { const params = ( hasRequiredParams ? args[0] : undefined ) as RouteParamsMap<R, StringLike> | undefined; const searchAndHash = ( hasRequiredParams ? args[1] : args[0] ) as SearchAndHashPathConstructorParams | undefined; if (hasRequiredParams) { if (!params) { throw new InvalidRouteParams( `Parameters for route ${route} weren't provided` ); } const missingKeys = keys.filter(k => !(k in params)); if (missingKeys.length) { throw new InvalidRouteParams( `Missing parameters for route ${route}: ${missingKeys.join(', ')}` ); } } else if (args.length > 1) { throw new InvalidRouteParams( `Route ${route} doesn't accept any parameters, received ${args[0]}` ); } let path = hasRequiredParams ? inject(route, params as RegexRouteParams<R>) : route; if (searchAndHash && searchAndHash.search) { if (typeof searchAndHash.search === 'string') { path += searchAndHash.search.startsWith('?') ? searchAndHash.search : '?' + searchAndHash.search; } else { path += '?' + new URLSearchParams(searchAndHash.search).toString(); } } if (searchAndHash && searchAndHash.hash) { path += searchAndHash.hash.startsWith('#') ? searchAndHash.hash : '#' + searchAndHash.hash; } return path as Path<R>; }, raw: route, keys: keys as RouteParam<R>[] || [], ambiguousness: keys.length, pattern: pattern, }; return parsedRoute; };


parseRouteも非常にシンプルですが、かなり長いです。ルートを解析してパラメータを抽出するには、 regexparamライブラリを使用します。これにより、ルートに必要なパラメータの配列を取得し、後で URL とルートを照合するために使用する正規表現を生成できます。


この情報は、このオブジェクトの構築に使用された元の生のルートとあいまいさレベル (ルート内の単なるパラメーターの数) とともに保存されます。

履歴ラッパー

すべてのルーターはその状態をどこかに保存する必要があります。ブラウザー内のアプリの場合、実際には、メモリ内 (ルート コンポーネント内の状態変数またはコンポーネント ツリーの外側の変数)、履歴 API、または URL のハッシュ部分の 4 つのオプションに集約されます。


たとえば、React でゲームをコーディングしている場合など、ルートがあることをユーザーにまったく見せたくない場合は、インメモリ ルーティングを選択することもできます。 React アプリが大きなアプリケーションの 1 ページにすぎず、URL を自由に変更できない場合、ルートをハッシュに保存すると便利です。


ただし、ほとんどの場合、History API を使用することが最善の選択肢になります。 SSR と互換性があり (他のオプションには互換性がありません)、ユーザーが慣れている動作パターンに従っており、見た目が最もきれいです。このプロジェクトでもそれを使用します。ただし、これには重大な欠陥が 1 つあります。それは、追加のラッパーがないとほとんど使用できないことです。


History AP を使用すると、 popstateイベントをサブスクライブでき、URL が変更されたときにブラウザーが通知します。ただし、ユーザーが戻るボタンをクリックするなどして変更を開始した場合に限ります。 URL の変更がコードから開始された場合は、それを自分で追跡する必要があります。


私が調査したほとんどのルーターは独自のラッパーを使用しています。react-router と シケイン は履歴NPM パッケージを使用し、TanStack ルーターには独自の実装があり、wouter には完全なフレッジャー ラッパーはありませんが、履歴をモンキーパッチする必要があります。


そこで、独自のラッパーを実装してみましょう。

 export type HistoryLocation = Pick<Location, | 'origin' | 'href' | 'hash' | 'search' | 'pathname' >; export type NavigationBlocker = (isSoftNavigation: boolean) => boolean; export const createHistory = () => { const winHistory = window.history; const winLocation = window.location; const getLocation = (): HistoryLocation => { return { origin: winLocation.origin, href: winLocation.href, pathname: winLocation.pathname, search: winLocation.search, hash: winLocation.hash, }; }; /* Some magic code */ return /* something... */; };


使用するタイプは、 HistoryLocationNavigationBlockerの 2 つです。 1 つ目は、組み込みのLocationタイプ ( window.locationのタイプ) の少し限定されたバージョンであり、2 つ目については、ナビゲーションのブロックに到達した後で説明します。この章以降のすべてのコードは、 createHistory関数内に組み込まれます。


まずは、履歴変更へのサブスクリプションの実装から始めましょう。このプロジェクトでは、サブスクライブに React スタイルの関数を使用します。コールバックを渡してsubscribe呼び出すと、サブスクライブを解除するときに呼び出す必要がある別の関数が返されます。

 const subscribers: Set<VoidFunction> = new Set(); const onChange = () => { subscribers.forEach(fn => { try { fn(); } catch (err) { console.error('Error while handling location update', err); } }) }; const subscribe = (listener: VoidFunction) => { subscribers.add(listener); return () => { subscribers.delete(listener); }; };


次のステップは、プログラムによる変更を含む、場所の変更に対応することです。どうやってやりますか?もちろんモンキーパッチ付きです。これは少し汚いように見えるかもしれませんが (実際、実際そうなのです)、残念ながらこれより良い選択肢はありません。

 const origPushState = winHistory.pushState.bind(winHistory); const origReplaceState = winHistory.replaceState.bind(winHistory); winHistory.pushState = (data, unused, url) => { // tryNavigate will be covered later tryNavigate(() => { origPushState(data, unused, url); onChange(); }); }; winHistory.replaceState = (data, unused, url) => { tryNavigate(() => { origReplaceState(data, unused, url); onChange(); }); }; // This event is emmited when user initiates navigation // or when calling history.go, history.back and history.forward window.addEventListener('popstate', onChange);


そして、これまでの実装に欠けていた最後の重要な部分は、ナビゲーション ブロックです。これは、ナビゲーション リクエストをインターセプトし、条件付きでキャンセルできる機能です。ナビゲーション ブロックの典型的な例は、ユーザーがフォーム内の進行状況を失わないようにすることです。

 let blockers: NavigationBlocker[] = []; const beforeUnloadHandler = (event: Event) => { const blocked = blockers.some(blocker => blocker(false)); if (blocked) { event.preventDefault(); // @ts-ignore For older browsers event.returnValue = ''; return ''; } }; const tryNavigate = (cb: VoidFunction) => { const blocked = blockers.some(blocker => blocker(true)); if (blocked) return; cb(); }; const addBlocker = (blocker: NavigationBlocker) => { blockers.push(blocker); if (blockers.length === 1) { addEventListener('beforeunload', beforeUnloadHandler, { capture: true }); } return () => { blockers = blockers.filter(b => b !== blocker); if (blockers.length === 0) { removeEventListener('beforeunload', beforeUnloadHandler, { capture: true }); } } };


私たちの実装では、ブロッカーは、このナビゲーションをブロックする必要があるかどうかを示すブール値を返す関数です。ナビゲーションのブロックに関しては、2 種類のナビゲーションがあり、それぞれを異なる方法で処理する必要があります。


一方では、ユーザーがアプリ内のあるページからアプリ内の別のページに移動するときのソフト ナビゲーションがあります。これは完全に制御されているため、ナビゲーションをブロックしたり、カスタム UI を表示したり (ユーザーの意図を確認するため)、ナビゲーションをブロックした後にアクションを実行したりできます。


一方、ハード ナビゲーションは、ユーザーが別のサイトに移動するか、タブを完全に閉じる場合です。セキュリティ上の懸念があるため、ブラウザでは JavaScript がこのナビゲーションを実行するかどうかを決定することを許可できません。ただし、ブラウザーでは、ユーザーに追加の確認ダイアログを表示するかどうかを JavaScript で指定できます。


ソフト ナビゲーションをブロックする場合、追加の UI (カスタム確認ダイアログなど) を表示することもできますが、ハード ナビゲーションの場合、ユーザーがページに留まることを決定した場合にのみそれが表示されるため、実際には意味がありません。 、その時点で、それは役に立たず、混乱します。


履歴がナビゲーション ブロッカー関数を呼び出すと、ソフト ナビゲーションを実行しているかどうかを示すブール値が提供されます。


以上で、history オブジェクトを返す必要があるだけです。

 return { subscribe, getLocation, push: winHistory.pushState, replace: winHistory.replaceState, go: (distance: number) => tryNavigate(() => winHistory.go.call(winHistory, distance)), back: () => tryNavigate(() => winHistory.back.call(winHistory)), forward: () => tryNavigate(() => winHistory.forward.call(winHistory)), addBlocker, };

ステップ 1: 命令型 API

ついにここまで来ました。命令型 API は、その後のすべてのフックとコンポーネントのベースとなり、開発者がニーズをカバーするカスタム フックを構築できるようになります。まず最初に、ルート マップをフラット配列に変換する必要があります。こうすることで、すべてのルートをループすることがはるかに簡単になり、ルート マッチング部分の作業を開始するときに役立ちます。


型ユーティリティ ( ParsedRoutesMap ParsedRouteの和集合に変換します) と関数 ( routesMap解析されたルートの配列に変換します) の両方が必要です。まずは次のように入力してみましょう。

 export type Values<T extends {}> = T[keyof T]; type FlattenRouteMap<T> = T extends ParsedRoute<any> | RawRoute ? T : T extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap ? AllRoutesFromMap<T> : never; export type AllRoutesFromMap< RM extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap > = FlattenRouteMap<Values<RM>>;


これを 2 つの型に分割するのは不必要に見えるかもしれませんが、これには非常に重要な理由が 1 つあります。それ自体を呼び出す単一の型として実装すると、TS はその型が深すぎて無限である可能性があると文句を言います。そこで、相互に呼び出しを行う 2 つのタイプに分割することで、この問題を回避します。


値レベルの関数の場合、渡された値が解析されたルートかどうかをチェックするための型ガードも必要です。

 export const isParsedRoute = <T extends `/${string}` = `/${string}`>( route: any ): route is ParsedRoute<T> => { return !!route && typeof route === 'object' && typeof route.raw === 'string' && typeof route.build === 'function'; } export const getAllRoutes = <T extends RawRoutesMap>( routesMap: ParsedRoutesMap<T> ): ParsedRoute<AllRoutesFromMap<T>>[] => { type PossibleRawRoute = AllRoutesFromMap<T>; return typedKeys(routesMap).flatMap((k) => { const val = routesMap[k]; if (isParsedRoute<PossibleRawRoute>(val)) { return [val] as const; } // At this point we know that val isn't ParsedRoute, so it has to be map of routes // but TS can't infer that, so we help him a little by casting val to correct type return getAllRoutes(val as ParsedRoutesMap<T>); }); };


それでは、ルーターの実装を始めましょう。歴史と同様に、この章と次の 2 つの章では、特に明記されていない限り、すべてのコードはcreateRouter関数に入ります。

 import { useSyncExternalStore, ComponentType, useMemo, MouseEventHandler, ComponentProps, useEffect } from 'react'; export const createRouter = <T extends RawRoutesMap>(routesMap: ParsedRoutesMap<T>) => { // Type for any possible route from passed routesMap type RouteType = AllRoutesFromMap<T>; // Similar to above, but for matched routes, ie includes URL parameters type BindedRouteWithParams = RouteWithParams<RouteType>; // Some of our functions will accept route filter, // which can be single parsed route, array or object type RouteFilter<T extends RouteType> = | ParsedRoute<T> | ParsedRoute<T>[] | Record<string, ParsedRoute<T>>; const history = createHistory(); const routes = getAllRoutes(routesMap); };


まず最初に、現在の場所を既知のルートの 1 つと一致させるようにルーターに教えてみましょう。いくつかのユーティリティは、 createRouter関数内ではなく、グローバル スコープまたは個別のファイルに入ることができます。

 export const filterOutFalsy = <T>(obj: T[]): Exclude<T, undefined>[] => { return obj.filter(Boolean) as Exclude<T, undefined>[]; }; export class RouteMatchingConflict extends Error { }; // This will be used later export class RouteMismatch extends Error { };


そして、このコードはcreateRouter関数に入ります。

 const extractRouteParams = <T extends RawRoute>( pathname: string, parsedRoute: ParsedRoute<T> ) => { const match = parsedRoute.pattern.exec(pathname); if (!match) return undefined; // Extract all route parameters from match array // and construct object from them return Object.fromEntries(parsedRoute.keys.map((key, index) => { return [key, match[index + 1]]; })) as RouteParamsMap<T>; }; const findMatchingRoute = ( location: HistoryLocation ): BindedRouteWithParams | undefined => { const matchingRoutes = filterOutFalsy(routes.map(route => { const params = extractRouteParams<RawRoute>( location.pathname, route ); if (!params) return undefined; return { route, params, matches<T extends RawRoute>(r: ParsedRoute<T>) { return route === r; }, }; })); if (matchingRoutes.length === 0) return undefined; if (matchingRoutes.length === 1) return matchingRoutes[0]; // At this point we have multiple matching routes :/ // Gotta decide which one we prefer let lowestAmbiguousnessLevel = Infinity; let lowestAmbiguousnessMatches: BindedRouteWithParams[] = []; matchingRoutes.forEach((match) => { if (match.route.ambiguousness === lowestAmbiguousnessLevel) { lowestAmbiguousnessMatches.push(match); } else if (match.route.ambiguousness < lowestAmbiguousnessLevel) { lowestAmbiguousnessLevel = match.route.ambiguousness; lowestAmbiguousnessMatches = [match]; } }); if (lowestAmbiguousnessMatches.length !== 1) { throw new RouteMatchingConflict( `Multiple routes with same ambiguousness level matched pathname ${location.pathname}: ${lowestAmbiguousnessMatches.map(m => m.route.raw).join(', ')}` ); } return lowestAmbiguousnessMatches[0]; }; let currentRoute = findMatchingRoute(history.getLocation()); // This function will be later returned from createRouter function const getCurrentRoute = () => currentRoute;


ここでは、既知のルートをすべて調べて、それぞれを現在の場所と照合しようとします。ルートの正規表現が URL と一致する場合、URL からルート パラメータを取得します。それ以外の場合は、 nullを取得します。一致したルートごとに、 RouteWithParamsオブジェクトを作成し、配列に保存します。さて、一致するルートが 0 つまたは 1 つある場合は、すべてが簡単です。


ただし、現在地に一致するルートが複数ある場合は、どれを優先するかを決定する必要があります。これを解決するために、 ambiguousnessフィールドを使用します。覚えているかもしれませんが、このルートには多数のパラメータがあり、 ambiguousnessの最も低いルートが優先されます。


たとえば、 /app/dashboard/app/:section 2 つのルートがある場合、場所http://example.com/app/dashboardは両方のルートに一致します。ただし、この URL は/app/:section /app/dashboardルートに対応する必要があることは明らかです。


ただし、このアルゴリズムは完全に安全というわけではありません。たとえば、ルート/app/:user/settings/:section/app/dashboard/:section/:region両方とも URL http://example.com/app/dashboard/settings/asiaと一致します。そして、それらのあいまいさレベルは同じであるため、ルーターはどちらを優先すべきかを決定できません。


ここで、位置の変更に反応し、 currentRoute変数を更新するために、このコードを結合する必要があります。

 const areRoutesEqual = <A extends RawRoute, B extends RawRoute>( a: RouteWithParams<A> | undefined, b: RouteWithParams<B> | undefined ): boolean => { if (!a && !b) return true; // Both are undefined if ((!a && b) || (a && !b)) return false; // Only one is undefined if (!a!.matches(b!.route)) return false; // Different routes // Same routes, but maybe parameters are different? const allParamsMatch = a.route.keys.every(key => a.params[key] === b!.params[key]); return allParamsMatch; }; history.subscribe(() => { const newRoute = findMatchingRoute(history.getLocation()); if (!areRoutesEqual(newRoute, currentRoute)) { currentRoute = newRoute; notifyRouteChange(); // Will be covered later } });


これで、ルーターは位置の変更に反応し、ユーザーはいつでも現在のルートを取得できるようになりました。ただし、ルート変更をサブスクライブする機能がなければあまり役に立たないので、それを追加しましょう。このアプローチは、履歴ラッパーで使用したものと非常によく似ています。

 const subscribers: Set<VoidFunction> = new Set(); const subscribe = (cb: VoidFunction) => { subscribers.add(cb); return () => void subscribers.delete(cb); }; const notifyRouteChange = () => { subscribers.forEach(cb => { try { cb(); } catch (err) { console.error('Error in route change subscriber', err); } }); };


ナビゲーションを実行するために、 navigate関数とnavigateUnsafe関数を公開します。これらは、 history.pushhistory.replaceの単純なラッパーです。

 // This function accepts any string path (no type-safety) const navigateUnsafe = ( path: string, { action = 'push' }: { action?: 'push' | 'replace' } = {} ) => { history[action]({}, '', path) }; // And this function accepts only paths that correspond to one of routes const navigate = ( path: Path<RouteType>, options: { action?: 'push' | 'replace' } = {} ) => { navigateUnsafe(path, options); };


さて、これが本物のルーターです。非常に必要最低限の機能ですが、それでも機能します。まだ実装するフックとコンポーネントがいくつかありますが、ここからははるかに簡単になります。

ステップ 2: フック

フックについては、現在の場所と現在のルートを返す単純なフックから始めることができます。これらはそれ自体では非常に簡単ですが、 useSyncExternalStore を使用すると、それらをワンライナーに変えることができます。以前に命令型 API を設計した方法により、これらのフックのコードを大幅に減らすことができました。

 const useLocation = () => { return useSyncExternalStore(history.subscribe, history.getLocation); }; const useCurrentRoute = () => { return useSyncExternalStore(subscribe, getCurrentRoute); };


特定のルート/ルートのセットでのみレンダリングされることになっているコンポーネントをコーディングする場合、 useCurrentRoute使用して現在のルートを取得し、基準に一致するかどうかを確認して、そのパラメーターを使用する (またはエラーをスローする) ことができます。


しかし、これは非常に一般的なタスクであるため、ユーザーにそのための独自のフックを作成させることは犯罪になります。ルーターはこれをすぐに提供する必要があります。

 function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: true): RouteWithParams<T>; function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict: false): RouteWithParams<T> | undefined; function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: boolean): RouteWithParams<T> | undefined { const currentRoute = useCurrentRoute(); const normalizedFilter = Array.isArray(filter) ? filter : isParsedRoute(filter) ? [filter] : Object.values(filter); const isMatching = !!currentRoute && normalizedFilter.some(route => currentRoute.matches(route)); if (isMatching) return currentRoute as RouteWithParams<T>; else { if (strict === false) return undefined; throw new RouteMismatch( `Current route doesn't match provided filter(s)` ); } }


このフックには、厳密なバージョンと緩和されたバージョンの 2 つのバージョンがあります。ユーザーが 2 番目のパラメーターとしてtrueを渡した場合 (またはtrueがデフォルト値なので何も渡さなかった場合)、現在のルートが提供されたフィルターのいずれかと一致しない場合、このフックはエラーをスローします。


こうすることで、フックが一致するルートを返すか、まったく返さないかを確認できます。 2 番目のパラメーターが false の場合、現在のルートがフィルターに一致しない場合、フックは例外をスローするのではなく、単純に未定義を返します。


この動作を TypeScript に記述するには、関数オーバーロードと呼ばれる機能を使用します。これにより、異なる型の複数の関数定義を定義できるようになり、ユーザーがそのような関数を呼び出すときに TypeScript が自動的に使用する関数を選択します。


パス パラメーターに加えて、一部のデータが検索パラメーターに渡される可能性があるため、それらを文字列からマッピングに解析するフックを追加しましょう。このために、組み込みブラウザー API URLSearchParamsを使用します。

 const useSearchParams = () => { const location = useLocation(); return useMemo(() => { return Object.fromEntries( (new URLSearchParams(location.search)).entries() ); }, [location.search]); };


このセクションの最後のフックはuseNavigationBlockerで、これも非常に単純です。これはコールバックを受け入れ、 history.addBlockerへの呼び出しをエフェクトにラップするだけで、変更された場合にブロッカーを再アタッチします。

 const useNavigationBlocker = (cb: NavigationBlocker) => { useEffect(() => { return history.addBlocker(cb); }, [cb]); };


それでは、コンポーネントの説明に移りましょう。

ステップ 3: コンポーネント

ルーティング ライブラリについて言及したときに最初に思い浮かぶコンポーネントは何ですか?きっとそれはRouteか、少なくともそれに似たものだと思います。前に見たように、私たちのフックは、すべての面倒な作業を行う適切に設計された命令型 API のおかげで、非常にシンプルになりました。


コンポーネントについても同様です。ユーザーは数行のコードで簡単に実装できます。しかし、私たちは本格的なルーティング ライブラリなので、電池も同梱しましょう :)

 type RouteProps = { component: ComponentType, match: RouteFilter<RouteType> }; const Route = ({ component: Component, match }: RouteProps) => { const matchedRoute = useRoute(match, false); if (!matchedRoute) return null; return (<Component />); };


まあ、それは簡単でした! NotFoundコンポーネントをどのように実装するか考えてみませんか? :)

 type NotFoundProps = { component: ComponentType }; const NotFound = ({ component: Component }: NotFoundProps) => { const currentRoute = useCurrentRoute(); if (currentRoute) return null; return (<Component />); };


そして、ルーターに必要な最後のコンポーネントはLinkですが、これは少し注意が必要です。 <a href="/app/dashboard" />は常にハード ナビゲーションを開始し、タイプセーフを提供しないため、単純に使用することはできません。そこで、これらの問題に対処しましょう。

 type LinkProps = Omit<ComponentProps<"a">, 'href'> & ( // Our link accepts either type-strict href // or relaxed unsafeHref { href: Path<RouteType>, unsafeHref?: undefined } | { href?: undefined, unsafeHref: string } ) & { action?: 'push' | 'replace' }; const Link = ({ action = 'push', onClick, href, unsafeHref, ...props }: LinkProps) => { const hrefToUse = (href ?? unsafeHref)!; const targetsCurrentTab = props.target !== '_blank'; const localOnClick: MouseEventHandler<HTMLAnchorElement> = (event) => { if (onClick) { onClick(event); if (event.isDefaultPrevented()) { // User-defined click handler cacnelled navigation, we should exit too return; } } const inNewTab = !targetsCurrentTab || event.ctrlKey || event.shiftKey || event.metaKey || event.button === 1; if (!isExternal && !inNewTab) { event.preventDefault(); navigateUnsafe(hrefToUse, { action }); } }; const isExternal = useMemo(() => { if (!hrefToUse) return false; return new URL(hrefToUse, window.location.href).origin !== location.origin; }, [hrefToUse]); return <a {...props} href={hrefToUse} onClick={localOnClick} /> };


navigate機能と同様に、 Linkコンポーネントは渡された URL をタイプチェックしますが、任意の文字列 URL を (エスケープ ハッチまたは外部リンクとして) 提供することもできます。 <a>の動作をオーバーライドするには、独自のonClickリスナーをアタッチします。その中で、元のonClick ( Linkコンポーネントに渡される) を呼び出す必要があります。


その後、ナビゲーションが開発者によってすでに中止されていないかどうかを確認します (中止されている場合は、イベントを無視する必要があります)。すべて問題がなければ、リンクが外部ではないかどうか、また現在のタブで開く必要があるかどうかを確認します。その場合にのみ、組み込みのハード ナビゲーションをキャンセルし、代わりに独自のnavigateUnsafe関数を呼び出すことができます。


あとは、 createRouter関数からすべての関数、フック、コンポーネント (履歴から直接再エクスポートされたいくつかの関数) を返すだけです。

 return { // Directly re-exported from history go: history.go, back: history.back, forward: history.forward, addBlocker: history.addBlocker, getLocation: history.getLocation, subscribeToLocation: history.subscribe, // Imperative API subscribe, getCurrentRoute, navigate, navigateUnsafe, // Hooks useLocation, useCurrentRoute, useRoute, useSearchParams, useNavigationBlocker, // Components Link, Route, NotFound, };


これでルーターは完成です。これで、作成したばかりの小さなルーターを紹介するための小さなアプリを構築できます。ちなみに、このルーターの完全なコード (サンプル アプリのコードを含む) はここで見つけることができます。

パズルのピースを組み立てる

では、このすべてのコードはどのように結びついているのでしょうか?自分で言うのもなんですが、実に見事です!メモを取るアプリを作成していると想像してください。まず、ルートを定義し、次のようなルーターを作成することから始めます。

 export const routes = defineRoutes({ // Yep, this will be very minimal app root: '/', newNote: '/new', note: '/note/:noteId', }); const router = createRouter(routes); // Export functions and component you will be using export const { navigate, Link, Route, NotFound, useRoute, useNavigationBlocker } = router;


次に、定義したルートをページ コンポーネントにリンクします。

 function App() { return ( <div className="app"> <div className="links"> {/* This is how you build URL for Link */} <Link href={routes.root.build({})}>View all notes</Link> <Link href={routes.newNote.build({})}>Create new</Link> </div> <Route match={routes.root} component={NotesListPage} /> <Route match={routes.newNote} component={NewNotePage} /> <Route match={routes.note} component={NoteDetailsPage} /> <NotFound component={NotFoundPage} /> </div> ) }


NoteDetailsPageでは、URL からノート ID を取得する必要があるため、 useRouteフックを使用します。

 export const NoteDetailsPage = () => { const { getNote } = useNotes(); const { params } = useRoute(routes.note); const note = getNote(params.noteId); return note ? (<> <h1>{note.title}</h1> <div>{note.text}</div> </>) : (<h1>Not found</h1>); };


また、新しいメモを作成するときに、ユーザーがメモを保存せずに移動した場合は、ユーザーの意図を確認したいと思うでしょう。

 export const NewNotePage = () => { const saveNote = () => { isSubmittingNoteRef.current = true; const note = createNote(title, text); // And this is programmatic redirect navigate(routes.note.build({ noteId: note.id })); isSubmittingNoteRef.current = false; }; const [title, setTitle] = useState(''); const [text, setText] = useState(''); const { createNote } = useNotes(); const isSubmittingNoteRef = useRef(false); useNavigationBlocker((isSoftNavigation) => { const dirty = title !== '' || text !== ''; if (!dirty || isSubmittingNoteRef.current) return false; if (isSoftNavigation) { const confirmation = confirm('Do you want to leave?'); return !confirmation; } else { return true; } }); return <> <h1>New note</h1> <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} /> <textarea placeholder="Text" value={text} onChange={e => setText(e.target.value)} /> <button onClick={saveNote}>Save</button> </>; };

考えられる改善点

私たちのルーターは確かにルーティングを行いますが、TanStack ルーター、react-router、Next.js ルーターなどの実稼働対応ソリューションには匹敵しません。つまり、コードはわずか約 500 行であり、それほど多くはありません。しかし、正確には何が欠けているのでしょうか?


まずはサーバーサイドレンダリングです。現在、すべてのアプリが SSR を必要とするわけではありませんが、すべてのルーティング ライブラリが SSR をサポートすることが期待されています。サーバー側のレンダリングを文字列に追加するには (SSR のストリーミングではありません!)、メモリ内の現在の位置を保存する別のhistoryを作成し (サーバーには History API がないため)、それをcreateRouter関数に接続します。


ストリーミング SSR の実装がどれほど難しいかはわかりませんが、サスペンスのサポートと強く結びつくと思われます。


次に、このルーターは同時レンダリングとうまく統合できません。これは主に、非ブロッキング トランジションと互換性がないため、 useSyncExternalStoreを使用していることが原因です。これは、ティアリング、つまり UI の一部が特定のストア値でレンダリングされているが、UI の残りの部分が別のストア値でレンダリングされる状況を回避するためにこのように機能します。


このため、ルーターはサスペンスとうまく統合できず、サスペンドするすべての位置情報更新ではフォールバックが表示されます。ちなみに、React の同時実行についてはこの記事で説明しましたが、この記事では、Suspense、データの取得、フックのuseについて説明します。


しかし、これらの欠点があっても、この記事が興味深く、途中で独自のルーターを構築していただければ幸いです :)