paint-brush
So schreiben Sie Ihren eigenen typsicheren React-Router in 500 Zeilenvon@olegwock
549 Lesungen
549 Lesungen

So schreiben Sie Ihren eigenen typsicheren React-Router in 500 Zeilen

von OlegWock39m2024/03/11
Read on Terminal Reader

Zu lang; Lesen

Begleiten Sie mich also in diesem Beitrag, in dem wir unseren eigenen typsicheren Router von Grund auf bauen, um diese Black Box zu öffnen und sein Innenleben zu verstehen. In diesem Artikel wird davon ausgegangen, dass Sie React bereits kennen und mit TypeScript vertraut sind.
featured image - So schreiben Sie Ihren eigenen typsicheren React-Router in 500 Zeilen
OlegWock HackerNoon profile picture
0-item

Als ich vor etwa sechs Jahren React lernte, war React-Router eine der ersten Bibliotheken von Drittanbietern, die ich in die Hand nahm. Ich meine, es macht Sinn: Routing ist einer der Schlüsselaspekte moderner Single Page Application. Ich hielt es für selbstverständlich.


Für mich war React-Router eine Zauberkiste, ich hatte keine Ahnung, wie es intern funktioniert. Als ich nach einiger Zeit natürlich herausfand, wie Routing im Allgemeinen funktioniert, war ich etwas traurig. Keine Magie mehr, nicht wahr :(


Aber ich bin froh, dass ich das Konzept der „magischen Black Boxes“ hinter mir gelassen habe. Ich denke, es ist eine wirklich schädliche Idee. Zu verstehen, dass jedes Stück Technik jemals von Ingenieuren gebaut wurde, genau wie Sie und ich, inspiriert mich sehr und ich hoffe, dass dies auch bei Ihnen der Fall ist.


Begleiten Sie mich also in diesem Beitrag, in dem wir unseren eigenen typsicheren Router von Grund auf bauen, um diese Black Box zu öffnen und sein Innenleben zu verstehen. In diesem Artikel wird davon ausgegangen, dass Sie React bereits kennen und mit TypeScript vertraut sind.

Funktionen und Einschränkungen

Lassen Sie mich erläutern, welche Funktionen unseres Routers in diesem Artikel behandelt werden.


Unser Killerfeature wird die Typsicherheit sein. Das bedeutet, dass Sie Ihre Routen im Voraus definieren müssen und TypeScript prüft, ob Sie keinen Unsinn anstelle der URL übergeben oder versuchen, Parameter abzurufen, die in der aktuellen Route fehlen. Dies erfordert einige Gymnastikübungen, aber keine Sorge, ich führe Sie durch.


Darüber hinaus unterstützt unser Router alles, was Sie von einem durchschnittlichen Router erwarten: Navigation zu URLs, Routenabgleich, Routenparameteranalyse, Vorwärts-/Rückwärtsnavigation und Navigationsblockierung.


Nun zu den Einschränkungen. Unser Router funktioniert nur im Browser (sorry React Native!) und unterstützt SRR nicht. Die SSR-Unterstützung sollte relativ einfach sein, aber dieser Beitrag ist bereits umfangreich, daher werde ich nicht darauf eingehen.

Terminologie

Nachdem wir nun eine Vorstellung davon haben, was wir machen möchten, müssen wir über Struktur und Terminologie sprechen. Es wird eine ganze Reihe ähnlicher Konzepte geben, daher ist es wichtig, sie im Voraus zu definieren.


In unserer Bibliothek gibt es zwei Arten von Routen: Rohrouten und analysierte Routen. Raw Route ist nur eine Zeichenfolge, die wie folgt aussieht /user/:id/info oder /login ; Es ist eine Vorlage für URLs. Die Rohroute kann Parameter enthalten, bei denen es sich um Abschnitte handelt, die mit einem Doppelpunkt beginnen, z. B. :id .


Dieses Format ist für Entwickler einfach zu verwenden, für ein Programm jedoch nicht so sehr; Wir werden diese Routen in ein maschinenfreundlicheres Format umwandeln und es als geparste Route bezeichnen.


Aber Benutzer öffnen keine Routen, sondern URLs. Und die URL ist eine Kennung für die Ressource (in unserem Fall die Seite innerhalb der App); es könnte so aussehen: http://example.app/user/42/info?anonymous=1#bio . Unser Router kümmert sich hauptsächlich um den zweiten Teil der URL ( /user/42/info?anonymous=1#bio ), den wir path nennen.


Der Pfad besteht aus Pfadname ( /user/42/info ), Suchparametern ( ?anonymous=1 ) und Hash ( #bio ). Das Objekt, das diese Komponenten als separate Felder speichert, heißt location .

Allgemeiner Überblick über die API

Beim Erstellen von React-Bibliotheken gehe ich gerne in drei Schritten vor. Zunächst einmal die zwingende API. Seine Funktionen können von jedem Ort aus aufgerufen werden und sind normalerweise überhaupt nicht an React gebunden. Im Falle eines Routers sind das Funktionen wie navigate , getLocation oder subscribe .


Basierend auf diesen Funktionen werden dann Hooks wie useLocation oder useCurrentRoute erstellt. Und dann werden diese Funktionen und Haken als Basis für den Bau von Komponenten verwendet. Das funktioniert meiner Erfahrung nach außerordentlich gut und ermöglicht die Erstellung einer leicht erweiterbaren und vielseitigen Bibliothek.


Die API des Routers beginnt mit der Funktion defineRoutes . Der Benutzer soll eine Karte aller Rohrouten an die Funktion übergeben, die die Routen analysiert und eine Karte mit derselben Form zurückgibt. Alle entwicklerorientierten APIs, wie URL-Generierung oder Routenabgleich, akzeptieren geparste Routen und keine Rohrouten.


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


Der nächste Schritt besteht darin, analysierte Routen an die Funktion createRouter zu übergeben. Das ist das Fleisch unseres Routers. Diese Funktion erstellt alle Funktionen, Hooks und Komponenten. Das mag ungewöhnlich aussehen, aber eine solche Struktur ermöglicht es uns, die Typen akzeptierter Argumente und Requisiten an einen bestimmten Satz von Routen anzupassen, die in routes definiert sind, und so die Typsicherheit zu gewährleisten (und DX zu verbessern).


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


createRouter gibt Funktionen zurück, die überall in Ihrer App verwendet werden können (imperative API), Hooks, die es Ihren Komponenten ermöglichen, auf Standortänderungen zu reagieren, und drei Komponenten: Link , Route und NotFound . Dies reicht aus, um die meisten Anwendungsfälle abzudecken, und Sie können Ihre eigenen Komponenten basierend auf diesen APIs erstellen.

Programmierung auf Typebene für Spaß und Gewinn

Wir beginnen damit, dass wir uns mit dem Typsicherheitsteil unseres Pitch befassen. Wie ich bereits erwähnt habe, warnt Sie TypeScript bei einem typsicheren Router im Voraus vor einer Situation wie dieser:

 <Link href="/logim" />


Oder so:

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


Und wenn Sie nicht sofort erkennen können, was daran falsch ist, brauchen Sie auf jeden Fall einen typsicheren Router :)

Das Typsystem in TypeScript ist sehr leistungsfähig. Ich meine, Sie können mithilfe der Programmierung auf Typebene eine Schach-Engine , ein Abenteuerspiel oder sogar eine SQL-Datenbank erstellen.


Sie sind bereits mit der „Programmierung auf Werteebene“ vertraut, bei der Sie Werte manipulieren, z. B. durch die Verkettung zweier Zeichenfolgen:

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


Aber das geht auch mit Typen!

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


Ja, es ist nicht so leistungsstark wie Ihre normalen Funktionen und sieht anders aus. Aber es ermöglicht Ihnen, einige ziemlich coole und nützliche Dinge zu tun.


Wir verwenden die Programmierung auf Typebene, um Parameter aus Rohrouten zu extrahieren und neue Typen zu erstellen, die überprüfen können, dass der Entwickler nicht versucht, falsche Werte an Link oder an die Funktion zu übergeben, die die URL erstellt.


Die Programmierung auf Typebene mit TS kann schnell zu einem unleserlichen Durcheinander werden. Zum Glück gibt es bereits mehrere Projekte, die diese Komplexität verbergen und es uns ermöglichen, sauberen Code wie diesen zu schreiben:


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


Ziemlich ordentlich, oder? Das ist übrigens der gesamte Code, den Sie zum Parsen von Parametern aus der Rohroute benötigen. In diesem Projekt verwenden wir die Hotscript- Bibliothek – sie wird uns helfen, die Komplexität und die Menge an Code auf Typebene zu reduzieren.


Dies ist jedoch nicht erforderlich: Wenn Sie abenteuerlustig sind, können Sie versuchen, alle diese Arten selbst umzusetzen. Inspiration finden Sie im Chicane-Router , der ähnliche Funktionen implementiert, ohne Typbibliotheken von Drittanbietern zu verwenden.


Wenn Sie mitmachen möchten, empfehle ich Ihnen, mit Ihrem bevorzugten Starter (ich verwende Vite ) ein neues React-Projekt zu erstellen und dort mit dem Codieren zu beginnen. Auf diese Weise können Sie Ihren Router sofort testen.


Bitte beachten Sie, dass Frameworks wie Next.js ihr eigenes Routing bereitstellen, das dieses Projekt beeinträchtigen kann, und verwenden Sie stattdessen „Vanilla“ React. Sollten Sie Schwierigkeiten haben, finden Sie den vollständigen Code hier .


Beginnen Sie mit der Installation von Paketen von Drittanbietern: Hotscript für Dienstprogramme auf Typebene und Regexparam zum Parsen von Parametern aus URL/Rohroute.


 npm install hotscript regexparam


Der erste Baustein unserer Art ist Rohroute. Die Rohroute sollte mit / beginnen; Wie würdest du das in TS codieren? So was:

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


Einfach richtig? Aber defineRoutes akzeptiert keine einzelne Rohroute, sondern eine Zuordnung, möglicherweise verschachtelt, also programmieren wir das. Sie könnten versucht sein, so etwas zu schreiben:

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


Das wird funktionieren. Allerdings kann dieser Typ unendlich tief sein, und TS wird es in manchen Fällen schwer haben, ihn zu berechnen. Um TS das Leben zu erleichtern, begrenzen wir den Grad der zulässigen Verschachtelung. 20 Verschachtelungsebenen sollten für alle Apps ausreichen und TS kommt damit problemlos zurecht.


Aber wie begrenzt man die Tiefe rekursiver Typen? Ich habe diesen Trick aus dieser SO-Antwort gelernt; Hier ist eine für unsere Anforderungen modifizierte Version:

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


Dies ist unser erster komplexer Typ, also lassen Sie es mich erklären. Wir haben hier zwei Typen: RecursiveMap fungiert als Einstiegspunkt und ruft RecursiveMap_ auf und übergibt ihm einen zusätzlichen Tupelparameter. Dieses Tupel wird verwendet, um die Tiefe der Zuordnung zu verfolgen. Mit jedem Aufruf fügen wir diesem Array ein Element hinzu.


Und wir rufen es so lange auf, bis die Länge dieses Tupels gleich MaxDepth ist. Wenn extends in TS mit bestimmten Werten verwendet wird, die auch Literale genannt werden (z. B. speziell 42 , nicht number ), bedeutet dies „gleich“.


Und da sowohl MaxDepth als auch Stack["length"] spezifische Zahlen sind, kann dieser Code als MaxDepth === Stack["length"] gelesen werden. Sie werden sehen, dass diese Konstruktion häufig genutzt wird.


Warum Tupel verwenden, anstatt nur Zahlen hinzuzufügen? Nun, es ist gar nicht so einfach, in TypeScript zwei Zahlen zu addieren! Dafür gibt es eine ganze Bibliothek , und Hotscript kann auch Zahlen hinzufügen, aber es erfordert viel Code (auch wenn Sie ihn nicht sehen), der bei übermäßiger Verwendung Ihren TS-Server und Code-Editor verlangsamen kann.


Meine Faustregel lautet daher, komplexe Typen so weit wie möglich zu vermeiden.


Mit diesem Dienstprogrammtyp können wir unsere Zuordnung so einfach definieren:

 export type RawRoutesMap = RecursiveMap<RawRoute, 20>;


Das ist alles für Rohroutentypen. Als nächstes in der Warteschlange steht die analysierte Route. Die geparste Route ist lediglich ein JavaScript-Objekt mit einigen zusätzlichen Feldern und einer Funktion. So sieht es aus:

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


Beginnen wir mit dem Auspacken aus dem keys . Es handelt sich lediglich um eine Reihe von Parametern, die für diese Route erforderlich sind. So wird es gemacht:

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


In Hotscript gibt es zwei Möglichkeiten, eine Funktion aufzurufen: Call oder Pipe . Call ist nützlich, wenn Sie eine einzelne Funktion aufrufen müssen, aber in unserem Fall haben wir vier davon! Pipe akzeptiert Eingaben und leitet sie an die erste Funktion eines bereitgestellten Tupels weiter.


Der zurückgegebene Wert wird als Eingabe an die zweite Funktion usw. übergeben. Wenn wir in unserem Fall beispielsweise die Rohroute /user/:userId/posts/:postId hätten, würde diese wie folgt umgewandelt werden:

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


Sehen? Das ist die Magie der Programmierung auf Typebene! Kommen wir nun zur build Funktion. Es akzeptiert Routenparameter (wie userId und postId ) und optionale Suchparameter/Hash und kombiniert sie zu einem Pfad. Schauen Sie sich eine PathConstructorParams Implementierung an:

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


Funktionsparameter werden als Array definiert (das später in der Definition des Arrays verteilt wird).

build Funktion), wobei das erste Element RouteParamsMap und das zweite optionale SearchAndHashPathConstructorParams ist. Wie wäre es mit der Rückgabe des Werts von build ? Wir haben seinen Weg bereits festgelegt, aber wie beschreibt man ihn mit TypeScript?


