paint-brush
Cómo escribir su propio enrutador Typesafe React en 500 líneaspor@olegwock
549 lecturas
549 lecturas

Cómo escribir su propio enrutador Typesafe React en 500 líneas

por OlegWock39m2024/03/11
Read on Terminal Reader

Demasiado Largo; Para Leer

Entonces, únete a mí en esta publicación donde construiremos nuestro propio enrutador con seguridad tipográfica desde cero para abrir esa caja negra y comprender su funcionamiento interno. Este artículo asume que ya conoces React y te sientes cómodo con TypeScript.
featured image - Cómo escribir su propio enrutador Typesafe React en 500 líneas
OlegWock HackerNoon profile picture
0-item

Cuando estaba aprendiendo React hace unos 6 años, reaccionar-router fue una de las primeras bibliotecas de terceros que adquirí. Quiero decir, tiene sentido: el enrutamiento es uno de los aspectos clave de las aplicaciones de página única modernas. Lo di por sentado.


Para mí, reaccionar-router era una caja mágica, no tenía idea de cómo funciona internamente. Entonces, cuando después de un tiempo descubrí naturalmente cómo funciona el enrutamiento en general, fue un poco triste. No más magia, eh :(


Pero me alegro de haber dejado atrás ese concepto de "cajas negras mágicas". Creo que es una idea realmente dañina. Comprender que cada pieza de tecnología ha sido construida por ingenieros, como tú y como yo, me inspira mucho y espero que haga lo mismo por ti.


Entonces, únete a mí en esta publicación donde construiremos nuestro propio enrutador con seguridad tipográfica desde cero para abrir esa caja negra y comprender su funcionamiento interno. Este artículo asume que ya conoces React y te sientes cómodo con TypeScript.

Características y limitaciones

Permítanme resumir qué características de nuestro enrutador se tratarán en este artículo.


Nuestra característica principal será la seguridad de tipos. Esto significa que deberá definir sus rutas con anticipación y TypeScript verificará que no pase ninguna tontería en lugar de la URL o intente obtener parámetros que faltan en la ruta actual. Esto requerirá algún tipo de gimnasia, pero no te preocupes, te guiaré.


Además de eso, nuestro enrutador admitirá todo lo que esperarías de un enrutador promedio: navegación a URL, coincidencia de rutas, análisis de parámetros de ruta, navegación hacia atrás/adelante y bloqueo de navegación.


Ahora, sobre las limitaciones. Nuestro enrutador funcionará solo en el navegador (¡lo siento, React Native!) y no admitirá SRR. El soporte de SSR debería ser relativamente fácil, pero esta publicación ya es enorme, por lo que no la cubriré.

Terminología

Ahora que tenemos una visión de lo que nos gustaría hacer, necesitamos hablar sobre estructura y terminología. Habrá muchos conceptos similares, por lo que definirlos de antemano es fundamental.


Habrá dos tipos de rutas en nuestra biblioteca: rutas sin procesar y rutas analizadas. La ruta sin formato es solo una cadena que se parece a /user/:id/info o /login ; es una plantilla para URL. La ruta sin formato puede contener parámetros, que son secciones que comienzan con dos puntos, como :id .


Este formato es fácil de usar para los desarrolladores, pero no tanto para un programa; Transformaremos esas rutas a un formato más amigable para las máquinas y las llamaremos ruta analizada .


Pero los usuarios no abren rutas, abren URL. Y la URL es un identificador del recurso (página dentro de la aplicación en nuestro caso); podría verse así http://example.app/user/42/info?anonymous=1#bio . Nuestro enrutador se preocupa principalmente por la segunda parte de la URL ( /user/42/info?anonymous=1#bio ), que llamaremos ruta .


La ruta consta de nombre de ruta ( /user/42/info ), parámetros de búsqueda ( ?anonymous=1 ) y hash ( #bio ). El objeto que almacena esos componentes como campos separados se llamará ubicación .

Descripción general de alto nivel de la API

Al crear bibliotecas de React, me gusta seguir tres pasos. En primer lugar, API imperativa. Sus funciones se pueden llamar desde cualquier lugar y, por lo general, no están vinculadas en absoluto a React. En el caso de un enrutador, serán funciones como navigate , getLocation o subscribe .


Luego, en base a esas funciones, se crean enlaces como useLocation o useCurrentRoute . Y luego esas funciones y ganchos se utilizan como base para los componentes de construcción. En mi experiencia, esto funciona excepcionalmente bien y permite crear una biblioteca versátil y fácilmente ampliable.


La API del enrutador comienza con la función defineRoutes . Se supone que el usuario debe pasar un mapa de todas las rutas sin procesar a la función, que analiza las rutas y devuelve un mapeo con la misma forma. Todas las API orientadas al desarrollador, como la generación de URL o la coincidencia de rutas, aceptarán rutas analizadas y no sin formato.


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


El siguiente paso es pasar rutas analizadas a la función createRouter . Esta es la carne de nuestro enrutador. Esta función creará todas las funciones, enlaces y componentes. Esto puede parecer inusual, pero dicha estructura nos permite adaptar los tipos de argumentos y accesorios aceptados a un conjunto específico de rutas definidas en routes , garantizando la seguridad de tipos (y mejorando DX).


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


createRouter devolverá funciones que se pueden usar en cualquier lugar de su aplicación (API imperativa), enlaces que permiten que sus componentes reaccionen a los cambios de ubicación y tres componentes: Link , Route y NotFound . Esto será suficiente para cubrir la mayoría de los casos de uso y podrá crear sus propios componentes basados en esas API.

Programación a nivel de tipo para diversión y ganancias

Comenzamos abordando la parte de seguridad tipográfica de nuestro discurso. Como mencioné antes, con un enrutador con seguridad de tipos, TypeScript le advertirá con anticipación sobre una situación como esta:

 <Link href="/logim" />


O así:

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


Y si no puedes ver de inmediato qué está mal con estos, definitivamente necesitas un enrutador con seguridad de tipos :)

El sistema de tipos en TypeScript es muy poderoso. Quiero decir, puedes crear un motor de ajedrez , un juego de aventuras o incluso una base de datos SQL usando programación de nivel de tipo.


Ya está familiarizado con la 'programación a nivel de valores' donde manipula valores, por ejemplo, concatenando dos cadenas:

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


¡Pero también puedes hacerlo con tipos!

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


Sí, no es tan potente como las funciones habituales y tiene un aspecto diferente. Pero te permite hacer algunas cosas interesantes y útiles.


Usaremos programación de nivel de tipo para extraer parámetros de rutas sin procesar y crear nuevos tipos que puedan verificar que el desarrollador no intente pasar valores incorrectos a Link o a la función que está construyendo la URL.


La programación a nivel de tipo con TS podría convertirse rápidamente en un desastre ilegible. Afortunadamente para nosotros, ya existen varios proyectos que ocultan toda esta complejidad y nos permiten escribir código limpio como este:


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


Bastante bonito, ¿eh? Eso, por cierto, es todo el código que necesita para analizar los parámetros de la ruta sin formato. En este proyecto, usaremos la biblioteca hotscript ; nos ayudará a reducir la complejidad y la cantidad de código de nivel de tipo.


Pero no es obligatorio: si te sientes aventurero, puedes intentar implementar todos estos tipos tú mismo. Puede encontrar algo de inspiración en el enrutador Chicane , que implementa funciones similares sin utilizar bibliotecas de tipos de terceros.


Si vas a seguirlo, te recomiendo que crees un nuevo proyecto de React usando tu iniciador favorito (yo uso Vite ) y comiences a codificar allí. De esta manera, podrás probar tu enrutador de inmediato.


Tenga en cuenta que los marcos como Next.js proporcionan su propio enrutamiento que puede interferir con este proyecto y, en su lugar, utilice React 'vainilla'. Si tiene alguna dificultad, puede encontrar el código completo aquí .


Comience instalando paquetes de terceros: hotscript para utilidades de nivel de tipo y regexparam para analizar parámetros desde URL/ruta sin formato.


 npm install hotscript regexparam


El primer ladrillo de construcción de nuestro tipo es el en bruto. La ruta sin formato debe comenzar con / ; ¿Cómo codificarías eso en TS? Como esto:

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


Fácil, ¿verdad? Pero defineRoutes no acepta una única ruta sin formato, acepta mapeo, posiblemente anidado, así que codifiquemos eso. Quizás tengas la tentación de escribir algo como esto:

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


Esto funcionará. Sin embargo, este tipo puede ser infinitamente profundo y TS tendrá dificultades para calcularlo en algunos casos. Para facilitarle la vida a TS, limitaremos el nivel de anidamiento permitido. 20 niveles de anidamiento deberían ser suficientes para todas las aplicaciones y TS puede manejarlo fácilmente.


Pero, ¿cómo se limita la profundidad de los tipos recursivos? Aprendí este truco de esta respuesta SO ; Aquí hay una versión modificada para nuestros requisitos:

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


Este es nuestro primer tipo complejo, así que déjame explicarlo. Aquí tenemos dos tipos: RecursiveMap funciona como un punto de entrada y llama RecursiveMap_ pasándole un parámetro de tupla adicional. Esta tupla se usa para rastrear la profundidad del mapeo; con cada llamada agregamos un elemento a esta matriz.


Y continuamos llamándolo hasta que la longitud de esta tupla sea igual a MaxDepth . En TS, cuando extends se usa con valores específicos , también llamados literales (por ejemplo, específicamente 42 , no number ), significa "igual".


Y dado que tanto MaxDepth como Stack["length"] son números específicos, este código se puede leer como MaxDepth === Stack["length"] . Verás que esta construcción se utiliza mucho.


¿Por qué utilizar tuplas en lugar de simplemente sumar números? Bueno, ¡no es tan fácil sumar dos números en TypeScript! Hay una biblioteca completa para eso, y Hotscript también puede agregar números, pero requiere mucho código (incluso si no lo ve), lo que puede ralentizar su servidor TS y su editor de código si se usa excesivamente.


Por lo tanto, mi regla general es evitar los tipos complejos tanto como sea razonablemente posible.


Con este tipo de utilidad, podemos definir nuestro mapeo tan simple como:

 export type RawRoutesMap = RecursiveMap<RawRoute, 20>;


Eso es todo para los tipos de rutas sin formato. El siguiente en la cola es la ruta analizada. La ruta analizada es sólo un objeto JavaScript con algunos campos adicionales y una función; así es como se ve:

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


Comencemos a descomprimir esto desde el campo keys . Es simplemente una serie de parámetros necesarios para esta ruta. Así es como se hace:

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


En Hotscript, hay dos formas de llamar a una función: Call o Pipe . Call es útil cuando necesitas llamar a una sola función, pero en nuestro caso, ¡tenemos 4 de ellas! Pipe acepta entradas y, bueno, las canaliza a la primera función de una tupla proporcionada.


El valor devuelto se pasa como entrada a la segunda función y así sucesivamente. En nuestro caso, si tuviéramos, por ejemplo, la ruta sin formato /user/:userId/posts/:postId , se transformaría así:

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


¿Ver? ¡Ésta es la magia de la programación a nivel de tipo! Ahora, abordemos esa función build . Acepta parámetros de ruta (como userId y postId ) y parámetros de búsqueda/hash opcionales, y los combina en una ruta. Eche un vistazo a una implementación 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];


