GILVIEIRADEV

Efeitos Negativos do uso de Barrel Files

Efeitos Negativos do uso de Barrel Files

Vamos imaginar que você está trabalhando em um grande projeto com muitos arquivos. Você adiciona um novo arquivo para trabalhar em uma nova funcionalidade e importa uma função de outro diretório para o seu código.

import { foo } from "./algum/outro-arquivo";

export function meuCodigoLegal() {
  // Vamos fingir que este é um código super inteligente :)
  const resultado = foo();
  return resultado;
}

Animado para terminar sua funcionalidade, você executa o código e percebe que está levando um tempo terrivelmente longo para ser concluído. O código que você escreveu é bastante simples e não deveria demorar tanto. Preocupado com isso, você adiciona algum código de medição para ver quanto tempo sua função leva para fazer o que deve.

import { foo } from "./algum/outro-arquivo";

export function meuCodigoLegal() {
  console.time();
  const resultado = foo();
  console.timeEnd();
  return resultado;
}

Você executa o código novamente e, para sua surpresa, as medições que você inseriu mostram que ele é extremamente rápido. Você repete as etapas de medição, mas desta vez insere as declarações console.time() no arquivo de entrada principal do seu projeto e executa o código novamente. Mas não há sorte, as medições registradas apenas confirmam que seu código em si é super rápido. O que está acontecendo?

Bem, se prepare. Esta é a história dos efeitos devastadores dos barrel files no seu código.

Obtendo mais informações

A peça-chave de informação que obtivemos até agora é que o tempo de execução do código não é o problema. Você mediu isso e foi uma fração do tempo total. Isso significa que podemos assumir que todo o outro tempo é desperdiçado antes ou depois de executar nosso código. Por experiência, quando se trata de ferramentas, o tempo geralmente é gasto antes de executar o código do projeto.

Você teve uma ideia: lembra de ter ouvido falar que alguns pacotes npm pré-compilam seu código por motivos de desempenho. Talvez isso possa ajudar aqui? Você decide testar essa teoria e compilar seu código com o esbuild em um único arquivo. Você desativa intencionalmente qualquer forma de minificação, porque quer que seu código fique o mais próximo possível da fonte original.

Após a conclusão, você executa o arquivo compilado para repetir o experimento e voilà, ele é concluído num piscar de olhos. Por curiosidade, você mede o tempo que leva para executar o esbuild e executar o arquivo compilado juntos e percebe que ambos combinados ainda são mais rápidos do que executar o código da fonte original. Hein? O que está acontecendo?

Então você percebe: A principal coisa que um compilador faz é achatr e mesclar o grafo de módulos. O que antes era composto por milhares de arquivos é mesclado em um único arquivo graças ao esbuild. Isso seria um forte indicador de que o tamanho do grafo de módulos é o verdadeiro problema aqui. E os barrel files são a principal causa disso.

Anatomia de um barrel file

barrel files são arquivos que apenas exportam outros arquivos e não contêm código por si mesmos. Como um não falante nativo, esse termo é confuso para mim, mas vamos seguir com isso. Nos dias antes de os editores terem importações automáticas e outras comodidades, muitos desenvolvedores tentavam manter o número de declarações de importação que precisavam escrever à mão no mínimo.

// Veja todas essas importações
import { foo } from "../foo";
import { bar } from "../bar";
import { baz } from "../baz";

Isso deu origem a um padrão em que cada pasta tinha seu próprio arquivo index.js que apenas reexportava código de outros arquivos, geralmente no mesmo diretório. De certa forma, isso amortizava o trabalho de digitação manual, porque uma vez que esse tipo de arquivo estava no lugar, todo o outro código só precisava fazer referência a uma declaração de importação.

// feature/index.js
export * from "./foo";
export * from "./bar";
export * from "./baz";

As declarações de importação mostradas anteriormente podem agora ser agrupadas em uma única linha.

import { foo, bar, baz } from "../feature";

