Otimizando o Coverage do CSS com Dynamic Import e Next.Js

Utilizando técnicas como Dynamic Import e ferramentas como o Next.Js é possível gerar chunks de css para diferentes estados do seu componente e melhorar as métricas do seu website

Esses dias resolvi fazer uma investigação de performance e me deparei com um problema de coverage de CSS. Embora não seja algo inédito, não é algo muito falado nas stacks modernas com webpack, Next.Js, babel, etc e talvez seja um problema que fique um pouco mais deixado de lado.

Embora não comumente falado o Google cada vez mais traz mudanças em seu código e, CSS, é um problema real que vai prejudicar o rank do seu site.

Por isso resolvi fazer uns testes com uma nova stack e ver como funcionam os chunks usando conceitos como dynamic import e ferramentas como o next/dynamic. Neste artigo trago os resultados dos meus experimentos.

Para a demonstração foram utilizadas as seguintes ferramentas:

  • next - 12.1.6
  • react & react-dom - 18.1.0
  • SCSS (SASS) - 1.52.1
  • Typescript - 4.7.2

O código você pode encontrar nesse link no Github

Teste 1 - Validando Hipóteses

O primeiro teste consiste em um componente (um simples botão) que recebe uma prop e troca de cor como primary, secondary ou tertiary.

Versão A (sem o codesplit)

O Componente estatico simplesmente importa o modulo scss (ou css) como um grande objeto e passa o nome da class para o button, nenhum mistério aqui.

//src/components/test-1/static/index.tsx
import React from 'react';

import style from './static.module.scss';

interface StaticButtonProps {
  children: React.ReactNode;
  state: 'primary' | 'secondary' | 'tertiary';
}

const StaticButton = ({ children, state }: StaticButtonProps) => {
  return <button className={style[state]}>{children}</button>;
};

export default StaticButton;

Módulo SCSS:

//static.module.scss
.primary {
  background-color: #00bcd4;
  color: #fff;
}

.secondary {
  background-color: #f44336;
  color: #fff;
}

.tertiary {
  background-color: #ff9800;
  color: #fff;
}

Invocando o componente em uma página passando qualquer uma das props:

//pages/test-1-a.tsx
import React from 'react';

import StaticButton from '../src/components/test-1/static';
const test = () => {
  return (
    <div>
      Hello world <StaticButton state="secondary">Secondary</StaticButton>
    </div>
  );
};

export default test;

Como podemos ver na imagem abaixo, o componente está sendo renderizado com a cor secondary, o coverage nos mostra que apenas 1 das 3 classes do css está sendo usada, o que nos mostra um coverage de apenas 33% (ou 66% de código não utilizado que estamos mandando para o usuário).

Para corrigir esse problema (ou pelo menos tentar) é necessário otimizar o código, e para isso vamos utilizar import dinâmicos.

Versão B (com o codesplit)

Para esse segundo caso vamos dividir o css de cada "estado" em um arquivo diferente e vamos importá-lo com o import dinâmico.

//primary.module.scss
.primary {
  background-color: #00bcd4;
  color: #fff;
}
//secondary.module.scss
.secondary {
  background-color: #f44336;
  color: #fff;
}
//tertiary.module.scss
.tertiary {
  background-color: #ff9800;
  color: #fff;
}

Como o dynamic import se baseia em promises, precisaremos utilizar algumas artimanhas para lidar com seu comportamento assincrono, por isso o uso do useEffect e useState se fazem necessários neste contexto.

//src/components/test-1/dynamic/index.tsx
import React, { useEffect, useState } from 'react';

interface DynamicButtonProps {
  children: React.ReactNode;
  state: 'primary' | 'secondary' | 'tertiary';
}

const DynamicButton = ({ children, state }: DynamicButtonProps) => {
  const [style, setStyle] = useState({});

  useEffect(() => {
    const handleStyle = async () => {
      switch (state) {
        case 'primary':
          setStyle((await import('./primary.module.scss')).default);
          break;
        case 'secondary':
          setStyle((await import('./secondary.module.scss')).default);
          break;
        case 'tertiary':
          setStyle((await import('./tertiary.module.scss')).default);
          break;
        default:
          break;
      }
    };
    handleStyle();
  }, [state]);

  return <button className={style[state]}>{children}</button>;
};

export default DynamicButton;

Chamando esse componente na página, temos:

// pages/test-1-b.tsx
import React from 'react';
import DynamicButton from '../src/components/test-1/dynamic';

const test = () => {
  return (
    <div>
      Hello worldssss <DynamicButton state="tertiary">Tertiary</DynamicButton>
    </div>
  );
};

export default test;

Na imagem abaixo podemos ver que o novo teste se mostra um sucesso, o coverage vai para 100% mostrando que o tree shaking funciona conforme o esperado.

Embora não seja nada prático escrever um componente dessa forma, essa prova de conceito nos mostra que a ideia de lazy loading tem um potencial de ser utilizada, daí que entra o segundo caso de teste.

Teste 2 - Um exemplo mais prático

Um exemplo com maior potencial de ganho são os componentes que sofrem grandes transformações na sua árvore de componentes, como por exemplo um card que varia os itens internos e cores dependendo da categoria.

Versão A - Sem codesplit

O Componente principal do card recebe uma prop e dependendo do valor da mesma, ele renderiza um componente interno diferente.

// src/components/test-2/static/index.tsx
import React from 'react';

import style from './static.module.scss';

// Imports fixos
import Car from './sub/car';
import Processor from './sub/processor';
import Book from './sub/book';

interface StaticCardProps {
  type: 'processor' | 'car' | 'book';
}

