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:

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
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
  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:
1
2
3
const Paragraph = styled.p`
  color: ${props => props.color ?? "white"}
`

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

1
2
3
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:

1
2
3
4
5
6
7
8
9
10
11
12
// 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.
1
2
3
4
5
6
7
8
9
10
11
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.
1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
5
6
7
8
9
10
11
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.

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

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