paint-brush
Comment écrire votre propre routeur Typesafe React en 500 lignespar@olegwock
395 lectures
395 lectures

Comment écrire votre propre routeur Typesafe React en 500 lignes

par OlegWock39m2024/03/11
Read on Terminal Reader

Trop long; Pour lire

Alors rejoignez-moi dans cet article où nous allons construire notre propre routeur typesafe à partir de zéro pour ouvrir cette boîte noire et comprendre son fonctionnement interne. Cet article suppose que vous connaissez déjà React et que vous êtes à l'aise avec TypeScript.
featured image - Comment écrire votre propre routeur Typesafe React en 500 lignes
OlegWock HackerNoon profile picture
0-item

Lorsque j'apprenais React il y a environ 6 ans, React-Router était l'une des premières bibliothèques tierces que j'ai choisies. Je veux dire, c’est logique : le routage est l’un des aspects clés de l’application moderne à page unique. Je l'ai pris pour acquis.


Pour moi, React-Router était une boîte magique, je n'avais aucune idée de son fonctionnement en interne. Alors, quand après un certain temps j’ai naturellement compris comment fonctionne le routage en général, c’était un peu triste. Plus de magie, hein :(


Mais je suis heureux d’avoir laissé derrière moi ce concept de « boîtes noires magiques ». Je pense que c'est une idée vraiment néfaste. Comprendre que chaque élément technologique a été construit par des ingénieurs, tout comme vous et moi, m'inspire beaucoup, et j'espère que cela fera de même pour vous.


Alors rejoignez-moi dans cet article où nous allons construire notre propre routeur typesafe à partir de zéro pour ouvrir cette boîte noire et comprendre son fonctionnement interne. Cet article suppose que vous connaissez déjà React et que vous êtes à l'aise avec TypeScript.

Caractéristiques et limites

Permettez-moi de vous expliquer quelles fonctionnalités de notre routeur seront couvertes dans cet article.


Notre fonctionnalité phare sera la sécurité de type. Cela signifie que vous devrez définir vos itinéraires à l'avance et TypeScript vérifiera que vous ne transmettez pas de bêtises à la place d'une URL ou n'essayez pas d'obtenir les paramètres manquants dans l'itinéraire actuel. Cela nécessitera une certaine gymnastique, mais ne vous inquiétez pas, je vais vous guider.


En plus de cela, notre routeur prendra en charge tout ce que vous attendez d'un routeur moyen : navigation vers les URL, correspondance d'itinéraire, analyse des paramètres d'itinéraire, navigation avant/arrière et blocage de la navigation.


Maintenant, parlons des limites. Notre routeur ne fonctionnera que dans le navigateur (désolé React Native !) et il ne prendra pas en charge SRR. La prise en charge du SSR devrait être relativement simple, mais cet article est déjà énorme, je n'en parlerai donc pas.

Terminologie

Maintenant que nous avons une vision de ce que nous aimerions réaliser, nous devons parler de structure et de terminologie. Il existe de nombreux concepts similaires, il est donc crucial de les définir à l’avance.


Il y aura deux types de routes dans notre bibliothèque : les routes brutes et les routes analysées. La route brute est juste une chaîne qui ressemble à /user/:id/info ou /login ; c'est un modèle pour les URL. La route brute peut contenir des paramètres, qui sont des sections commençant par deux points, comme :id .


Ce format est facile à utiliser pour les développeurs, mais pas tellement pour un programme ; nous transformerons ces routes dans un format plus convivial et l'appellerons une route analysée .


Mais les utilisateurs n'ouvrent pas de routes, ils ouvrent des URL. Et l'URL est un identifiant pour la ressource (page à l'intérieur de l'application dans notre cas) ; cela pourrait ressembler à http://example.app/user/42/info?anonymous=1#bio . Notre routeur se soucie principalement de la deuxième partie de l'URL ( /user/42/info?anonymous=1#bio ), que nous appellerons path .