Nun, dieses ist RouteParam ziemlich ähnlich, erfordert aber etwas mehr Typgymnastik!

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


Was wir hier tun, ist, unsere Route in Segmente aufzuteilen, jedes Segment zuzuordnen und für jedes Segment unsere benutzerdefinierte Funktion ReplaceParam aufzurufen. Es prüft, ob das aktuelle Segment ein Parameter ist und ersetzt ihn durch string oder gibt das Segment unverändert zurück. Die Funktion ReplaceParam “ sieht vielleicht etwas seltsam aus, aber so definieren Sie benutzerdefinierte Funktionen mit Hotscript.


Wir geben ausdrücklich an, dass der Pfad entweder nur aus einem Pfad, einem Pfad gefolgt von einem Fragezeichen (dies gilt für URLs mit Suchparametern und einem Hash) oder einem Hash-Symbol (dies gilt für URLs ohne Suchparameter, aber mit einem Hash) besteht.


Wir benötigen außerdem einen Typ, um die übereinstimmende Route zu beschreiben, d. h. eine analysierte Route mit von der URL erfassten Parametern:

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


Der letzte Typ ist ParsedRoutesMap ; Es ähnelt RawRoutesMap , aber für geparste Routen.

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


Und in diesem Sinne beenden wir mit den Typen. Es wird noch ein paar mehr geben, aber sie sind einfacher und wir werden sie im Laufe der Implementierung behandeln. Wenn Sie mehr über die Programmierung auf Typebene erfahren möchten, können Sie sich Type-Level Typescript ansehen, um mehr zu erfahren und zu versuchen, Typherausforderungen zu lösen (sie haben auch eine gute Liste mit Ressourcen ).

