Code splitting com React.lazy e Suspense

Victor Barros
6 min readJun 14, 2020

Como reduzir o tempo inicial de carregamento da aplicação com uma abordagem simples de code splitting e carregamento de componentes

O que você vai encontrar neste artigo:

  • Função simples de utilização do React.lazy e Suspense em componentes ou containers React
  • Centralização do controle de erros com Error Boundaries (utilizando Sentry)
  • Tentativa de carregamento em caso de falha na importação
  • Padronização de fallbacks que podem melhorar a experiência do usuário durante o carregamento dos componentes

Lembrando que o projeto deve estar devidamente configurado para suportar code splitting, no meu caso, utilizo o webpack 4 que possui excelentes configurações nativas para esta estratégia.

Esta solução de code splitting com React.lazy e Suspense, ambas funcionalidades nativas do React, me trouxeram como principais benefícios a velocidade de implementação e ajuste da aplicação já em produção, além da melhoria na usabilidade e tempo de carregamento da aplicação. Também consegui explorar a padronização na utilização de Error Boundaries em todos os componentes, evitando a "quebra total" da experiência para o usuário devido a eventuais erros ou bugs.

A estratégia de implementação pode ser concentrada em uma pasta utils e ser levada para as importações de componentes. A inspiração e ponta pé inicial da estratégia veio do projeto react-boilerplate, que costumo recomendar devido a quantidade de boas práticas e conhecimento acumulado aplicado pela comunidade.

Neste exemplo também fiz utilização do Sentry para log de erros da aplicação, porém não irei aprofundar esta implementação neste artigo.

A implementação envolve a criação de 3 arquivos no projeto, que no meu caso, ficaram centralizadas em uma pasta utils e é consumida em todos os módulos do projeto.

  • loadable.js função que será chamada em todas as importações de componentes ou containers;
  • retry.js função assíncrona responsável pelo carregamento da importação e novas tentativas caso exista alguma falha na chamada do lado do cliente;
  • ErrorBoundary.js sendo o componente responsável em capturar os erros e mostrar um componente alternativo, evitando "quebrar toda" a aplicação para o usuário.

A função loadable é bastante simples, e tem como premissa:

  • Deve receber uma função de importação no formato: () => import(‘./Component’)
  • Pode receber o objeto options, que neste exemplo implementei apenas o fallback para exibir um componente de carregamento enquanto o componente final será baixado

A função de importação do componente (importFunc) será passada para a função de apoio retry, que será responsável por tentar o carregamento do arquivo importado até um determinado limite de vezes (neste caso 5, podendo ser alterado) antes de desistir e propagar um erro (reject). Está é uma estratégia importante considerando a aplicação em produção, uma vez que intermitências na conexão do usuário poderiam prejudicar o carregamento dos componentes.

Para evitar importar dezenas de vezes o ErrorBoundary ao longo dos componentes e containers da minha aplicação, tomei a decisão de implementar o componente entre o Suspense e o LazyComponente, de tal forma a garantir que todos meus componentes carregados de forma assíncrona estejam devidamente protegidos e não propaguem o erro para toda a aplicação.

Caso não esteja familiarizado com a estratégia de Error Boundaries do React, recomendo fortemente a leitura da documentação aqui. Essa estratégia é fundamental para uma boa usabilidade e experiência do usuário e evitar que sua aplicação pare de funcionar devido a erros pontuais de alguns componentes.

No passado, erros de JavaScript dentro de componentes costumavam corromper o estado interno do React e fazê-lo emitir erros incompreensíveis nas próximas renderizações. Estes erros eram causados por um erro anterior no código da aplicação, mas o React não fornecia um meio para tratá-los de forma graciosa nos componentes e não conseguia se recuperar deles.

Este componente exemplo também faz uso do Sentry, que é responsável por rastrear todos os erros da minha aplicação. A utilização do Sentry requer a abertura de uma conta, além da instalação do pacote na apliacação e inicialização. Mais informações podem ser encontradas aqui. Caso não utilize o Sentry em sua aplicação, só basta remover a função do Sentry.withScope no bloco componentDidCatch.

Agora existem duas formas de utilizarmos esta implementação em nossa aplicação.

Loadable.js

O projeto react-boilerplate trouxe este pattern que gosto muito, pois permite que qualquer pasta com componente ou container possua um arquivo Loadable.js que será responsável pelo carregamento assíncrono do componente. Um exemplo ficaria assim:

- containers/HomePage
--| index.js
--| Loadable.js

O arquivo Loadable.js deverá ser escrito desta forma:

import { loadable } from '../../utils';export default loadable(() => import('./index'), {});

Desta forma, a importação do meu container na aplicação poderá ser feita de duas formas diferentes dependendo da necessidade.

Opção 1 — carregamento síncrono

import HomePage from './containers/HomePage';

Opção 2 — carregamento assíncrono

import HomePage from './container/HomePage/Loadable';

Sendo que o caminho 1 é a importação síncrona, e a opção 2 seria a importação assíncrona e consequentemente teria como resultado o code splitting. Lembre-se que o webpack é bastante inteligente, e levará em consideração na decisão de code splitting durante a compilação a chamada de entrada da importação, ou seja, se você se utilizar dos dois formatos de importação em sua aplicação, poderá comprometer o code splitting do container, tornando inútil a implementação.

Quando utilizar a opção 1 ou a opção 2? Depende, se for aceitável para a experiência do usuário aguardar um tempo de carregamento do componente antes da interface ficar disponível para interação, a opção 2 é a melhor escolha. Caso a resposta seja negativa, então a opção 1 será a melhor escolha, pois tornará o componente disponível juntamente com todo o restante que está sendo chamado de forma síncrona.

index.js

Esta implementação praticamente tornará obrigatória a utilização do carregamento assíncrono do componente ou do container, e pressupõe que o arquivo index.js centralize a importação de 1 ou mais componentes ou containers da pasta.

Me deparei com a necessidade de criar componentes com um nível maior de complexidade, então minha estrutura ficou desta forma:

- components/Product
--| index.js
--| Card.js
--| Image.js
--| Description.js

Logo, para me aproveitar desta estratégia, precisei ajustar o meu arquivo index.js para que lidasse corretamente com a importação assíncrona destes componentes conforme eu precisasse.

components/Product/index.js

import { loadable } from '../../utils';export const Card = loadable(() => import('./Card'), {});
export const Image = loadable(() => import('./Image'), {});
export const Description = loadable(() => import('./Description'), {});

Desta forma, a utilização a importação individual dos meus componentes poderão ser feitas em qualquer lugar da aplicação desta forma:

import { Card, Image, Description } from './components/Product';

Ou também da seguinte maneira:

import * as Product from './components/Product';

Este simples ajuste no projeto me ajudou a reduzir em mais de 40% o tamanho do meu arquivo inicial de entrada do meu projeto, tornando o carregamento da primeira experiência do usuário muito mais rápida e adequada para conexões mais lentas.

A equipe de engenharia de software do Facebook fez este post recentemente sobre as estratégias utilizadas no novo stack de tecnologias que adotaram para a nova sua versão. Merece destaque as estratégias de code splitting que adotaram, que no caso, criam uma experiência do usuário ainda mais incrível.

Code-splitting JavaScript for faster performance

Code size is one of the biggest concerns with a JavaScript-based single-page app because it has a large influence on page load performance. We knew that if we wanted a client-side React app for Facebook.com, we’d need to solve for this. We introduced several new APIs that work in line with our “as little as possible, as early as possible” mantra.

Padronizando o estado de carregamento do componente

Existe uma melhoria opcional nesta estratégia que pode ser implementada para deixar a experiência do usuário ainda melhor.

A variável fallback da função loadable determina qual é o componente que deve ser carregado na interface enquanto o componente final não for importado. Isso praticamente criaria um efeito como este:

Estado de carregamento da interface enquanto os módulos são importados

Seria possível padronizar um ou mais componentes que poderiam ser utilizados de forma padrão pela função loadable ou também customizados individualmente em cada importação, passando simplesmente um componente durante a importação.

import { loadable } from '../../utils';
import LoaderComponent from '../../components/LoaderComponent'
export default loadable(() => import('./index'), { fallback: <LoaderComponent />});

A interface ficará com o carregamento bem mais amigável e evitará que os blocos dos componentes simplesmente surjam do nada na interface do usuário.

Referências

--

--

Victor Barros
Victor Barros

Written by Victor Barros

Entrepreneur, geek, marathon runner, and hobbies from how to get a recipe for tomato sauce, nature, space exploration or AI

No responses yet