const StaticCardComponent = ({ type }: StaticCardProps) => {
  return (
    <div className={style.card}>
      {type === 'car' && <Car />}
      {type === 'processor' && <Processor />}
      {type === 'book' && <Book />}
    </div>
  );
};

export default StaticCardComponent;

Vou colocar apenas um como exemplo, os outros você pode encontrar no repo do github.

// src/components/test-2/static/sub/book.tsx
import React from 'react';

import style from '../static.module.scss';

const book = () => {
  return (
    <div className={style.book}>
      <h2 className={style.title}>Book</h2>
      <p className={style.editor}>
        Editor: <label>Planet</label>
      </p>
      <p className={style.ean}>
        EAN: <label>ADS234556</label>
      </p>
      <p className={style.price}>
        Price: <label>R$ 87</label>
      </p>
    </div>
  );
};

export default book;

Já nosso arquivo scss se encontra da seguinte forma:

.card {
  height: 100%;
  width: 200px;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  padding: 20px;
}

.car {
  background-color: aquamarine;
  padding: 0 20px 20px;
}
.processor {
  background-color: bisque;
  padding: 0 20px 20px;
}
.book {
  background-color: cornsilk;
  padding: 0 20px 20px;
}

.title {
  font-size: 24px;
  font-weight: bold;
  color: #333;
}

.item {
  font-size: 16px;
  font-weight: bold;
  & > label {
    font-weight: normal;
    font-style: italic;
  }
}

.generation {
  color: blue;
}

.price {
  font-size: 20px;
  font-weight: bold;
  color: red;
}

Podemos perceber que no nosso arquivo scss temos propriedades que não existem no nosso arquivo book.tsx e vice-versa. Vamos colocar isso em uma página e ver o que acontece.

// pages/test-2-a.tsx
import React from 'react';
import StaticCardComponent from '../src/components/test-2/static';

const test2A = () => {
  return (
    <div>
      <StaticCardComponent type="book" />
    </div>
  );
};

export default test2A;

Olhando na nova imagemm, as classes que são usadas nos tipos de processor.tsx e car.tsx não estão sendo utilizadas, levando nosso coverage para baixo.

Versão B - Utilizando Next/Dynamic

Como já possuímos sub componentes separados torna nossa tarefa de utilizar o next/dynamic muito mais simples, bastando apenas trocar a forma que o import é feito (e pequenos ajustes).

Nosso então componente principal ficaria desta forma:

// src/components/test-2/dynamic/index.tsx
import React from 'react';
import dynamic from 'next/dynamic';

import style from './dynamic.module.scss';

interface StaticCardProps {
  type: 'processor' | 'car' | 'book';
}

const DynamicCar = dynamic(() => import('./sub/car'));
const DynamicProcessor = dynamic(() => import('./sub/processor'));
const DynamicBook = dynamic(() => import('./sub/book'));

const StaticCardComponent = ({ type }: StaticCardProps) => {
  return (
    <div className={style.card}>
      {type === 'car' && <DynamicCar />}
      {type === 'processor' && <DynamicProcessor />}
      {type === 'book' && <DynamicBook />}
    </div>
  );
};

export default StaticCardComponent;

E a folha de estilos principal ficaria apenas com os estilos comuns a todos (os que são usados apenas no index.tsx)

// src/components/test-2/dynamic/dynamic.module.scss
.card {
  height: 100%;
  width: 200px;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  padding: 20px;
}

.title {
  font-size: 24px;
  font-weight: bold;
  color: #333;
}

.item {
  font-size: 16px;
  font-weight: bold;
  & > label {
    font-weight: normal;
    font-style: italic;
  }
}

.price {
  font-size: 20px;
  font-weight: bold;
  color: red;
}

Voltando ao exemplo do book.tsx, ele além de poder se utilizar dos arquivos base (dynamic.module.scss), ele importaria seus proprios estilos (book.module.scss).

import React from 'react';

import styleBase from '../dynamic.module.scss';
import localStyle from './book.module.scss';

const book = () => {
  return (
    <div className={localStyle.book}>
      <h2 className={styleBase.title}>Book</h2>
      <p className={styleBase.item}>
        Editor: <label>Planet</label>
      </p>
      <p className={styleBase.item}>
        EAN: <label>ADS234556</label>
      </p>
      <p className={styleBase.price}>
        Price: <label>R$ 87</label>
      </p>
    </div>
  );
};

export default book;

Estilo do book.tsx

// src/components/test-2/dynamic/sub/book.module.scss
.book {
  background-color: cornsilk;
  padding: 0 20px 20px;
}

Empacotando em uma página e testando

import React from 'react';
import DynamicCardComponent from '../src/components/test-2/dynamic';

const test2A = () => {
  return (
    <div>
      <DynamicCardComponent type="book" />
    </div>
  );
};

export default test2A;

A primeira novidade que podemos notar aqui é que agora não temos apenas 1, mas 2 arquivos de css sendo enviados para o browser.

O primeiro arquivo podemos notar que temos todos as classes que são utilizadas para montar o card, é o nosso arquivo dynamic.module.scss, e que lindo, 100% de coverage.

Já o segundo arquivo tem a classe específica do nosso Book, e nada das outras variações, ou seja, 100% de coverage também 🤩.

Palavras finais

Otimizar a aplicação pode parecer uma tarefa ingrata, pois leva-se tempo para realizar pequenas melhorias e mais tempo ainda para que, de pouco em pouco, elas realmente tomem algum tipo de proporção de impacto na sua aplicação.

Porém o contrário não é verdade, bastam pequenos descuidos para que sua aplicação entre em uma espiral de perda de desempenho e vá ficando cada vez mais lenta, até que, um refactor geral e urgente seja iminente.