Routenparser

Endlich kehren wir zur regulären Codierung auf Werteebene zurück. Lassen Sie uns den Ball ins Rollen bringen, indem wir defineRoutes implementieren.

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


Hier ist nichts Komplexes; Lassen Sie uns tiefer in die parseRoute -Funktion eintauchen.

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


Auch parseRoute ist sehr einfach, wenn auch deutlich länger. Um Routen zu analysieren und Parameter zu extrahieren, verwenden wir die Bibliothek regexparam . Es ermöglicht uns, ein Array von für die Route erforderlichen Parametern abzurufen und einen regulären Ausdruck zu generieren, den wir später verwenden werden, um die URL mit der Route abzugleichen.


Wir speichern diese Informationen zusammen mit der ursprünglichen Rohroute, die zur Konstruktion dieses Objekts verwendet wurde, und dem Mehrdeutigkeitsgrad (bei dem es sich nur um eine Reihe von Parametern in der Route handelt).

Geschichtshülle

Jeder Router muss seinen Zustand irgendwo speichern. Bei Apps im Browser läuft das eigentlich auf vier Optionen hinaus: In-Memory (entweder in einer Statusvariablen innerhalb der Stammkomponente oder in einer Variablen außerhalb des Komponentenbaums), History API oder Hash-Teil der URL.