Los parámetros de la función se definen como una matriz (que luego se distribuye en la definición de la función).

función build ), donde el primer elemento es RouteParamsMap y el segundo es SearchAndHashPathConstructorParams opcional. ¿Qué pasa con la devolución del valor de build ? Ya establecimos su camino, pero ¿cómo lo describe con TypeScript?


Bueno, este es bastante similar a RouteParam , ¡pero requiere un poco más de gimnasia!

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


Lo que hacemos aquí es dividir nuestra ruta en segmentos, mapear cada segmento y llamar a nuestra función personalizada ReplaceParam en cada uno. Comprueba si el segmento actual es un parámetro y lo reemplaza con string o devuelve el segmento tal como está. La 'función' ReplaceParam puede parecer un poco extraña, pero así es como se definen funciones personalizadas con Hotscript.


Indicamos explícitamente que la ruta consta solo de una ruta, una ruta seguida de un signo de interrogación (esto cubre las URL con parámetros de búsqueda y hash) o un símbolo de almohadilla (esto cubre las URL sin parámetros de búsqueda pero con hash).


También necesitaremos un tipo para describir la ruta coincidente, es decir, ruta analizada con parámetros capturados de la 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>, }


El último tipo es ParsedRoutesMap ; Es similar a RawRoutesMap , pero para rutas analizadas.

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