Le chemin comprend le nom du chemin ( /user/42/info ), les paramètres de recherche ( ?anonymous=1 ) et le hachage ( #bio ). L'objet qui stocke ces composants dans des champs distincts sera appelé location .

Présentation générale de l'API

Lors de la création de bibliothèques React, j'aime procéder en trois étapes. Tout d’abord, API impérative. Ses fonctions peuvent être appelées depuis n'importe où et elles ne sont généralement pas du tout liées à React. Dans le cas d'un routeur, il s'agira de fonctions telles que navigate , getLocation ou subscribe .


Ensuite, sur la base de ces fonctions, des hooks comme useLocation ou useCurrentRoute sont créés. Et puis ces fonctions et crochets sont utilisés comme base pour construire des composants. D'après mon expérience, cela fonctionne exceptionnellement bien et permet de créer une bibliothèque facilement extensible et polyvalente.


L'API du routeur commence par la fonction defineRoutes . L'utilisateur est censé transmettre une carte de toutes les routes brutes à la fonction, qui analyse les routes et renvoie un mappage avec la même forme. Toutes les API destinées aux développeurs, comme la génération d'URL ou la correspondance de routes, accepteront les routes analysées et non brutes.


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


L'étape suivante consiste à transmettre les routes analysées à la fonction createRouter . C'est la viande de notre routeur. Cette fonction créera toutes les fonctions, hooks et composants. Cela peut sembler inhabituel, mais une telle structure nous permet d'adapter les types d'arguments et d'accessoires acceptés à un ensemble spécifique de routes définies dans routes , garantissant la sécurité des types (et améliorant DX).


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


createRouter renverra des fonctions qui peuvent être utilisées n'importe où dans votre application (API impérative), des hooks qui permettent à vos composants de réagir aux changements d'emplacement et trois composants : Link , Route et NotFound . Cela suffira à couvrir la majorité des cas d’utilisation et vous pourrez créer vos propres composants basés sur ces API.

Programmation au niveau du type pour le plaisir et le profit

Nous commençons par aborder la partie sécurité des types de notre argumentaire. Comme je l'ai mentionné précédemment, avec un routeur typesafe, TypeScript vous avertira à l'avance d'une situation comme celle-ci :

 <Link href="/logim" />


Ou comme ceci :

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


Et si vous ne voyez pas tout de suite ce qui ne va pas, vous avez absolument besoin d'un routeur typesafe :)

Le système de types dans TypeScript est très puissant. Je veux dire, vous pouvez créer un moteur d'échecs , un jeu d'aventure ou même une base de données SQL en utilisant la programmation au niveau du type.


Vous êtes déjà familier avec la « programmation au niveau des valeurs » dans laquelle vous manipulez des valeurs, par exemple en concaténant deux chaînes :

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


Mais vous pouvez aussi le faire avec des types !

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


Oui, ce n'est pas aussi puissant que vos fonctions ordinaires et son aspect est différent. Mais cela vous permet de faire des choses plutôt sympas et utiles.


Nous utiliserons la programmation au niveau du type pour extraire les paramètres des routes brutes et créer de nouveaux types capables de vérifier que le développeur n'essaie pas de transmettre des valeurs incorrectes à Link ou à la fonction qui construit l'URL.


La programmation au niveau du type avec TS pourrait rapidement devenir un gâchis illisible. Heureusement pour nous, il existe déjà plusieurs projets qui cachent toute cette complexité et nous permettent d'écrire du code propre comme celui-ci :


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


Plutôt sympa, hein ? Soit dit en passant, c'est tout le code dont vous avez besoin pour analyser les paramètres de la route brute. Dans ce projet, nous utiliserons la bibliothèque hotscript - cela nous aidera à réduire la complexité et la quantité de code au niveau du type.


Mais ce n'est pas obligatoire : si vous vous sentez aventureux, vous pouvez essayer de mettre en œuvre tous ces types vous-même. Vous pouvez trouver de l'inspiration dans le routeur Chicane , qui implémente des fonctionnalités similaires sans utiliser de bibliothèques de types tierces.


Si vous souhaitez suivre, je vous recommande de créer un nouveau projet React en utilisant votre démarreur préféré (j'utilise Vite ) et de commencer à coder là-bas. De cette façon, vous pourrez tester votre routeur immédiatement.


Veuillez noter que les frameworks comme Next.js fournissent leur propre routage qui peut interférer avec ce projet, et utilisent plutôt React « vanille ». Si vous rencontrez des difficultés, vous pouvez trouver le code complet ici .


Commencez par installer des packages tiers : hotscript pour les utilitaires au niveau du type et regexparam pour analyser les paramètres de l'URL/de la route brute.


 npm install hotscript regexparam


La première brique de construction de nos types est la route brute. L'itinéraire brut doit commencer par / ; comment coderiez-vous cela dans TS ? Comme ça:

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


Facile, non ? Mais defineRoutes n'accepte pas une seule route brute, il accepte le mappage, éventuellement imbriqué, alors codons cela. Vous pourriez être tenté d'écrire quelque chose comme ceci :

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


Cela fonctionnera. Cependant, ce type peut être infiniment profond, et TS aura du mal à le calculer dans certains cas. Pour faciliter la vie de TS, nous limiterons le niveau d'imbrication autorisé. 20 niveaux d'imbrication devraient suffire pour toutes les applications et TS peut gérer cela facilement.


Mais comment limiter la profondeur des types récursifs ? J'ai appris cette astuce grâce à cette réponse SO ; voici une version modifiée pour nos besoins :

 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]> };