In-Memory-Routing könnte Ihre Wahl sein, wenn Sie dem Benutzer überhaupt nicht zeigen möchten, dass Sie über Routen verfügen, beispielsweise wenn Sie ein Spiel in React programmieren. Das Speichern der Route im Hash kann praktisch sein, wenn Ihre React-App nur eine Seite in einer größeren Anwendung ist und Sie die URL nicht einfach nach Ihren Wünschen ändern können.


In den meisten Fällen ist die Verwendung der History API jedoch die beste Option. Es ist mit SSR kompatibel (andere Optionen nicht), folgt Verhaltensmustern, an die der Benutzer gewöhnt ist, und sieht einfach am saubersten aus. In diesem Projekt werden wir es auch verwenden. Es hat jedoch einen bemerkenswerten Fehler: Ohne zusätzliche Verpackungen ist es größtenteils unbrauchbar.


Mit History AP können Sie das popstate Event abonnieren und der Browser benachrichtigt Sie, wenn sich die URL ändert. Allerdings nur, wenn die Änderung durch den Nutzer beispielsweise durch einen Klick auf den Zurück-Button initiiert wird. Wenn eine URL-Änderung durch Code initiiert wird, müssen Sie selbst den Überblick behalten.


Die meisten Router, die ich untersucht habe, verwenden ihren eigenen Wrapper: React-Router und Chicane verwenden das History- NPM-Paket, der TanStack-Router verfügt über eine eigene Implementierung und Wouter verfügt nicht über einen vollwertigen Wrapper, muss aber dennoch den History-Patch durchführen .