Y ya dicho, terminamos con los tipos. Habrá algunos más, pero son más simples y los cubriremos a medida que avancemos en la implementación. Si la programación a nivel de tipo es algo que le gustaría probar más, puede consultar Type-level Typescript para obtener más información e intentar resolver desafíos de tipo (también tienen una buena lista de recursos ).

Analizador de rutas

Finalmente, volvemos a la codificación de nivel de valor normal. Pongamos manos a la obra implementando 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); };


Nada complejo aquí; Profundicemos en la función 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 también es muy simple, aunque notablemente más largo. Para analizar la ruta y extraer parámetros, utilizamos la biblioteca regexparam . Nos permite obtener una serie de parámetros necesarios para la ruta y genera una expresión regular que luego usaremos para hacer coincidir la URL con la ruta.


Almacenamos esta información junto con la ruta original sin procesar utilizada para construir este objeto y el nivel de ambigüedad (que es solo una cantidad de parámetros en la ruta).

Envoltorio de historia

Cada enrutador tiene que almacenar su estado en algún lugar. En el caso de las aplicaciones en el navegador, eso realmente se reduce a 4 opciones: en memoria (ya sea en una variable de estado dentro del componente raíz o en una variable fuera del árbol de componentes), API de historial o parte hash de la URL.


El enrutamiento en memoria puede ser tu elección si no quieres mostrarle al usuario que tienes rutas, por ejemplo, si estás codificando un juego en React. Almacenar la ruta en hash puede ser útil cuando tu aplicación React es solo una página en una aplicación más grande y no puedes simplemente cambiar la URL como quieras.


