Criando seu próprio Router

Desmistificando o conceito por trás do React Router

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

{"<Router />"}

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

{"<Route />"}

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.