Also implementieren wir unseren eigenen 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... */; };


Es gibt zwei Typen, die wir verwenden werden: HistoryLocation und NavigationBlocker . Erstens handelt es sich um eine etwas eingeschränkte Version des integrierten Location (das ist der Typ von window.location ), und der zweite wird behandelt, sobald wir zur Navigationsblockierung kommen. Der gesamte weitere Code aus diesem Kapitel wird in die Funktion createHistory eingefügt.


Beginnen wir mit der Implementierung eines Abonnements für Verlaufsänderungen. In diesem Projekt verwenden wir zum Abonnieren Funktionen im React-Stil: Sie rufen subscribe auf, indem Sie einen Rückruf übergeben, und es gibt eine weitere Funktion zurück, die Sie aufrufen müssen, wenn Sie sich abmelden möchten.

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


Der nächste Schritt besteht darin, auf Standortänderungen zu reagieren, einschließlich programmgesteuert vorgenommener Änderungen. Wie würdest du es machen? Natürlich mit Monkey-Patching . Das sieht vielleicht etwas schmutzig aus (und das ist es auch), aber wir haben leider keine besseren Optionen.

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


Und das letzte große fehlende Teil in unserer Verlaufsimplementierung ist die Navigationsblockierung: eine Funktion, die es Ihnen ermöglicht, die Navigationsanfrage abzufangen und unter bestimmten Bedingungen abzubrechen. Ein klassisches Beispiel für eine Navigationsblockierung wäre, zu verhindern, dass der Benutzer seinen Fortschritt in einem Formular verliert.

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


In unserer Implementierung ist ein Blocker eine Funktion, die einen booleschen Wert zurückgibt, der angibt, ob wir diese Navigation blockieren müssen. Was die Navigationsblockierung betrifft, gibt es zwei Arten der Navigation, die wir unterschiedlich handhaben müssen.


Einerseits gibt es die Soft-Navigation – wenn der Benutzer von einer Seite in unserer App zu einer anderen Seite in unserer App navigiert. Wir haben die volle Kontrolle darüber und können es daher blockieren, jede benutzerdefinierte Benutzeroberfläche anzeigen (um die Absicht des Benutzers zu bestätigen) oder Aktionen ausführen, nachdem die Navigation blockiert wurde.


Andererseits gibt es eine harte Navigation – wenn der Benutzer zu einer anderen Website navigiert oder den Tab ganz schließt. Der Browser kann nicht zulassen, dass JavaScript entscheidet, ob diese Navigation durchgeführt werden soll, da dies ein Sicherheitsrisiko darstellt. Der Browser lässt jedoch zu, dass JavaScript angibt, ob wir dem Benutzer einen zusätzlichen Bestätigungsdialog anzeigen möchten.