Pero en la mayoría de los casos, utilizar History API será la mejor opción. Es compatible con SSR (otras opciones no lo son), sigue patrones de comportamiento a los que el usuario está acostumbrado y simplemente se ve más limpio. En este proyecto, también lo usaremos. Sin embargo, tiene un defecto notable: es prácticamente inutilizable sin envoltorios adicionales.


Con History AP, puede suscribirse al evento popstate y el navegador le informará cuando cambie la URL. Pero sólo si el cambio lo inicia el usuario, por ejemplo, haciendo clic en el botón Atrás. Si se inicia un cambio de URL desde el código, debe realizar un seguimiento usted mismo.


La mayoría de los enrutadores que estudié usan su propio contenedor: reaccionar-router y chicane usan el paquete NPM de historial , el enrutador TanStack tiene su propia implementación y wouter no tiene un contenedor completo, pero aún tiene que parchear el historial .


Entonces, implementemos nuestro propio contenedor.

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


Hay dos tipos que usaremos, HistoryLocation y NavigationBlocker . Primero, es una versión un poco limitada del tipo Location incorporado (ese es el tipo de window.location ), y el segundo se cubrirá una vez que lleguemos al bloqueo de navegación. Todo el código adicional de este capítulo irá dentro de la función createHistory .


Comencemos implementando una suscripción a los cambios del historial. Usaremos funciones de estilo React para suscribirte en este proyecto: llamas a subscribe pasando una devolución de llamada y devuelve otra función a la que debes llamar cuando quieras cancelar la suscripción.

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


