Recriando o Styled Components

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

Introdução

TL;DR — Se você só quiser o 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.

Trabalhar com CSS e JS em arquivos separados é uma abordagem igualmente válida, mas não é o foco deste post.

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

O resultado é CSS escrito dentro do JavaScript, funcionando como esperado. Analisando o comportamento da biblioteca 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]);

Um hook utilitário para compor o 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);
    };
  };
}

O uso fica bastante próximo ao 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

O styled-components original realiza otimizações de performance durante a compilação através das macros. Em projetos pequenos ou para fins de estudo, no entanto, esta implementação simplificada é suficiente para observar o comportamento do React de forma mais direta. Obrigado pelo seu tempo, tamo junto e até a próxima