Wenn Sie die Soft-Navigation blockieren, möchten Sie möglicherweise eine zusätzliche Benutzeroberfläche anzeigen (z. B. einen benutzerdefinierten Bestätigungsdialog), aber im Falle einer Hard-Navigation ist dies nicht wirklich sinnvoll, da der Benutzer sie nur sieht, wenn er sich entscheidet, auf der Seite zu bleiben und An diesem Punkt ist es nutzlos und verwirrend.


Wenn unser Verlauf die Navigationsblockerfunktion aufruft, stellt er einen booleschen Wert bereit, der angibt, ob wir eine Soft-Navigation durchführen.


Und bei alledem müssen wir nur noch unser History-Objekt zurückgeben:

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

Schritt 1: Imperative API

Wir sind endlich da. Die Imperative API wird die Basis für alle weiteren Hooks und Komponenten sein und es dem Entwickler ermöglichen, benutzerdefinierte Hooks zu erstellen, um seine Anforderungen abzudecken. Zunächst müssen wir unsere Routenkarte in ein flaches Array umwandeln. Auf diese Weise wird es viel einfacher, alle Routen zu durchlaufen, was sich als nützlich erweisen wird, wenn wir mit der Arbeit am Routenanpassungsteil beginnen.


Wir benötigen sowohl ein Typ-Dienstprogramm (das ParsedRoutesMap in eine Vereinigung von ParsedRoute umwandelt) als auch eine Funktion (die routesMap in ein Array geparster Routen umwandelt). Beginnen wir mit dem Typ:

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


Es mag unnötig erscheinen, dies in zwei Typen aufzuteilen, aber dafür gibt es einen sehr wichtigen Grund: Wenn Sie es als einen einzelnen Typ implementieren, der sich selbst aufruft, wird sich TS darüber beschweren, dass der Typ übermäßig tief und möglicherweise unendlich ist. Wir umgehen dieses Problem, indem wir es in zwei Typen aufteilen, die sich gegenseitig aufrufen.


Für eine Funktion auf Wertebene benötigen wir außerdem einen Typschutz, um zu prüfen, ob der übergebene Wert eine analysierte Route ist.

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


Beginnen wir nun mit der Implementierung unseres Routers. Wie bei der Geschichte wird auch in diesem und den nächsten beiden Kapiteln der gesamte Code in die Funktion createRouter eingefügt, sofern nicht anders angegeben.

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


Lassen Sie uns zunächst unserem Router beibringen, den aktuellen Standort einer der bekannten Routen zuzuordnen. Einige Dienstprogramme können in den globalen Bereich oder in separate Dateien eingebunden werden, nicht innerhalb der Funktion 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 { };


Und dieser Code geht in die Funktion createRouter ein.

 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;


Hier gehen wir alle bekannten Routen durch und versuchen, jede einzelne dem aktuellen Standort zuzuordnen. Wenn der reguläre Ausdruck der Route mit der URL übereinstimmt, erhalten wir Routenparameter von der URL, andernfalls erhalten wir null . Für jede übereinstimmende Route erstellen wir ein RouteWithParams Objekt und speichern es in einem Array. Wenn wir nun 0 oder 1 passende Routen haben, ist alles einfach.


Wenn jedoch mehr als eine Route mit dem aktuellen Standort übereinstimmt, müssen wir entscheiden, welche Route die höhere Priorität hat. Um dieses Problem zu lösen, verwenden wir das ambiguousness . Wie Sie sich vielleicht erinnern, handelt es sich bei dieser Route um eine Reihe von Parametern, und die Route mit der geringsten ambiguousness wird priorisiert.


Wenn wir beispielsweise zwei Routen /app/dashboard und /app/:section hätten, würde der Standort http://example.com/app/dashboard mit beiden Routen übereinstimmen. Aber es ist ziemlich offensichtlich, dass diese URL der Route /app/dashboard entsprechen sollte, nicht /app/:section .


Dieser Algorithmus ist jedoch nicht kugelsicher. Beispielsweise stimmen die Routen /app/:user/settings/:section und /app/dashboard/:section/:region beide mit der URL http://example.com/app/dashboard/settings/asia überein. Und da sie denselben Mehrdeutigkeitsgrad haben, kann unser Router nicht entscheiden, welcher von ihnen priorisiert werden soll.


Jetzt müssen wir diesen Code zusammenfügen, um auf Standortänderungen zu reagieren und die Variable currentRoute zu aktualisieren;

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