El siguiente paso es reaccionar a los cambios de ubicación, incluidos los cambios realizados mediante programación. ¿Como lo harias? Con parches de mono , por supuesto. Eso puede parecer un poco sucio (y realmente lo es), pero desafortunadamente no tenemos mejores opciones.

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


Y la última pieza importante que falta en nuestra implementación histórica es el bloqueo de navegación: una característica que le permite interceptar la solicitud de navegación y cancelarla condicionalmente. Un ejemplo canónico de bloqueo de navegación sería evitar que el usuario pierda su progreso en un formulario.

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


En nuestra implementación, un bloqueador es una función que devuelve un valor booleano que indica si necesitamos bloquear esta navegación. En lo que respecta al bloqueo de navegación, existen dos tipos de navegación y tendremos que manejarlos de manera diferente.


Por un lado, está la navegación suave, cuando el usuario navega de una página de nuestra aplicación a otra página de nuestra aplicación. Lo controlamos completamente y, por lo tanto, podemos bloquearlo, mostrar cualquier interfaz de usuario personalizada (para confirmar la intención del usuario) o realizar acciones después de bloquear la navegación.


Por otro lado, existe la navegación difícil, cuando el usuario navega a otro sitio o cierra la pestaña por completo. El navegador no puede permitir que JavaScript decida si se debe realizar esta navegación, ya que sería un problema de seguridad. Pero el navegador permite que JavaScript indique si queremos mostrar un cuadro de diálogo de confirmación adicional al usuario.


Al bloquear la navegación suave, es posible que desee mostrar una interfaz de usuario adicional (por ejemplo, un cuadro de diálogo de confirmación personalizado), pero en el caso de una navegación difícil, en realidad no tiene sentido ya que el usuario solo la verá si decide permanecer en la página y , en ese punto, es inútil y confuso.


Cuando nuestro historial llama a la función de bloqueo de navegación, proporcionará un valor booleano que indica si estamos realizando una navegación suave.


Y con todo eso, sólo necesitamos devolver nuestro objeto de historial:

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

Paso 1: API imperativa

Finalmente estamos aquí. La API imperativa será la base para todos los enlaces y componentes adicionales y permitirá al desarrollador crear enlaces personalizados para cubrir sus necesidades. En primer lugar, necesitamos transformar nuestro mapa de rutas en una matriz plana. De esta manera, será mucho más fácil recorrer todas las rutas, lo que será útil cuando comencemos a trabajar en la parte de coincidencia de rutas.


Necesitamos tanto una utilidad de tipo (que transformará ParsedRoutesMap en una unión de ParsedRoute ) como una función (que transformará routesMap en una matriz de rutas analizadas). Comencemos con el tipo:

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


Puede parecer innecesario dividir esto en dos tipos, pero hay una razón muy importante para ello: si lo implementa como un tipo único que se llama a sí mismo, TS se quejará de que el tipo es excesivamente profundo y posiblemente infinito. Entonces, solucionamos esto dividiéndolo en dos tipos que se llaman entre sí.


Para una función de nivel de valor, también necesitaremos un tipo de protección para verificar si el valor pasado es una ruta analizada.

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


Ahora, comencemos a implementar nuestro enrutador. Al igual que con la historia, en este y los dos capítulos siguientes, todo el código irá a la función createRouter , a menos que se indique lo contrario.

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


En primer lugar, enseñemos a nuestro enrutador a hacer coincidir la ubicación actual con una de las rutas conocidas. Un par de utilidades pueden tener un alcance global o archivos separados, no dentro de la función 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 { };


Y este código va a la función 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;


Aquí repasamos todas las rutas conocidas e intentamos hacer coincidir cada una con la ubicación actual. Si la expresión regular de la ruta coincide con la URL, obtenemos los parámetros de ruta de la URL; de lo contrario, obtenemos null . Para cada ruta coincidente, creamos un objeto RouteWithParams y lo guardamos en una matriz. Ahora bien, si tenemos 0 o 1 rutas coincidentes, todo es sencillo.