C'est notre premier type complexe, alors laissez-moi vous l'expliquer. Nous avons ici deux types : RecursiveMap fonctionne comme un point d'entrée et appelle RecursiveMap_ en lui passant un paramètre de tuple supplémentaire. Ce tuple est utilisé pour suivre la profondeur du mappage, à chaque appel, nous ajoutons un élément à ce tableau.


Et nous continuons à l'appeler jusqu'à ce que la longueur de ce tuple soit égale à MaxDepth . En TS, lorsque extends est utilisé avec des valeurs spécifiques , également appelées littéraux (par exemple, spécifiquement 42 , pas number ), cela signifie « égal ».


Et comme MaxDepth et Stack["length"] sont des nombres spécifiques, ce code peut être lu comme MaxDepth === Stack["length"] . Vous verrez cette construction être beaucoup utilisée.


Pourquoi utiliser un tuple au lieu de simplement ajouter des nombres ? Eh bien, ce n'est pas si simple d'ajouter deux nombres en TypeScript ! Il existe toute une bibliothèque pour cela, et Hotscript peut également ajouter des nombres, mais cela nécessite beaucoup de code (même si vous ne le voyez pas), ce qui peut ralentir votre serveur TS et votre éditeur de code s'il est utilisé de manière excessive.


Donc, ma règle générale est d’éviter autant que possible les types complexes.


Avec ce type d'utilitaire, nous pouvons définir notre mappage aussi simplement que :

 export type RawRoutesMap = RecursiveMap<RawRoute, 20>;


C'est tout pour les types d'itinéraires bruts. Le prochain dans la file d'attente est l'itinéraire analysé. La route analysée n'est qu'un objet JavaScript avec quelques champs supplémentaires et une fonction ; voici à quoi ça ressemble :

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


Commençons par déballer cela à partir du champ keys . Il s'agit simplement d'un ensemble de paramètres requis pour cet itinéraire. Voici comment procéder :

 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 ] >;


Dans Hotscript, il existe deux manières d'appeler une fonction : Call ou Pipe . Call est utile lorsque vous devez appeler une seule fonction, mais dans notre cas, nous en avons 4 ! Pipe accepte les entrées et, eh bien, les dirige vers la première fonction d'un tuple fourni.


La valeur renvoyée est transmise en entrée à la deuxième fonction et ainsi de suite. Dans notre cas, si nous avions, par exemple, la route brute /user/:userId/posts/:postId , elle serait transformée comme ceci :

 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" ] >;


Voir? C'est la magie de la programmation au niveau du type ! Maintenant, abordons cette fonction build . Il accepte les paramètres de route (comme userId et postId ) et les paramètres/hachage de recherche facultatifs, et les combine dans un chemin. Jetez un œil à une implémentation 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];


Les paramètres de fonction sont définis sous forme de tableau (qui est ensuite réparti dans la définition du

build function), où le premier élément est RouteParamsMap et le second est facultatif SearchAndHashPathConstructorParams . Qu'en est-il du retour de la valeur de build ? Nous avons déjà établi son chemin, mais comment le décrire avec TypeScript ?