Jetzt reagiert unser Router auf Standortänderungen und der Benutzer kann immer die aktuelle Route abrufen, juhuu! Aber ohne die Möglichkeit, Routenänderungen zu abonnieren, ist es nicht sehr nützlich, also fügen wir das hinzu. Der Ansatz ist dem, den wir im History Wrapper verwendet haben, sehr ähnlich.

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


Um die Navigation durchzuführen, stellen wir die Funktionen navigate “ und navigateUnsafe bereit, die einen einfachen Wrapper um history.push “ und history.replace darstellen:

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


Nun, das ist ein echter Router! Sehr schlicht, aber trotzdem funktionsfähig. Wir müssen noch einige Hooks und Komponenten implementieren, aber von hier aus wird es viel einfacher.

Schritt 2: Haken

Bei Hooks können wir mit einfachen beginnen, die den aktuellen Standort und die aktuelle Route zurückgeben. Für sich genommen sind sie recht einfach, aber useSyncExternalStore verwandelt sie in einen Einzeiler. Die Art und Weise, wie wir unsere imperative API früher entworfen haben, ermöglichte es uns, den Code für diese Hooks drastisch zu reduzieren.

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


Beim Codieren von Komponenten, die nur auf einer bestimmten Route/Routengruppe gerendert werden sollen, können Sie useCurrentRoute verwenden, um die aktuelle Route abzurufen, zu prüfen, ob sie den Kriterien entspricht, und dann ihre Parameter zu verwenden (oder einen Fehler auszulösen).


Aber das ist eine so häufige Aufgabe, dass es ein Verbrechen wäre, unsere Benutzer dazu zu bringen, ihren eigenen Hook dafür zu schreiben – unser Router sollte dies sofort bereitstellen.

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


Dieser Haken hat zwei Versionen: streng und entspannt. Wenn der Benutzer true als zweiten Parameter übergibt (oder nichts übergibt, da true der Standardwert ist), gibt dieser Hook einen Fehler aus, wenn die aktuelle Route nicht mit einem der bereitgestellten Filter übereinstimmt.


Auf diese Weise können Sie sicher sein, dass der Hook eine passende Route zurückgibt oder überhaupt nicht. Wenn der zweite Parameter falsch ist, gibt der Hook statt einer Ausnahme einfach undefiniert zurück, wenn die aktuelle Route nicht mit den Filtern übereinstimmt.


Um dieses Verhalten gegenüber TypeScript zu beschreiben, verwenden wir eine Funktion namens „Function Overloading“ . Dadurch können wir mehrere Funktionsdefinitionen mit unterschiedlichen Typen definieren, und TypeScript wählt automatisch eine aus, die verwendet werden soll, wenn der Benutzer eine solche Funktion aufruft.


Zusätzlich zu den Pfadparametern werden möglicherweise einige Daten in Suchparametern übergeben. Fügen wir daher einen Hook hinzu, um sie vom String in die Zuordnung zu analysieren. Hierzu verwenden wir die integrierte Browser-API URLSearchParams .

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


Und der letzte Hook in diesem Abschnitt ist useNavigationBlocker , der ebenfalls recht einfach ist: Er akzeptiert lediglich Rückrufe und wickelt Aufrufe von history.addBlocker in einen Effekt ein, um den Blocker erneut anzuhängen, wenn er sich ändert.

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


Kommen wir nun zu den Komponenten!

Schritt 3: Komponenten

Welche Komponente fällt Ihnen als Erstes ein, wenn Sie von Routing-Bibliotheken sprechen? Ich wette, es ist Route oder zumindest etwas Ähnliches. Wie Sie bereits gesehen haben, waren unsere Hooks aufgrund einer gut gestalteten Imperativ-API, die die ganze schwere Arbeit erledigt, sehr einfach.


Das Gleiche gilt für Komponenten; Sie können vom Benutzer einfach in wenigen Codezeilen implementiert werden. Da wir aber eine ernsthafte Routing-Bibliothek sind, legen wir Batterien in die Box :)

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


Nun, das war einfach! Möchten Sie erraten, wie wir die NotFound Komponente implementieren? :) :)

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


Und die letzte für unseren Router erforderliche Komponente ist Link , was etwas kniffliger ist. Sie können nicht einfach <a href="/app/dashboard" /> verwenden, da dies immer eine harte Navigation initiiert und keine Typsicherheit bietet. Gehen wir also diese Probleme an:

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