Sin embargo, si más de una ruta coincide con la ubicación actual, tendremos que decidir cuál tiene mayor prioridad. Para solucionar esto, utilizamos el campo ambiguousness . Como recordarás, esta ruta tiene una serie de parámetros y se prioriza una ruta con la menor ambiguousness .


Por ejemplo, si tuviéramos dos rutas /app/dashboard y /app/:section , la ubicación http://example.com/app/dashboard coincidiría con ambas rutas. Pero es bastante obvio que esta URL debe corresponder a la ruta /app/dashboard , no /app/:section .


Sin embargo, este algoritmo no es a prueba de balas. Por ejemplo, las rutas /app/:user/settings/:section y /app/dashboard/:section/:region coincidirán con la URL http://example.com/app/dashboard/settings/asia . Y como tienen el mismo nivel de ambigüedad, nuestro router no podrá decidir cuál debe tener prioridad.


Ahora, necesitamos unir este código para reaccionar a los cambios de ubicación y actualizar 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 } });


Ahora, nuestro enrutador reacciona a los cambios de ubicación y el usuario siempre puede obtener la ruta actual, ¡sí! Pero no es muy útil sin la posibilidad de suscribirse a cambios de ruta, así que agreguemos eso. El enfoque es muy similar al que usamos en el contenedor histórico.

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


Para realizar la navegación, expondremos las funciones navigate y navigateUnsafe , que son un contenedor simple para history.push y 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); };


Bueno, ¡ese sí que es un enrutador real! Muy básico, pero funcionando de todos modos. Todavía tenemos algunos ganchos y componentes para implementar, pero a partir de aquí se vuelve mucho más fácil.

Paso 2: Ganchos

Para los ganchos, podemos comenzar con los simples que devuelven la ubicación actual y la ruta actual. Son bastante fáciles por sí solos, pero useSyncExternalStore los convierte en una sola línea. La forma en que diseñamos nuestra API imperativa anteriormente nos permitió reducir drásticamente el código para estos enlaces.

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


Al codificar componentes que se supone que deben representarse solo en una ruta/conjunto de rutas específico, puede usar useCurrentRoute para obtener la ruta actual, verificar si coincide con los criterios y luego usar sus parámetros (o generar un error).


Pero esta es una tarea tan común que sería un delito obligar a nuestros usuarios a escribir su propio enlace para ello; nuestro enrutador debería proporcionarlo de forma inmediata.

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


Este gancho tiene dos versiones: estricta y relajada. Si el usuario pasa true como segundo parámetro (o no pasa nada, ya que true es el valor predeterminado), este enlace generará un error si la ruta actual no coincide con uno de los filtros proporcionados.


De esta manera, puede estar seguro de que el gancho devolverá una ruta coincidente o no regresará en absoluto. Si el segundo parámetro es falso, en lugar de generar una excepción, el gancho simplemente devolverá un valor indefinido si la ruta actual no coincide con los filtros.


Para describir este comportamiento en TypeScript, utilizamos una característica llamada sobrecarga de funciones . Esto nos permite definir múltiples definiciones de funciones con diferentes tipos, y TypeScript elegirá automáticamente una para usar cuando el usuario llame a dicha función.


Además de los parámetros de ruta, es posible que se pasen algunos datos en los parámetros de búsqueda, así que agreguemos un enlace para analizarlos desde la cadena hasta el mapeo. Para esto, usaremos la API integrada del navegador URLSearchParams .

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


Y el último gancho en esta sección es useNavigationBlocker , que también es bastante simple: simplemente aceptará devolución de llamada y envolverá llamadas a history.addBlocker en un efecto, para volver a adjuntar el bloqueador si cambia.

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


¡Ahora, pasemos a los componentes!

Paso 3: Componentes

¿Cuál es el primer componente que me viene a la mente cuando se mencionan las bibliotecas de enrutamiento? Apuesto a que es Route o, al menos, algo parecido. Como vio anteriormente, nuestros ganchos eran muy simples debido a una API imperativa bien diseñada que hace todo el trabajo pesado.