Bon, celui-ci est assez similaire à RouteParam , mais demande un peu plus de gymnastique type !

 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}`;


Ce que nous faisons ici, c'est diviser notre itinéraire en segments, cartographier chaque segment et appeler notre fonction personnalisée ReplaceParam sur chacun. Il vérifie si le segment actuel est un paramètre et le remplace par string ou renvoie le segment tel quel. La « fonction » ReplaceParam peut sembler un peu bizarre, mais c'est ainsi que vous définissez des fonctions personnalisées avec Hotscript.


Nous indiquons explicitement que le chemin consiste soit en un chemin simple, soit en un chemin suivi d'un point d'interrogation (cela couvre les URL avec des paramètres de recherche et un hachage), ou d'un symbole de hachage (cela couvre les URL sans paramètres de recherche mais avec un hachage).


Nous aurons également besoin d'un type pour décrire la route correspondante, c'est-à-dire une route analysée avec des paramètres capturés à partir de l'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>, }


Le dernier type est ParsedRoutesMap ; c'est similaire à RawRoutesMap , mais pour les itinéraires analysés.

 // 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; };


Et sur ce point, nous terminons par les types. Il y en aura quelques autres, mais ils sont plus simples, et nous les aborderons au fur et à mesure de la mise en œuvre. Si la programmation au niveau du type est quelque chose que vous aimeriez essayer davantage, vous pouvez consulter Type-level Typescript pour en savoir plus et essayer de résoudre les défis de type (ils ont également une bonne liste de ressources ).

Analyseur d'itinéraire

Enfin, nous revenons au codage habituel au niveau des valeurs. Lançons le bal en implémentant 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); };


Rien de complexe ici ; approfondissons la fonction 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 est également très simple, quoique sensiblement plus long. Pour analyser l'itinéraire et extraire les paramètres, nous utilisons la bibliothèque regexparam . Il nous permet d'obtenir un tableau de paramètres requis pour la route et génère une expression régulière que nous utiliserons plus tard pour faire correspondre l'URL avec la route.


Nous stockons ces informations avec la route brute d'origine utilisée pour construire cet objet et le niveau d'ambiguïté (qui n'est qu'un certain nombre de paramètres dans la route).

Emballage d'historique

Chaque routeur doit stocker son état quelque part. Dans le cas des applications dans le navigateur, cela se résume en réalité à 4 options : en mémoire (soit dans une variable d'état à l'intérieur du composant racine, soit dans une variable en dehors de l'arborescence des composants), API d'historique ou partie de hachage de l'URL.


Le routage en mémoire peut être votre choix si vous ne souhaitez pas du tout montrer à l'utilisateur que vous avez des itinéraires, par exemple si vous codez un jeu dans React. Stocker l'itinéraire dans un hachage peut être pratique lorsque votre application React ne représente qu'une page dans une application plus grande et que vous ne pouvez pas simplement modifier l'URL comme vous le souhaitez.


Mais dans la plupart des cas, l’utilisation de l’API History sera la meilleure option. Il est compatible avec SSR (les autres options ne le sont pas), suit les modèles de comportement auxquels l'utilisateur est habitué et semble tout simplement le plus propre. Dans ce projet, nous l'utiliserons également. Il présente cependant un défaut notable : il est pour la plupart inutilisable sans wrappers supplémentaires.


Avec History AP, vous pouvez vous abonner à l'événement popstate et le navigateur vous informera lorsque l'URL change. Mais seulement si la modification est initiée par l'utilisateur en cliquant par exemple sur le bouton retour. Si une modification d’URL est initiée à partir du code, vous devez en assurer le suivi vous-même.


La plupart des routeurs que j'ai étudiés utilisent leur propre wrapper : le routeur React et la chicane utilisent le package History NPM, le routeur TanStack a sa propre implémentation et Wouter n'a pas de wrapper à part entière mais doit quand même patcher l'historique .


Alors, implémentons notre propre wrapper.

 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... */; };


Nous utiliserons deux types, HistoryLocation et NavigationBlocker . Le premier est une version un peu limitée du type Location intégré (c'est le type de window.location ), et le second sera abordé une fois que nous arriverons au blocage de la navigation. Tout le code supplémentaire de ce chapitre ira dans la fonction createHistory .


Commençons par implémenter un abonnement aux modifications de l'historique. Nous utiliserons des fonctions de style React pour vous abonner dans ce projet : vous appelez subscribe en passant un rappel, et cela renvoie une autre fonction que vous devez appeler lorsque vous souhaitez vous désinscrire.

 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); }; };


L'étape suivante consiste à réagir aux changements d'emplacement, y compris aux modifications apportées par programme. Comment feriez-vous? Avec des patchs de singe , bien sûr. Cela peut paraître un peu sale (et c’est vraiment le cas), mais nous n’avons malheureusement pas de meilleures options.

 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);


Et la dernière pièce manquante majeure dans notre implémentation de l'historique est le blocage de la navigation : une fonctionnalité qui vous permet d'intercepter la demande de navigation et de l'annuler sous condition. Un exemple classique de blocage de la navigation consisterait à empêcher l'utilisateur de perdre sa progression dans un formulaire.

 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 }); } } };


Dans notre implémentation, un bloqueur est une fonction qui renvoie un booléen indiquant si nous devons bloquer cette navigation. En ce qui concerne le blocage de la navigation, il existe deux types de navigation, et nous devrons les gérer différemment.


D'une part, il y a la navigation douce - lorsque l'utilisateur navigue d'une page de notre application à une autre page de notre application. Nous le contrôlons entièrement et pouvons ainsi le bloquer, afficher n'importe quelle interface utilisateur personnalisée (pour confirmer l'intention de l'utilisateur) ou effectuer des actions après avoir bloqué la navigation.


D'un autre côté, il y a une navigation difficile - lorsque l'utilisateur navigue vers un autre site ou ferme complètement l'onglet. Le navigateur ne peut pas permettre à JavaScript de décider si cette navigation doit être effectuée, car cela constituerait un problème de sécurité. Mais le navigateur permet à JavaScript d'indiquer si nous souhaitons afficher une boîte de dialogue de confirmation supplémentaire à l'utilisateur.


Lorsque vous bloquez la navigation logicielle, vous souhaiterez peut-être afficher une interface utilisateur supplémentaire (par exemple, une boîte de dialogue de confirmation personnalisée), mais en cas de navigation difficile, cela n'a pas vraiment de sens car l'utilisateur ne la verra que s'il décide de rester sur la page et , à ce stade, c'est inutile et déroutant.


Lorsque notre historique appelle la fonction de blocage de navigation, il fournira un booléen indiquant si nous effectuons une navigation douce.


Et avec tout ça, il suffit de retourner notre objet 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, };

Étape 1 : API impérative

Nous sommes enfin là. L'API impérative sera la base de tous les autres hooks et composants et permettra au développeur de créer des hooks personnalisés pour couvrir ses besoins. Tout d’abord, nous devons transformer notre carte d’itinéraires en un tableau plat. De cette façon, il sera beaucoup plus facile de parcourir tous les itinéraires, ce qui sera utile lorsque nous commencerons à travailler sur la partie de correspondance d'itinéraire.


Nous avons besoin à la fois d'un utilitaire de type (qui transformera ParsedRoutesMap en union de ParsedRoute ) et d'une fonction (qui transformera routesMap en un tableau de routes analysées). Commençons par taper :

 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>>;


Il peut sembler inutile de diviser cela en deux types, mais il y a une raison très importante à cela : si vous l'implémentez comme un type unique qui s'appelle lui-même, TS se plaindra que le type est excessivement profond et peut-être infini. Nous contournons donc ce problème en le divisant en deux types qui s'appellent mutuellement.


Pour une fonction au niveau de la valeur, nous aurons également besoin d'un garde de type pour vérifier si la valeur transmise est une route analysée.

 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>); }); };


Maintenant, commençons à implémenter notre routeur. Comme pour l'histoire, dans ce chapitre et dans les deux chapitres suivants, tout le code ira dans la fonction createRouter , sauf indication contraire.

 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); };


Tout d'abord, apprenons à notre routeur à faire correspondre l'emplacement actuel à l'un des itinéraires connus. Quelques utilitaires peuvent accéder à la portée globale ou séparer des fichiers, pas à l'intérieur de la fonction 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 { };


Et ce code va dans la fonction 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;


Ici, nous passons en revue tous les itinéraires connus et essayons de faire correspondre chacun d'entre eux à l'emplacement actuel. Si l'expression régulière de la route correspond à l'URL, nous obtenons les paramètres de route de l'URL, sinon nous obtenons null . Pour chaque itinéraire correspondant, nous créons un objet RouteWithParams et l'enregistrons dans un tableau. Maintenant, si nous avons 0 ou 1 routes correspondantes, tout est simple.


Cependant, si plusieurs itinéraires correspondent à l'emplacement actuel, nous devons décider lequel a la priorité la plus élevée. Pour résoudre ce problème, nous utilisons le champ ambiguousness . Comme vous vous en souvenez peut-être, cet itinéraire comporte un certain nombre de paramètres, et l'itinéraire présentant le moins ambiguousness est prioritaire.


Par exemple, si nous avions deux itinéraires /app/dashboard et /app/:section , l'emplacement http://example.com/app/dashboard correspondrait aux deux itinéraires. Mais il est assez évident que cette URL doit correspondre à /app/dashboard route, et non à /app/:section .


Cet algorithme n’est cependant pas à l’épreuve des balles. Par exemple, les routes /app/:user/settings/:section et /app/dashboard/:section/:region correspondront toutes deux à l'URL http://example.com/app/dashboard/settings/asia . Et comme ils ont le même niveau d’ambiguïté, notre routeur ne pourra pas décider lequel doit être prioritaire.


Maintenant, nous devons coller ce code ensemble pour réagir aux changements d'emplacement et mettre à jour la variable 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 } });


Désormais, notre routeur réagit aux changements d'emplacement et l'utilisateur peut toujours obtenir l'itinéraire actuel, ouais ! Mais ce n'est pas très utile sans la possibilité de s'abonner aux changements d'itinéraire, alors ajoutons cela. L'approche est très similaire à celle que nous avons utilisée dans le wrapper d'historique.

 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); } }); };


Pour effectuer la navigation, nous exposerons les fonctions navigate et navigateUnsafe , qui sont un simple wrapper autour history.push et history.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); };


Eh bien, voilà un vrai routeur ! Très simple, mais fonctionnel néanmoins. Nous avons encore quelques hooks et composants à implémenter, mais cela devient beaucoup plus facile à partir de là.

Étape 2 : Crochets

Pour les hooks, nous pouvons commencer par des hooks simples qui renvoient l’emplacement actuel et l’itinéraire actuel. Ils sont assez simples en eux-mêmes, mais useSyncExternalStore les transforme en une seule ligne. La façon dont nous avons conçu notre API impérative plus tôt nous a permis de réduire considérablement le code de ces hooks.

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


Lors du codage de composants censés être rendus uniquement sur un itinéraire/un ensemble d'itinéraires spécifique, vous pouvez utiliser useCurrentRoute pour obtenir l'itinéraire actuel, vérifier s'il correspond aux critères, puis utiliser ses paramètres (ou générer une erreur).


Mais c'est une tâche tellement courante que ce serait un crime de demander à nos utilisateurs d'écrire leur propre hook pour cela - notre routeur devrait le fournir immédiatement.

 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)` ); } }