Ähnlich wie bei der navigate überprüft die Link Komponente die URL, die Sie ihr übergeben, auf den Typ, ermöglicht Ihnen aber auch die Angabe einer beliebigen Zeichenfolgen-URL (als Escape-Schraffur oder für externe Links). Um das Verhalten von <a> zu überschreiben, fügen wir unseren eigenen onClick Listener hinzu, in dem wir den ursprünglichen onClick aufrufen müssen (der an unsere Link Komponente übergeben wird).


Danach prüfen wir, ob die Navigation nicht bereits vom Entwickler abgebrochen wurde (wenn ja, sollten wir das Ereignis ignorieren). Wenn alles in Ordnung ist, prüfen wir, ob der Link nicht extern ist und ob er im aktuellen Tab geöffnet sein sollte. Und erst dann können wir die integrierte harte Navigation abbrechen und stattdessen unsere eigene navigateUnsafe -Funktion aufrufen.


Und jetzt müssen wir nur noch alle unsere Funktionen, Hooks und Komponenten (zusammen mit einigen direkt aus dem Verlauf erneut exportierten Funktionen) von der Funktion createRouter zurückgeben.

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


Und damit ist unser Router fertig. Jetzt können wir unsere winzige App erstellen, um unseren winzigen Router zu präsentieren, den wir gerade hergestellt haben! Den vollständigen Code für diesen Router (inkl. Code für eine Beispiel-App) finden Sie übrigens hier .

Die Puzzleteile zusammenfügen

Wie hängt dieser ganze Code zusammen? Ganz ordentlich, wenn ich das so sagen darf! Stellen Sie sich vor, Sie erstellen eine Notizen-App. Zunächst würden Sie damit beginnen, die Routen zu definieren und einen Router wie diesen zu erstellen:

 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;


Und dann verknüpfen Sie die von Ihnen definierten Routen mit den Seitenkomponenten:

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


Wenn Sie sich in NoteDetailsPage befinden, müssen Sie eine Notiz-ID von der URL abrufen, also verwenden Sie einen useRoute Hook:

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


Und wenn Sie eine neue Notiz erstellen, möchten Sie wahrscheinlich die Absicht des Benutzers bestätigen, wenn er wegnavigiert, ohne die Notiz zu speichern:

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

Mögliche Verbesserungen

Unser Router leitet zwar tatsächlich, ist jedoch nicht mit produktionsbereiten Lösungen wie dem TanStack-Router, dem React-Router oder dem Next.js-Router vergleichbar. Ich meine, es sind nur etwa 500 Zeilen Code, das ist nicht viel. Aber was genau fehlt?


Zunächst einmal das serverseitige Rendering. Heutzutage benötigen möglicherweise nicht alle Apps SSR, aber von allen Routing-Bibliotheken wird erwartet, dass sie es unterstützen. Das Hinzufügen von serverseitigem Rendering zu einem String (kein Streaming von SSR!) erfordert das Erstellen eines anderen history , der den aktuellen Speicherort im Speicher speichert (da es auf dem Server keine Verlaufs-API gibt) und diesen in die Funktion createRouter einfügt.


Mir ist nicht bewusst, wie schwierig es sein wird, Streaming-SSR zu implementieren, aber ich gehe davon aus, dass dies stark mit der Unterstützung von Suspense zusammenhängt.


Zweitens lässt sich dieser Router nicht gut in das gleichzeitige Rendering integrieren. Hauptsächlich aufgrund unserer Verwendung von useSyncExternalStore , da es nicht mit nicht blockierenden Übergängen kompatibel ist. Dies funktioniert auf diese Weise, um Tearing zu vermeiden: eine Situation, in der ein Teil der Benutzeroberfläche mit einem bestimmten Speicherwert gerendert wurde, der Rest der Benutzeroberfläche jedoch mit einem anderen.


Aus diesem Grund lässt sich der Router nicht gut in Suspense integrieren, da für jede Standortaktualisierung, die angehalten wird, ein Fallback angezeigt wird. Übrigens habe ich in diesem Artikel die Parallelität in React behandelt, und in diesem spreche ich über Suspense, Datenabruf und use von Hooks.


Aber trotz dieser Nachteile hoffe ich, dass Sie diesen Artikel interessant fanden und nebenbei Ihren eigenen Router gebaut haben :)