Lo mismo ocurre con los componentes; El usuario puede implementarlos fácilmente en unas pocas líneas de código. Pero somos una biblioteca de enrutamiento seria, incluyamos baterías en la caja :)

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


Bueno, ¡eso fue fácil! ¿Quieres adivinar cómo implementamos el componente NotFound ? :)

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


Y el último componente necesario para nuestro enrutador es Link , que es un poco más complicado. No puedes simplemente usar <a href="/app/dashboard" /> ya que siempre iniciará una navegación difícil y no proporciona ninguna seguridad de tipos. Entonces, abordemos estos problemas:

 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 manera similar a la función navigate , el tipo de componente Link verifica la URL que le pasa pero también le permite proporcionar una URL de cadena arbitraria (como una trampilla de escape o para enlaces externos). Para anular el comportamiento de <a> , adjuntamos nuestro propio oyente onClick , dentro del cual necesitaremos llamar al onClick original (pasado a nuestro componente Link ).


Después de eso, verificamos si el desarrollador no canceló la navegación (si lo fue, debemos ignorar el evento). Si todo está bien, comprobamos si el enlace no es externo y si debe estar abierto en la pestaña actual. Y solo entonces podremos cancelar la navegación física incorporada y, en su lugar, llamar a nuestra propia función navigateUnsafe .


Y ahora, solo necesitamos devolver todas nuestras funciones, enlaces y componentes (junto con algunas funciones reexportadas directamente desde el historial) desde la función 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, };


Y con eso, nuestro enrutador estará listo. ¡Ahora podemos crear nuestra pequeña aplicación para mostrar nuestro pequeño enrutador que acabamos de crear! Por cierto, puedes encontrar el código completo para este enrutador (incluido el código de una aplicación de ejemplo) aquí .

Armar las piezas del rompecabezas

Entonces, ¿cómo se une todo este código? ¡Muy bien, si lo digo yo mismo! Imagina que estás creando una aplicación para tomar notas. En primer lugar, comenzarías definiendo las rutas y creando un enrutador como este:

 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;


Y luego, vincula las rutas que ha definido con los componentes de la página:

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


Cuando estás en NoteDetailsPage , necesitas obtener un ID de nota de la URL, por lo que usas un enlace 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>); };


Y al crear una nueva nota, probablemente quieras confirmar la intención del usuario si navega sin guardar la nota:

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

Posibles mejoras

Si bien nuestro enrutador sí enruta, no es rival para soluciones listas para producción como el enrutador TanStack, el enrutador reaccionar o el enrutador Next.js. Quiero decir, son solo ~500 líneas de código, eso no es mucho. ¿Pero qué es exactamente lo que falta?


En primer lugar, renderizado del lado del servidor. Hoy en día, es posible que no todas las aplicaciones necesiten SSR, pero se espera que todas las bibliotecas de enrutamiento lo admitan. Agregar representación del lado del servidor a una cadena (¡no transmitir SSR!) implicará crear un history diferente que almacenará la ubicación actual en la memoria (ya que no hay una API de historial en el servidor) y lo conectará a la función createRouter .


No sé lo difícil que será implementar el streaming SSR, pero supongo que estará fuertemente relacionado con el soporte de Suspense.


En segundo lugar, este enrutador no se integra bien con el renderizado concurrente. Principalmente debido a nuestro uso de useSyncExternalStore , ya que no es compatible con transiciones sin bloqueo . Funciona de esta manera para evitar el desgarro: una situación en la que una parte de la interfaz de usuario se representa con un valor de tienda particular, pero el resto de la interfaz de usuario se representa con uno diferente.


Y debido a esto, el enrutador no se integra bien con Suspense, ya que por cada actualización de ubicación que se suspenda, se mostrará un respaldo. Por cierto, cubrí la concurrencia en React en este artículo , y en este , hablo sobre Suspense, obtención de datos y use de gancho.


Pero incluso con estas desventajas, espero que este artículo te haya resultado interesante y hayas construido tu propio enrutador a lo largo del camino :)