Ce crochet a deux versions : stricte et décontractée. Si l'utilisateur passe true comme deuxième paramètre (ou ne transmet rien, car true est la valeur par défaut), ce hook générera une erreur si la route actuelle ne correspond pas à l'un des filtres fournis.


De cette façon, vous pouvez être sûr que le hook renverra un itinéraire correspondant ou ne reviendra pas du tout. Si le deuxième paramètre est faux, au lieu de lever une exception, le hook renverra simplement undefined si la route actuelle ne correspond pas aux filtres.


Pour décrire ce comportement à TypeScript, nous utilisons une fonctionnalité appelée surcharge de fonctions . Cela nous permet de définir plusieurs définitions de fonctions avec différents types, et TypeScript en choisira automatiquement une à utiliser lorsque l'utilisateur appelle une telle fonction.


En plus des paramètres de chemin, certaines données peuvent être transmises dans les paramètres de recherche, ajoutons donc un hook pour les analyser de la chaîne au mappage. Pour cela, nous utiliserons l'API du navigateur intégrée URLSearchParams .

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


Et le dernier hook de cette section est useNavigationBlocker qui est également assez simple : il acceptera simplement le rappel et encapsulera les appels à history.addBlocker dans un effet, pour rattacher le bloqueur s'il change.

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


