Recriando o Styled Components

Que tal fazer o seu próprio styled-components da forma simples?

Introdução

TL;DRSe você só quiser código: Link do Gist

Você conhece o styled-components? Se não, te apresento agora a famosa biblioteca de CSS-in-JS mais utilizada no mundo React.

Particularmente, prefiro trabalhar com CSS e JS em arquivos separados, mas isso fica pra próxima

Se você nunca utilizou styled-components, observe um exemplo que peguei da documentação oficial:

import styled, { css } from 'styled-components'

const Button = styled.a`
  display: inline-block;
  border-radius: 3px;
  padding: 0.5rem 0;
  margin: 0.5rem 1rem;
  width: 11rem;
  background: transparent;
  color: white;
  border: 2px solid white;
  ${props => props.primary && css`
    background: white;
    color: black;
  `}
`;
const App = () => (
  <div>
    <Button
      href="https://github.com/styled-components/styled-components"
      target="_blank"
      rel="noopener noreferrer"
      primary
    >
      GitHub
    </Button>
    <Button as="a" href="/docs">Documentation</Button>
  </div>
);

Incrível né? Você escreveu CSS dentro do JS e isso funcionou. Parece até magia. E é exatamente por parecer magia que estou escrevendo esse post.

Até o momento ainda não consegui entender o código todo do styled-components, mas analisando seu funcionamento em algumas páginas, é possível perceber que:

  1. Ao carregar a página, o styled-components insere no <head> da aplicação uma tag <style> com alguns atributos para identificação
  2. Insere os estilos passados no CSS-in-JS nos elementos que são filhos de styled-components
  3. O CSS gerado é inserido no HTML. Em algumas versões ele era inserido no head>style com todo o CSS necessário, nas versões novas parece ser um texto encodado e transformado em estilo CSS

Talk is cheap, show me the code

Para podermos criar qualquer elemento HTML, sendo estes passados por parâmetros, devemos conhecer o React.createElement para criar nossos elementos de forma dinâmica, não utilizando JSX.

Como vamos simular o styled-components. Nem tudo será exatamente igual a biblioteca. Nossa função principal precisará:

  • Receber a string referente ao elemento que utilizar. Styled("div"), por exemplo.
  • Receber um TemplateStringsArray, que é o template literal que utilizamos para escrever o estilo do nosso componente.
  • Receber as props como qualquer componente React irá receber
/*
  Recebemos as props como `Props` (simulando o .attrs()) e o elemento HTML
  que nosso componente irá representar
*/ 
function Styled<Props = unknown, Element = Html>(tag: string) {
  /*
    Retornamos uma função que interpreta o nosso template literal string,
    as strings passadas entre crases (template string)
  */
  return ([first, ...placeholders]: TemplateStringsArray, ...a: StyledArgs<Element, Props>[]) => {
    /*
      Retornamos uma função que de fato será nosso componente JSX, recebendo as props do elemento HTML
      e as props adicionais
    */
    return ({ children, ...props }: Html & Props) => {
    }
  }
}

Agora precisamos fazer toda a criação do elemento, inserção do CSS no header, adição de classes, concatenação de props e renderização.

  • Concatenar o template literals de acordo com as funções utilizadas dentro da string. Quando utilizamos um styled-components, fazemos algo parecido com:
const Paragraph = styled.p`
  color: ${props => props.color ?? "white"}
`

Utilizando nosso styled-components customizado, seria o equivalente a:

const Paragraph = Styled("p")`
  color: ${props => props.color ?? "white"}
`

Para conseguirmos "executar" a concatenação de string + função, precisamos do nosso seguinte snippet abaixo:

// Reutilizando os nomes apresentados acima
const template = (placeholders: StyledArgs<Element, Props>[]) => {
  const final = placeholders.reduce((acc, el, i) => {
  const curr = a[i];
  if (typeof curr === "function") {
    // as props recebidas pelo componente.
    return acc + curr(props as never) + el;
  }
  return acc + a[i] + el;
  }, first);
  return final.trim();
}
  • Inserir os estilos no header para utilizar as classes. Que outra maneira de inserir elementos HTML no nosso documento se não utilizar a DOM API? Nesse ponto, iremos utilizar o React apenas para observar as mudanças ocorridas na nossa string e então, executar nosso efeito novamente.
useEffect(() => {
  const sheet = document.createElement("style");
  sheet.innerHTML = `.${className} { ${string} }`;
  sheet.id = className;
  const el = document.getElementById(className);
  if (!el) {
    document.head.insertBefore(sheet, document.head.firstElementChild);
    return;
  }
  el?.replaceWith(sheet);
}, [string]);
  • Assim como o styled-components, nós precisamos simular também a eliminação de atributos não pertencentes ao HTML, para assim passarmos para nosso componente HTML de fato. Para isso, criaremos um computedProps para limpar as props do nosso componente customizado.
