Criando seu próprio Router

Desmistificando o conceito por trás do React Router

Table of Content

    Introdução

    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.

    TL;DR

    Repositório com o resultado da brincadeira

    Definindo o escopo

    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

    • Controle de parâmetros
    • Renderização por path
    • Controle de query string
    • Controle de parâmetros
    • Acesso ao history
    • Controle de navegação do browser

    Agora que temos o que precisamos fazer, vamos analisar o que queremos ter como código final

    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:

    • Criar uma context de history*
    • Controlar todos os paths com as rotas recebidas
    • Registrar as rotas de acordo com a renderização dos filhos de router

    O pacote history foi utilizado para garantir a consistência entre browsers

    Para a nossa context, temos:

    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.

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

    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.

    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

    useQS (QueryString)

    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:

    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

    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.