Passons maintenant aux composants !

Étape 3 : Composants

Quel est le premier composant qui vient à l’esprit lorsque l’on évoque les bibliothèques de routage ? Je parie que c'est Route ou, du moins, quelque chose de similaire. Comme vous l'avez vu précédemment, nos hooks étaient très simples grâce à une API impérative bien conçue qui fait tout le gros du travail.


Il en va de même pour les composants ; ils peuvent être facilement implémentés par l’utilisateur en quelques lignes de code. Mais nous sommes une bibliothèque de routage sérieuse, incluons les piles dans la boîte :)

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


Eh bien, c'était facile ! Voulez-vous deviner comment nous implémentons le composant NotFound ? :)

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


Et le dernier composant requis pour notre routeur est Link , ce qui est un peu plus délicat. Vous ne pouvez pas simplement utiliser <a href="/app/dashboard" /> car il lancera toujours une navigation difficile et ne fournira aucune sécurité de type. Alors, abordons ces problèmes :

 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} /> };


De la même manière que pour la fonction navigate , le composant Link vérifie le type de l'URL que vous lui transmettez mais vous permet également de fournir une URL de chaîne arbitraire (comme trappe d'échappement ou pour les liens externes). Pour remplacer le comportement de <a> , nous attachons notre propre écouteur onClick , à l'intérieur duquel nous devrons appeler l' onClick d'origine (transmis à notre composant Link ).