Depois de um tempo, esse padrão se espalha por todo o código e cada pasta em seu projeto tem um arquivo index.js. Bastante inteligente, não é? Bem, não.

As coisas não estão bem Nesse tipo de configuração, um módulo muito provavelmente importa outro arquivo barrel, que traz um monte de outros arquivos, que então importa outro arquivo barrel e assim por diante. No final, você acaba importando praticamente todos os arquivos do seu projeto através de uma teia de declarações de importação. E quanto maior o projeto, mais tempo leva para carregar todos esses módulos.

Pergunte a si mesmo: o que é mais rápido? Ter que carregar 30 mil arquivos ou apenas 10? Provavelmente, carregar apenas 10 arquivos é mais rápido.

É uma concepção comum entre os desenvolvedores de JavaScript que os módulos só seriam carregados quando necessário. Isso não é verdade, porque fazer isso quebraria o código que depende de variáveis globais ou da ordem de execução do módulo.

// a.js
globalThis.foo = 123;

// b.js
console.log(globalThis.foo); // deveria registrar: 123

// index.js
import "./a";
import "./b";

Se o mecanismo não carregasse a primeira importação ./a, o código registraria inesperadamente undefined em vez de 123.

Efeitos dos barrel files no desempenho

Fica pior quando se consideram ferramentas como test runners. No popular test runner jest, cada arquivo de teste é executado em seu próprio processo filho. Na prática, isso significa que cada arquivo de teste constrói o grafo de módulos do zero e tem que pagar por esse custo. Se a construção do grafo de módulos em um projeto leva 6 segundos e você tem - digamos - apenas 100 arquivos de teste, então você desperdiça 10 minutos no total para construir repetidamente o grafo de módulos. Nenhum teste ou qualquer outro código é executado durante esse tempo. É apenas o tempo que o mecanismo precisa para preparar o código fonte para que ele possa ser executado em seguida.

Outra área em que os barrel files afetam gravemente o desempenho são quaisquer tipos de regras de linting para ciclos de importação. Normalmente, os linters são executados em uma base de arquivo por arquivo, o que significa que o custo de construir o grafo de módulos precisa ser pago para cada arquivo. Não é incomum que isso sozinho faça com que os tempos de linting fiquem fora de controle e de repente o linting leva algumas horas em um projeto maior.

Para obter alguns números brutos, gerei um projeto com arquivos que se importam mutuamente para ter uma ideia melhor do custo de construir o grafo de módulos. Cada arquivo está vazio e não contém código além das declarações de importação. Os tempos são medidos no meu MacBook M1 Air (2020).

Carregar 500 módulos vazios leva 0,15s, 1000 levam 0,31s, 10000 levam 3,12s, 25000 levam 16,81s, 50000 levam 48,44s.

Como você pode ver, carregar menos módulos vale muito a pena. Vamos aplicar esses números a um projeto com 100 arquivos de teste em que um test runner é usado e que gera um novo processo filho para cada arquivo de teste. Sejamos generosos aqui e digamos que nosso test runner pode executar 4 testes em paralelo:

500 módulos: 0,15s * 100 / 4 = 3,75s de sobrecarga
1000 módulos: 0,31s * 100 / 4 = 7,75s de sobrecarga
10000 módulos: 3,12s * 100 / 4 = 1:18 min de sobrecarga
25000 módulos: 16,81s * 100 / 4 = ~7:00 min de sobrecarga
50000 módulos: 48,44s * 100 / 4 = ~20:00 min de sobrecarga

Como esta é uma configuração sintética, esses números são subestimados. Em um projeto real, esses números provavelmente são piores. Os barrel files não são bons quando se trata de desempenho de ferramentas.

O que fazer Ter apenas alguns barrel files no seu código geralmente está bem, mas se torna problemático quando cada pasta tem um. Infelizmente, isso não é uma ocorrência rara na indústria de JavaScript.

Então, se você trabalha em um projeto que usa extensivamente barrel files, há uma otimização gratuita que você pode aplicar e que torna muitas tarefas 60-80% mais rápidas:

Eliminar todos os barrel files.