const computedProps = useMemo(() => {
  const el = document.createElement(tag);
  const newProps = {};
  for (const prop in el) {
    if (prop in props) {
      newProps[prop] = props[prop];
    }
  }
  el.remove();
  return newProps;
}, [props, str]);

Pera um pouco. Tem um hook de brinde para você utilizar na hora de compor seu className

import React, { useState, DependencyList, useMemo } from "react";

type ClassArray = ClassValue[];

type ClassDictionary = { [id: string]: any };

export type ClassValue = string | number | ClassDictionary 
    | ClassArray | undefined | null | boolean;

export const useClassNames = (dependency: DependencyList, ...classes: ClassValue[]) =>
  useMemo(() => classNamesDedupe(...classes), dependency);

Pronto. Agora temos tudo necessário para a construção do nosso joked-components.

import classNamesDedupe from "classnames/dedupe";
import React, { useState, DependencyList, useMemo } from "react";

type ClassArray = ClassValue[];
type ClassDictionary = { [id: string]: any };
export type ClassValue = string | number | ClassDictionary | ClassArray | undefined | null | boolean;

export const useClassNames = (dependency: DependencyList, ...classes: ClassValue[]) =>
  useMemo(() => classNamesDedupe(...classes), dependency);

type Html = React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;

type StyledArgs<E, T> = ((args: E & T) => string | number) | string | number;

function Styled<ExtraProps = unknown, Element = Html>(tag: string) {
  return ([first, ...placeholders]: TemplateStringsArray, ...a: StyledArgs<Element, ExtraProps>[]) => {
    return ({ children, ...props }: Html & ExtraProps) => {
      const className = useMemo(() => `${tag}-${Date.now()}`, []);

      // aplicando a demonstração do método template, citado anteriormente
      const str = useMemo(() => {
        const final = placeholders.reduce((acc, el, i) => {
          const curr = a[i];
          if (typeof curr === "function") {
            return acc + curr(props as never) + el;
          }
          return acc + a[i] + el;
        }, first);
        return final.trim();
      }, [props]);

      // utilizando a DOM API para inserir o <style>
      useEffect(() => {
        const sheet = document.createElement("style");
        sheet.innerHTML = `.${className} { ${str} }`;
        sheet.id = className;
        const el = document.getElementById(className);
        if (!el) {
          document.head.insertBefore(sheet, document.head.firstElementChild);
          return;
        }
        el?.replaceWith(sheet);
      }, [str]);

      // composição dos classNames
      const classNames = useClassNames([props.className, str], props.className, className);

      // limpando as props que não pertencem ao HTML
      const computedProps = useMemo(() => {
        const div = document.createElement(tag);
        const newProps = {};
        for (const prop in div) {
          if (prop in props) {
            newProps[prop] = props[prop];
          }
        }
        div.remove();
        return newProps;
      }, [props, str]);
      return React.createElement(tag, { ...computedProps, className: classNames }, children);
    };
  };
}

Olhando assim, não parece tão difícil né? E pra utilizar fica bem parecido com o styled-components original:

type DIV = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;

type GridProps = { gap: number; rows: number };

// Dizendo as props customizadas do nosso componente e qual o tipo do elemento HTML que o mesmo será
const GridRow = Styled<GridProps, DIV>("div")`
  display: grid;
  grid-template-rows: repeat(${(props) => props.rows}, minmax(0, 1fr));
  grid-auto-flow: column dense;
  grid-gap: ${(props) => props.gap}rem;
  gap: ${(props) => props.gap}rem;
`;

const App = () => {
  const [zero, setZero] = useState(0);
  return (
    <GridRow gap={zero} rows={4}>
      <button onClick={() => setZero((p) => p + 1)}>Add + {zero}</button>{" "}
      <button onClick={() => setZero((p) => p + 1)}>Add + {zero}</button>
      <button onClick={() => setZero((p) => p + 1)}>Add + {zero}</button>
      <button onClick={() => setZero((p) => p + 1)}>Add + {zero}</button>
      <button onClick={() => setZero((p) => p + 1)}>Add + {zero}</button>
    </GridRow>
  );
};

Conclusão

E então, o que achou? Claro que o styled-components faz algumas melhorias de performance durante a compilação do projeto, através das macros. Mas em projetos pequenos ou para fins de estudo, vale a pena você utilizar essa versão para observar o comportamento do React de forma mais profunda.

É isso aí amiguinhos, até a próxima