Après cela, nous vérifions si la navigation n'a pas déjà été interrompue par le développeur (si c'était le cas, nous devrions ignorer l'événement). Si tout va bien, nous vérifions si le lien n'est pas externe et s'il doit être ouvert dans l'onglet courant. Et ce n'est qu'alors que nous pourrons annuler la navigation matérielle intégrée et appeler à la place notre propre fonction navigateUnsafe .


Et maintenant, il nous suffit de renvoyer toutes nos fonctions, hooks et composants (ainsi que quelques fonctions réexportées directement depuis l'historique) à partir de la fonction 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, };


Et avec cela, notre routeur est terminé. Maintenant, nous pouvons créer notre toute petite application pour présenter notre tout petit routeur que nous venons de créer ! À propos, vous pouvez trouver le code complet de ce routeur (y compris le code d'un exemple d'application) ici .

Assembler les pièces du puzzle

Alors, comment tout ce code s’articule-t-il ? Très bien, si je le dis moi-même ! Imaginez que vous créez une application de prise de notes. Tout d'abord, vous commenceriez par définir les routes et créer un routeur comme celui-ci :

 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;


Et puis, vous liez les itinéraires que vous avez définis avec les composants de la page :

 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> ) }


Lorsque vous êtes dans NoteDetailsPage , vous devez obtenir un ID de note à partir de l'URL, vous utilisez donc un hook 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>); };


Et lors de la création d'une nouvelle note, vous souhaiterez probablement confirmer l'intention de l'utilisateur s'il s'éloigne sans enregistrer la note :

 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> </>; };

Améliorations possibles

Bien que notre routeur effectue effectivement le routage, il ne fait pas le poids face aux solutions prêtes pour la production telles que le routeur TanStack, le routeur React ou le routeur Next.js. Je veux dire, c'est juste environ 500 lignes de code, ce n'est pas beaucoup. Mais que manque-t-il exactement ?


Tout d’abord, le rendu côté serveur. Aujourd'hui, toutes les applications n'ont pas besoin de SSR, mais toutes les bibliothèques de routage devraient le prendre en charge. L'ajout d'un rendu côté serveur dans une chaîne (pas de streaming SSR !) impliquera la création d'un history différent qui stockera l'emplacement actuel en mémoire (car il n'y a pas d'API d'historique sur le serveur) et le branchera à la fonction createRouter .


Je ne sais pas à quel point il sera difficile de mettre en œuvre le streaming SSR, mais je suppose que cela sera fortement lié au support de Suspense.


Deuxièmement, ce routeur ne s’intègre pas bien au rendu simultané. Principalement à cause de notre utilisation de useSyncExternalStore , car il n'est pas compatible avec les transitions non bloquantes . Cela fonctionne de cette façon pour éviter le déchirement : une situation dans laquelle une partie de l'interface utilisateur est rendue avec une valeur de magasin particulière, mais le reste de l'interface utilisateur est rendu avec une valeur différente.


Et pour cette raison, le routeur ne s'intègre pas bien à Suspense, car pour chaque mise à jour de localisation suspendue, une solution de repli sera affichée. À propos, j'ai couvert la concurrence dans React dans cet article , et dans celui-ci , je parle de Suspense, de récupération de données et use du hook.


Mais même avec ces inconvénients, j'espère que vous avez trouvé cet article intéressant et que vous avez construit votre propre routeur en cours de route :)