Quando eu estava aprendendo React, há cerca de 6 anos, react-router foi uma das primeiras bibliotecas de terceiros que adquiri. Quero dizer, faz sentido: o roteamento é um dos aspectos principais dos aplicativos modernos de página única. Eu tomei isso como certo.
Para mim, o react-router era uma caixa mágica, não tinha ideia de como funcionava internamente. Então, quando depois de algum tempo eu naturalmente descobri como funciona o roteamento em geral, fiquei um pouco triste. Chega de magia, hein :(
Mas estou feliz por ter deixado para trás esse conceito de “caixas pretas mágicas”. Acho que é uma ideia muito prejudicial. Entender que cada peça de tecnologia foi construída por engenheiros, assim como você e eu, me inspira muito e espero que faça o mesmo por você.
Então, junte-se a mim neste post onde construiremos nosso próprio roteador typesafe do zero para abrir essa caixa preta e entender seu funcionamento interno. Este artigo pressupõe que você já conheça o React e esteja confortável com o TypeScript.
Deixe-me descrever quais recursos do nosso roteador serão abordados neste artigo.
Nosso recurso matador será a segurança de tipografia. Isso significa que você precisará definir suas rotas com antecedência e o TypeScript verificará se você não passou alguma bobagem em vez de URL ou tentará obter parâmetros que estão faltando na rota atual. Isso exigirá algum tipo de ginástica, mas não se preocupe, vou orientá-lo.
Além disso, nosso roteador suportará tudo o que você esperaria de um roteador comum: navegação para URLs, correspondência de rotas, análise de parâmetros de rota, navegação para trás/para frente e bloqueio de navegação.
Agora, sobre limitações. Nosso roteador funcionará apenas no navegador (desculpe, React Native!) E não suportará SRR. O suporte SSR deve ser relativamente fácil, mas este post já é enorme, então não vou abordá-lo.
Agora que temos uma visão do que gostaríamos de fazer, precisamos falar sobre estrutura e terminologia. Haverá muitos conceitos semelhantes, portanto defini-los com antecedência é crucial.
Haverá dois tipos de rotas em nossa biblioteca: rotas brutas e rotas analisadas. A rota bruta é apenas uma string que se parece com /user/:id/info
ou /login
; é um modelo para URLs. A rota bruta pode conter parâmetros, que são seções que começam com dois pontos, como :id
.
Este formato é fácil de usar para desenvolvedores, mas não tanto para um programa; transformaremos essas rotas em um formato mais amigável à máquina e chamaremos isso de rota analisada .
Mas os usuários não abrem rotas, eles abrem URLs. E URL é um identificador do recurso (página dentro do aplicativo no nosso caso); pode ser parecido com http://example.app/user/42/info?anonymous=1#bio
. Nosso roteador se preocupa principalmente com a segunda parte da URL ( /user/42/info?anonymous=1#bio
), que chamaremos de path .
O caminho consiste em nome do caminho ( /user/42/info
), parâmetros de pesquisa ( ?anonymous=1
) e hash ( #bio
). O objeto que armazena esses componentes como campos separados será chamado location .
Ao construir bibliotecas React, gosto de seguir três etapas. Em primeiro lugar, API imperativa. Suas funções podem ser chamadas de qualquer lugar e geralmente não estão vinculadas ao React. No caso de um roteador, serão funções como navigate
, getLocation
ou subscribe
.
Então, com base nessas funções, são criados ganchos como useLocation
ou useCurrentRoute
. E então essas funções e ganchos são usados como base para a construção de componentes. Isso funciona excepcionalmente bem, na minha experiência, e permite criar uma biblioteca facilmente extensível e versátil.
A API do roteador começa com a função defineRoutes
. O usuário deve passar um mapa de todas as rotas brutas para a função, que analisa as rotas e retorna um mapeamento com o mesmo formato. Todas as APIs voltadas para o desenvolvedor, como geração de URL ou correspondência de rotas, aceitarão rotas analisadas e não brutas.
const routes = defineRoutes({ login: '/login', user: { me: '/user/me', byId: '/user/:id/info', } });
A próxima etapa é passar as rotas analisadas para a função createRouter
. Esta é a essência do nosso roteador. Esta função criará todas as funções, ganchos e componentes. Isso pode parecer incomum, mas tal estrutura nos permite adaptar os tipos de argumentos e adereços aceitos a um conjunto específico de rotas definidas em routes
, garantindo a segurança de tipo (e melhorando o DX).
const { Link, Route, useCurrentRoute, navigate, /* etc... */ } = createRouter(routes);
createRouter
retornará funções que podem ser usadas em qualquer lugar do seu aplicativo (API imperativa), ganchos que permitem que seus componentes reajam às mudanças de localização e três componentes: Link
, Route
e NotFound
. Isso será suficiente para cobrir a maioria dos casos de uso, e você poderá construir seus próprios componentes com base nessas APIs.
Começamos abordando a parte de segurança de tipografia do nosso argumento de venda. Como mencionei antes, com um roteador typesafe, o TypeScript irá avisá-lo com antecedência sobre uma situação como esta:
<Link href="/logim" />
Ou assim:
const { userld } = useRoute(routes.user.byId);
E se você não consegue ver imediatamente o que há de errado com eles, você definitivamente precisa de um roteador typesafe :)
O sistema de tipos no TypeScript é muito poderoso. Quero dizer, você pode criar um mecanismo de xadrez , um jogo de aventura ou até mesmo um banco de dados SQL usando programação em nível de tipo.
Você já está familiarizado com a 'programação em nível de valor', onde você manipula valores, por exemplo, concatenando duas strings:
function concat(a, b) { return a + b; } concat('Hello, ', 'World!'); // 'Hello, World!'
Mas você também pode fazer isso com tipos!
type Concat<A extends string, B extends string> = `${A}${B}`; type X = Concat<'Hello, ', 'World!'>; // ^? type X = "Hello, World!"
Sim, não é tão poderoso quanto suas funções normais e parece diferente. Mas permite que você faça coisas muito legais e úteis.
Usaremos programação em nível de tipo para extrair parâmetros de rotas brutas e construir novos tipos que possam verificar se o desenvolvedor não tenta passar valores incorretos para Link
ou para a função que está construindo a URL.
A programação em nível de tipo com TS pode rapidamente se tornar uma bagunça ilegível. Felizmente para nós, já existem vários projetos que escondem toda essa complexidade e nos permitem escrever código limpo como este:
export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;
Muito legal, hein? A propósito, esse é todo o código que você precisa para analisar os parâmetros da rota bruta. Neste projeto, usaremos a biblioteca hotscript - ela nos ajudará a reduzir a complexidade e a quantidade de código em nível de tipo.
Mas não é obrigatório: se você se sente aventureiro, pode tentar implementar todos esses tipos sozinho. Você pode encontrar alguma inspiração no roteador Chicane , que implementa recursos semelhantes sem usar bibliotecas de tipos de terceiros.
Se você quiser acompanhar, recomendo que você crie um novo projeto React usando seu starter favorito (eu uso Vite ) e comece a codificar lá. Dessa forma, você poderá testar seu roteador imediatamente.
Observe que estruturas como Next.js fornecem seu próprio roteamento que pode interferir neste projeto e, em vez disso, use React 'vanilla'. Caso tenha alguma dificuldade, você pode encontrar o código completo aqui .
Comece instalando pacotes de terceiros: hotscript para utilitários de nível de tipo e regexparam para analisar parâmetros de URL/rota bruta.
npm install hotscript regexparam
O primeiro tijolo de construção de nossos tipos é a rota bruta. A rota bruta deve começar com /
; como você codificaria isso em TS? Assim:
export type RawRoute = `/${string}`;
Fácil, certo? Mas defineRoutes
não aceita uma única rota bruta, aceita mapeamento, possivelmente aninhado, então vamos codificar isso. Você pode ficar tentado a escrever algo assim:
export type RawRoutesMap = { [key: string]: RawRoute | RawRoutesMap };
Isso funcionará. No entanto, esse tipo pode ser infinitamente profundo e o TS terá dificuldade em calculá-lo em alguns casos. Para facilitar a vida do TS, limitaremos o nível de aninhamento permitido. 20 níveis de aninhamento devem ser suficientes para todos os aplicativos e o TS pode lidar com isso facilmente.
Mas como limitar a profundidade dos tipos recursivos? Aprendi esse truque com esta resposta do SO ; aqui está uma versão modificada para nossos 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 é o nosso primeiro tipo complexo, então deixe-me explicar. Temos dois tipos aqui: RecursiveMap
funciona como um ponto de entrada e chama RecursiveMap_
passando um parâmetro de tupla adicional. Esta tupla é usada para rastrear a profundidade do mapeamento, a cada chamada adicionamos um elemento a este array.
E continuamos a chamá-lo até que o comprimento desta tupla seja igual a MaxDepth
. No TS, quando extends
é usado com valores específicos , também chamados de literais (por exemplo, especificamente 42
, não number
), significa 'igual'.
E como MaxDepth
e Stack["length"]
são números específicos, esse código pode ser lido como MaxDepth === Stack["length"]
. Você verá essa construção sendo muito utilizada.
Por que usar tupla em vez de apenas somar números? Bem, não é tão fácil adicionar dois números no TypeScript! Existe uma biblioteca inteira para isso, e o Hotscript também pode adicionar números, mas requer muito código (mesmo que você não o veja), o que pode tornar lento o servidor TS e o editor de código se usado excessivamente.
Portanto, minha regra é evitar tipos complexos tanto quanto razoavelmente possível.
Com este tipo de utilitário, podemos definir nosso mapeamento tão simples quanto:
export type RawRoutesMap = RecursiveMap<RawRoute, 20>;
Isso é tudo para tipos de rotas brutas. O próximo na fila é a rota analisada. A rota analisada é apenas um objeto JavaScript com alguns campos adicionais e uma função; aqui está como parece:
export type ParsedRoute<R extends RawRoute> = { keys: RouteParam<R>[]; build(...params: PathConstructorParams<R>): Path<R>; raw: R; ambiguousness: number, pattern: RegExp; };
Vamos começar a descompactar isso no campo keys
. É simplesmente uma série de parâmetros necessários para esta rota. Aqui está como isso é feito:
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 ] >;
No Hotscript, existem duas maneiras de chamar uma função: Call
ou Pipe
. Call
é útil quando você precisa chamar uma única função, mas no nosso caso temos 4 delas! Pipe
aceita entrada e, bem, canaliza-a para a primeira função de uma tupla fornecida.
O valor retornado é passado como entrada para a segunda função e assim por diante. No nosso caso, se tivéssemos, por exemplo, rota bruta /user/:userId/posts/:postId
, ela seria transformada assim:
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? Esta é a magia da programação em nível de tipo! Agora, vamos abordar essa função build
. Ele aceita parâmetros de rota (como userId
e postId
) e parâmetros/hash de pesquisa opcionais e os combina em um caminho. Dê uma olhada em uma implementação de 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];
Os parâmetros da função são definidos como um array (que é posteriormente ... espalhado na definição do
função build
), onde o primeiro elemento é RouteParamsMap
e o segundo é opcional SearchAndHashPathConstructorParams
. Que tal retornar o valor de build
? Já estabelecemos seu caminho, mas como descrevê-lo com TypeScript?
Bem, este é bastante semelhante ao RouteParam
, mas requer um pouco mais de ginástica do tipo!
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}`;
O que fazemos aqui é dividir nossa rota em segmentos, mapear cada segmento e chamar nossa função personalizada ReplaceParam
em cada um. Ele verifica se o segmento atual é um parâmetro e o substitui por string
ou retorna o segmento como está. A 'função' ReplaceParam
pode parecer um pouco estranha, mas é assim que você define funções personalizadas com Hotscript.
Afirmamos explicitamente que o caminho consiste apenas em caminho, caminho seguido por um ponto de interrogação (isso abrange URLs com parâmetros de pesquisa e hash) ou um símbolo de hash (isso abrange URLs sem parâmetros de pesquisa, mas com hash).
Também precisaremos de um tipo para descrever a rota correspondente, ou seja, rota analisada com parâmetros capturados da 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>, }
O último tipo é ParsedRoutesMap
; é semelhante a RawRoutesMap
, mas para rotas analisadas.
// 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; };
E com essa nota, terminamos com os tipos. Haverá mais alguns, mas são mais simples e iremos abordá-los à medida que avançamos na implementação. Se a programação em nível de tipo é algo que você gostaria de experimentar mais, você pode conferir Typescript em nível de tipo para aprender mais e tentar resolver desafios de tipo (eles também têm uma boa lista de recursos ).
Finalmente, voltamos à codificação normal em nível de valor. Vamos dar o pontapé inicial 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 complexo aqui; vamos nos aprofundar na função 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
também é muito simples, embora visivelmente mais longo. Para analisar a rota e extrair parâmetros, usamos a biblioteca regexparam
. Ele nos permite obter uma matriz de parâmetros necessários para a rota e gera uma expressão regular que usaremos mais tarde para combinar a URL com a rota.
Armazenamos essas informações junto com a rota bruta original usada para construir esse objeto e o nível de ambiguidade (que é apenas uma série de parâmetros na rota).
Todo roteador precisa armazenar seu estado em algum lugar. No caso de aplicativos no navegador, isso se resume a 4 opções: na memória (seja em uma variável de estado dentro do componente raiz ou em uma variável fora da árvore de componentes), API de histórico ou parte hash da URL.
O roteamento na memória pode ser sua escolha se você não quiser mostrar ao usuário que possui rotas, por exemplo, se estiver codificando um jogo no React. Armazenar a rota em hash pode ser útil quando seu aplicativo React é apenas uma página em um aplicativo maior e você não pode simplesmente alterar a URL como quiser.
Mas, na maioria dos casos, usar a API History será a melhor opção. É compatível com SSR (outras opções não são), segue padrões de comportamento aos quais o usuário está acostumado e parece mais limpo. Neste projeto, iremos usá-lo também. Porém, ele tem uma falha notável: é praticamente inutilizável sem invólucros adicionais.
Com o History AP, você pode se inscrever no evento popstate
e o navegador avisará quando o URL for alterado. Mas somente se a alteração for iniciada pelo usuário, por exemplo, clicando no botão Voltar. Se uma alteração de URL for iniciada a partir do código, você mesmo precisará controlá-la.
A maioria dos roteadores que estudei usa seu próprio wrapper: react-router e chicane usam o pacote NPM de histórico , o roteador TanStack tem sua própria implementação e o wouter não tem um wrapper completo, mas ainda precisa corrigir o histórico .
Então, vamos implementar nosso próprio 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... */; };
Existem dois tipos que usaremos, HistoryLocation
e NavigationBlocker
. Primeiro, é uma versão um pouco limitada do tipo Location
integrado (esse é o tipo de window.location
), e o segundo será abordado quando chegarmos ao bloqueio de navegação. Todo o código adicional deste capítulo irá para dentro da função createHistory
.
Vamos começar implementando uma assinatura para alterações no histórico. Usaremos funções estilo React para assinatura neste projeto: você chama subscribe
passando um retorno de chamada e ele retorna outra função que você precisa chamar quando quiser cancelar a assinatura.
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); }; };
A próxima etapa é reagir às alterações de localização, incluindo alterações feitas programaticamente. Como você faria? Com patch de macaco , é claro. Isso pode parecer um pouco sujo (e realmente é), mas infelizmente não temos opções melhores.
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);
E a última grande peça que falta em nossa implementação de histórico é o bloqueio de navegação: um recurso que permite interceptar a solicitação de navegação e cancelá-la condicionalmente. Um exemplo canônico de bloqueio de navegação seria evitar que o usuário perdesse o progresso em um formulário.
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 }); } } };
Em nossa implementação, um bloqueador é uma função que retorna um booleano indicando se precisamos bloquear esta navegação. No que diz respeito ao bloqueio de navegação, existem dois tipos de navegação e precisaremos tratá-los de forma diferente.
Por um lado, existe a navegação suave - quando o usuário navega de uma página do nosso aplicativo para outra página do nosso aplicativo. Nós o controlamos totalmente e, portanto, podemos bloqueá-lo, exibir qualquer UI personalizada (para confirmar a intenção do usuário) ou realizar ações após bloquear a navegação.
Por outro lado, há uma navegação difícil – quando o usuário navega para outro site ou fecha totalmente a aba. O navegador não pode permitir que o JavaScript decida se esta navegação deve ser realizada, pois isso será uma preocupação de segurança. Mas o navegador permite que o JavaScript indique se queremos mostrar uma caixa de diálogo de confirmação extra ao usuário.
Ao bloquear a navegação suave, você pode querer exibir uma interface de usuário adicional (por exemplo, caixa de diálogo de confirmação personalizada), mas no caso de navegação difícil, isso realmente não faz sentido, pois o usuário só a verá se decidir permanecer na página e , nesse ponto, é inútil e confuso.
Quando nosso histórico chamar a função de bloqueador de navegação, ele fornecerá um booleano, indicando se estamos realizando uma navegação suave.
E com tudo isso, só precisamos retornar nosso objeto histórico:
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, };
Finalmente estamos aqui. A API Imperative será a base para todos os outros ganchos e componentes e permitirá ao desenvolvedor construir ganchos personalizados para atender às suas necessidades. Primeiro de tudo, precisamos transformar nosso mapa de rotas em um array plano. Dessa forma, será muito mais fácil percorrer todas as rotas, o que será útil quando começarmos a trabalhar na parte de correspondência de rotas.
Precisamos do tipo utility (que transformará ParsedRoutesMap
na união de ParsedRoute
) e function (que transformará routesMap
em uma matriz de rotas analisadas). Vamos começar com o 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>>;
Pode parecer desnecessário dividir isso em dois tipos, mas há uma razão muito importante para isso: se você implementá-lo como um tipo único que chama a si mesmo, o TS reclamará que o tipo é excessivamente profundo e possivelmente infinito. Então, contornamos isso dividindo-o em dois tipos que se chamam.
Para uma função de nível de valor, também precisaremos de um protetor de tipo para verificar se o valor passado é uma rota analisada.
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>); }); };
Agora, vamos começar a implementar nosso roteador. Assim como na história, neste e nos próximos dois capítulos, todo o código irá para a função createRouter
, salvo indicação em contrário.
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); };
Primeiro de tudo, vamos ensinar nosso roteador a combinar a localização atual com uma das rotas conhecidas. Alguns utilitários podem entrar no escopo global ou em arquivos separados, não dentro da função 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 { };
E esse código vai para a função 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;
Aqui examinamos todas as rotas conhecidas e tentamos combinar cada uma com a localização atual. Se o regex da rota corresponder a URL - obtemos parâmetros de rota de URL, caso contrário, obtemos null
. Para cada rota correspondente, criamos um objeto RouteWithParams
e o salvamos em um array. Agora, se tivermos 0 ou 1 rotas correspondentes, tudo é simples.
Contudo, se mais de uma rota corresponder à localização atual, temos que decidir qual delas tem maior prioridade. Para resolver isso, usamos o campo ambiguousness
. Como você deve se lembrar, essa rota possui vários parâmetros, e uma rota com a menor ambiguousness
é priorizada.
Por exemplo, se tivéssemos duas rotas /app/dashboard
e /app/:section
, a localização http://example.com/app/dashboard
corresponderia a ambas as rotas. Mas é bastante óbvio que esse URL deve corresponder a /app/dashboard
route, não /app/:section
.
Este algoritmo não é à prova de balas. Por exemplo, as rotas /app/:user/settings/:section
e /app/dashboard/:section/:region
corresponderão ao URL http://example.com/app/dashboard/settings/asia
. E como possuem o mesmo nível de ambiguidade, nosso roteador não conseguirá decidir qual deles deve ser priorizado.
Agora, precisamos unir esse código para reagir às mudanças de localização e atualizar a variável 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 } });
Agora, nosso roteador reage às mudanças de localização, e o usuário sempre pode obter a rota atual, sim! Mas não é muito útil sem a capacidade de assinar alterações de rota, então vamos adicionar isso. A abordagem é muito semelhante àquela que usamos no wrapper de 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 a navegação, exporemos as funções navigate
e navigateUnsafe
, que são um wrapper simples em torno de history.push
e 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); };
Bem, isso é um roteador de verdade! Muito básico, mas funcionando mesmo assim. Ainda temos alguns ganchos e componentes para implementar, mas fica muito mais fácil a partir daqui.
Para ganchos, podemos começar com ganchos simples que retornam a localização atual e a rota atual. Eles são bastante fáceis por si só, mas useSyncExternalStore os transforma em uma linha única. A maneira como projetamos nossa API imperativa anteriormente nos permitiu reduzir drasticamente o código desses ganchos.
const useLocation = () => { return useSyncExternalStore(history.subscribe, history.getLocation); }; const useCurrentRoute = () => { return useSyncExternalStore(subscribe, getCurrentRoute); };
Ao codificar componentes que devem ser renderizados apenas em uma rota/conjunto de rotas específico, você pode usar useCurrentRoute
para obter a rota atual, verificar se ela corresponde aos critérios e então usar seus parâmetros (ou gerar um erro).
Mas esta é uma tarefa tão comum que será um crime fazer com que nossos usuários escrevam seus próprios ganchos para isso - nosso roteador deve fornecer isso imediatamente.
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 tem duas versões: rigoroso e descontraído. Se o usuário passar true
como segundo parâmetro (ou não passar nada, já que true
é o valor padrão), esse gancho gerará um erro se a rota atual não corresponder a um dos filtros fornecidos.
Dessa forma, você pode ter certeza de que o gancho retornará uma rota correspondente ou nem retornará. Se o segundo parâmetro for falso, em vez de lançar uma exceção, o gancho simplesmente retornará indefinido se a rota atual não corresponder aos filtros.
Para descrever esse comportamento para TypeScript, usamos um recurso chamado sobrecarga de função . Isso nos permite definir múltiplas definições de funções com tipos diferentes, e o TypeScript escolherá automaticamente uma para ser usada quando o usuário chamar tal função.
Além dos parâmetros de caminho, alguns dados podem ser passados em parâmetros de pesquisa, então vamos adicionar um gancho para analisá-los da string para o mapeamento. Para isso, usaremos a API integrada do navegador URLSearchParams .
const useSearchParams = () => { const location = useLocation(); return useMemo(() => { return Object.fromEntries( (new URLSearchParams(location.search)).entries() ); }, [location.search]); };
E o último gancho nesta seção é useNavigationBlocker
que também é bastante simples: ele apenas aceitará retorno de chamada e agrupará chamadas para history.addBlocker
em um efeito, para anexar novamente o bloqueador se ele mudar.
const useNavigationBlocker = (cb: NavigationBlocker) => { useEffect(() => { return history.addBlocker(cb); }, [cb]); };
Agora, vamos pular para os componentes!
Qual é o primeiro componente que vem à mente ao mencionar bibliotecas de roteamento? Aposto que é Route
ou, pelo menos, algo parecido. Como você viu anteriormente, nossos ganchos eram muito simples devido a uma API imperativa bem projetada que faz todo o trabalho pesado.
O mesmo vale para componentes; eles podem ser facilmente implementados pelo usuário em poucas linhas de código. Mas somos uma biblioteca séria de roteamento por aí, vamos incluir baterias na caixa :)
type RouteProps = { component: ComponentType, match: RouteFilter<RouteType> }; const Route = ({ component: Component, match }: RouteProps) => { const matchedRoute = useRoute(match, false); if (!matchedRoute) return null; return (<Component />); };
Bem, isso foi fácil! Quer adivinhar como implementamos o componente NotFound
? :)
type NotFoundProps = { component: ComponentType }; const NotFound = ({ component: Component }: NotFoundProps) => { const currentRoute = useCurrentRoute(); if (currentRoute) return null; return (<Component />); };
E o último componente necessário para o nosso roteador é Link
, que é um pouco mais complicado. Você não pode simplesmente usar <a href="/app/dashboard" />
pois ele sempre iniciará uma navegação difícil e não fornece nenhuma segurança de tipo. Então, vamos abordar estas questões:
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} /> };
Da mesma forma que a função navigate
, o componente Link
verifica o tipo da URL que você passa para ele, mas também permite fornecer uma URL de string arbitrária (como uma saída de escape ou para links externos). Para substituir o comportamento de <a>
, anexamos nosso próprio ouvinte onClick
, dentro do qual precisaremos chamar o onClick
original (passado para nosso componente Link
).
Depois disso, verificamos se a navegação já não foi abortada pelo desenvolvedor (se foi, devemos ignorar o evento). Se tudo estiver bem, verificamos se o link não é externo e se deve estar aberto na aba atual. E só então poderemos cancelar a navegação rígida integrada e, em vez disso, chamar nossa própria função navigateUnsafe
.
E agora, só precisamos retornar todas as nossas funções, ganchos e componentes (junto com algumas funções reexportadas diretamente do histórico) da função 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, };
E com isso, nosso roteador está pronto. Agora, podemos construir nosso pequeno aplicativo para mostrar nosso minúsculo roteador que acabamos de fazer! A propósito, você pode encontrar o código completo deste roteador (incluindo o código de um aplicativo de exemplo) aqui .
Então, como todo esse código se interliga? Muito bem, se é que posso dizer! Imagine que você está criando um aplicativo de anotações. Primeiramente, você começaria definindo as rotas e criando um roteador 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;
E então, você vincula as rotas definidas aos componentes da 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> ) }
Quando estiver em NoteDetailsPage
, você precisa obter um ID de nota do URL, então use um gancho 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>); };
E ao criar uma nova nota, você provavelmente gostaria de confirmar a intenção do usuário se ele sair sem salvar a 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> </>; };
Embora nosso roteador realmente faça o roteamento, ele não é páreo para soluções prontas para produção, como roteador TanStack, roteador react ou roteador Next.js. Quero dizer, são apenas cerca de 500 linhas de código, isso não é muito. Mas o que exatamente está faltando?
Primeiro de tudo, renderização no lado do servidor. Hoje, nem todos os aplicativos podem precisar de SSR, mas espera-se que todas as bibliotecas de roteamento o suportem. Adicionar renderização do lado do servidor em uma string (não transmitir SSR!) Envolverá a criação de um history
diferente que armazenará a localização atual na memória (já que não há API de histórico no servidor) e conectará isso à função createRouter
.
Não estou ciente de quão difícil será implementar o SSR de streaming, mas presumo que estará fortemente conectado com o suporte do Suspense.
Em segundo lugar, este roteador não se integra bem à renderização simultânea. Principalmente por causa do uso de useSyncExternalStore
, pois não é compatível com transições sem bloqueio . Funciona desta forma para evitar tearing: uma situação em que uma parte da UI foi renderizada com um valor armazenado específico, mas o resto da UI é renderizado com um valor diferente.
E por causa disso, o roteador não se integra bem ao Suspense, pois para cada atualização de localização suspensa, um fallback será mostrado. A propósito, abordei simultaneidade no React neste artigo , e neste , falo sobre Suspense, busca de dados e use
de gancho.
Mas mesmo com essas desvantagens, espero que você tenha achado este artigo interessante e construído seu próprio roteador ao longo do caminho :)