Desmistificando o conceito por trás do React Router
Fala aí galera, tudo tranquilo? Nesse post eu gostaria de trazer pra vocês uma experiência que tive recriando o React Router.
Como todos sabemos, React Router é quase que a biblioteca oficial para roteamento em React, e quase ninguém conhece alguma alternativa. Durante algumas criações de telas, utilizando Query String, acabei enfrentando alguns problemas com isso, principalmente em como recuperar meu objeto dado a minha query string.
Repositório com o resultado da brincadeira
Eu tive problemas com o meu query string, mas para um router client side, eu precisava de algo que manipulasse o path da URL, controle de histórico e que renderizasse meus componentes de acordo com o path da URL (recebendo IDs, ou parâmetros que compõe a rota). Logo, vamos precisar
Agora que temos o que precisamos fazer, vamos analisar o que queremos ter como código final
1 2 3 4 5 6 7 8
const history = History(); const App = () => ( <Router history={history}> <Route path="/" component={Root} /> <Route path="/orders/:id" component={ViewOrders} /> <Route path="/orders/:id/:operation" component={CrudOrders} /> </Route> );
Esse é o resultado final, agora é só fazer acontecer haha
O Router costuma estar no top level da nossa aplicação, abraçando todos os componentes para que possamos criar <Route />
diferentes. O <Router />
é quem entrega a context para nossas rotas e é o responsável por comandar a renderização de cada rota.
Para que possamos fazer o nosso Router, devemos seguir os seguintes passos:
O pacote history foi utilizado para garantir a consistência entre browsers
Para a nossa context, temos:
1 2 3 4 5
import { createBrowserHistory } from "history"; import React, { createContext } from "react"; export const History = createBrowserHistory(); export const HistoryContext = createContext({ ...History, params: {} });
Com isso, podemos construir de fato o nosso componente <Router />
que irá entregar nossa context para cada elemento a ser renderizado na tela.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
import { pathToRegexp } from "path-to-regexp"; // Definição dos tipos para que possamos trabalhar type MatchRoute = { regex: RegExp; path: string; component: FC; params: Array<{ name: string; prefix: string; suffix: string; pattern: string; modifier: string; }>; }; type RouterProps = { notFound: FC; }; type Render = { Component: FC<any>; params: { [k: string]: any }; }; export const Router: FC<RouterProps> = ({ children, notFound: NotFound }) => { const [location, setLocation] = useState(() => History.location); const [pathName, setPathName] = useState(History.location.pathname); /* Esse é o callback que constrói o nosso estado, pegando o children e montando as rotas com base no "path" de cada <Route /> */ const init = useCallback(() => { // Utilizando o Children.toArray, pegamos todos os filhos de nosso <Router/> // o .sort() que fazemos é para que as rotas que nâo possuem parâmetros // sejam colocadas como prioridade para não atrapalhar a regex dos paths const routes = Children.toArray(children).sort((a: any, b: any) => { const x: RouteProps = a.props; const y: RouteProps = b.props; const xHas = x.path.includes(":"); const yHas = y.path.includes(":"); if (!xHas || x.path === "/") return -1; if (yHas) return 1; return 0; }); // Com esse map, nós criamos cada regex para os paths especificados nos // componente de rota const rules = routes.map((x: any) => { const params: any[] = []; const regex = pathToRegexp(x.props.path, params); return { ...x.props, regex, params }; }); return { routes, rules }; }, [children]); // Inicialização do estado através de função const controller = useMemo<{ rules: MatchRoute[]; routes: any[]; }>(init, [init]); useEffect(() => { History.listen((e) => { setLocation(e.location); setPathName(e.location.pathname); }); }, []); /* Um memo que cuidará dá renderização e dá obtenção do `params` dado o nosso path atual Nele faremos as comparações de rota e definiremos se tal rota existe ou não */ const render = useMemo((): Render => { const params: any = {}; // Early return para a raiz if (pathName === "/") { const current = controller.routes.find((x) => x.props.path === "/"); if (current) return { Component: current.props.component, params }; // Rota / não foi registrada e será redirecionado para NotFound return { Component: NotFound, params }; } const index = controller.rules.findIndex((x) => { const exec = x.regex.exec(pathName); // Caso o regex da rota atual não case, retorne false if (exec === null) return false; // objeto regex group retornado, podemos capturar os valores num array usando a destrução, // pegando do item 1 em diante. const [, ...groups] = exec; // Atribuindo os valores ao params groups.forEach((val, i) => { const regex = x.params[i].name; // um leve roubo para parsear os valores de forma segura try { params[regex] = JSON.parse(val); } catch (error) { params[regex] = val; } }); return true; }); const current = controller.routes[index]; // Se o meu current for undefined, a rota não existe e redirecionado para NotFound if (current === undefined) { return { Component: NotFound, params }; } return { Component: current.props.component, params }; }, [controller, NotFound, pathName]); // history props const historyComponent = useMemo(() => ({ ...History, location }), [ location, ]); // Nossa context entregue e o router renderizando somente o nosso componente alvo do path return ( <HistoryContext.Provider value={{ ...History, params: render.params }}> <render.Component history={historyComponent} /> </HistoryContext.Provider> ); };
E com isso temos nosso router, mas ainda falta a nossa forma de criar nosso <Route/>
1 2 3 4 5 6 7 8 9 10
type RouteProps = { path: string; component: FC; }; export const Route = (props: RouteProps) => { const router = useContext(HistoryContext); // o any é para que possamos ignorar e injetar as props de history em nossos componentes return <props.component {...(router as any)} />; };
Mas também faltou a forma de criar nossos links para caminhar entre as páginas. Para isso, podemos fazer uma componente utilizando o <a/>
e aproveitar o próprio atributo href, assim temos uma forma acessível e semântica de criar nossos Links.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
export const Link: React.FC<A> = ({ onClick, state, href, ...props }) => { // o callback de click que previne o default do elemento const click = useCallback( (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { onClick?.(e); if (!href.startsWith("http")) { e.preventDefault(); return History.push(href, state); } }, [onClick, href, state] ); // eslint-disable-next-line jsx-a11y/anchor-has-content return <a {...props} href={href} onClick={click} />; };
E com isso temos nossos elementos necessários para o básico do router. O que nos leva a real motivação do nosso router
Nesse ponto, já temos tudo necessário menos a nossa forma de conseguir obter o queryString como objeto. Para isso, vamos utilizar a biblioteca query-string
.
Neste artigo irei usar essa lib para facilitar o código. Mas no código do github eu acabei optando por tentar reproduzir os meus próprios métodos de parse e stringify de query string
Então antes de começar o nosso useQs, devemos instalar a query-string
:
1
yarn add qs
Você pode conferir no github a implementação do query string.
Pós instalação, é só partir pro código do nosso hook
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import { useEffect, useRef, useState } from "react"; import qs from "query-string"; import { History } from "./router"; // Basicamente essa é a função chave que vai pegar o path atual de window.location.href // e retornar o objeto que está em formato de string após o `?` const getQs = <T,>(): T => qs.parse<T>(window.location.href); export const useQueryString = <T extends object>(): T => { const qs = useRef(History.location.search); const [queryString, setQueryString] = useState<T>(getQs); useEffect(() => { History.listen((e) => { if (e.location.search !== qs.current) { qs.current = e.location.search; setQueryString(getQs); } }); }, []); return queryString; };
E assim o nosso useQs
e <Router />
estão prontos para serem usados (mas tome cuidado, ainda não vi o comportamento desse router). Mas o que vale aqui é o aprendizado sobre como criar o seu router e ver como os hooks podem ser nossos amigos se bem utilizados.
É isso galera, vou ficando por aqui, e caso você tenha perdido, o link desse repositório para que você possa se aventurar pelo código.