Reduzir payloads de JavaScript com tree shaking

Os aplicativos da web de hoje podem ficar muito grandes, especialmente a parte JavaScript deles. Em meados de 2018, o HTTP Archive colocou o tamanho médio de transferência do JavaScript em dispositivos móveis (link em inglês) de aproximadamente 350 KB. E isso é apenas o tamanho da transferência. O JavaScript geralmente é compactado quando enviado pela rede, o que significa que a quantidade real de JavaScript é um pouco maior depois que o navegador o descompacta. É importante ressaltar que, no que diz respeito ao processamento de recursos, a compactação é irrelevante. 900 KB de JavaScript descompactado ainda equivalem a 900 KB para o analisador e o compilador, embora possa ter aproximadamente 300 KB quando compactado.

Um diagrama que ilustra o processo de download, descompactação, análise, compilação e execução de JavaScript.
Processo de download e execução do JavaScript. Embora o tamanho da transferência do script seja de 300 KB compactado, ele ainda tem 900 KB de JavaScript que precisam ser analisados, compilados e executados.

JavaScript é um recurso caro para ser processado. Ao contrário das imagens, que só geram um tempo de decodificação relativamente trivial após o download, é preciso analisar, compilar e executar o JavaScript. Byte por byte, isso torna o JavaScript mais caro do que outros tipos de recursos.

Um diagrama comparando o tempo de processamento de 170 KB de JavaScript com uma imagem JPEG de tamanho equivalente. O recurso JavaScript usa muito mais recursos como byte por byte do que o JPEG.
O custo de processamento da análise/compilação de 170 KB de JavaScript em comparação com o tempo de decodificação de um JPEG de tamanho equivalente. (fonte).

Embora estejam sendo feitas melhorias continuamente para aumentar a eficiência dos mecanismos JavaScript, melhorar o desempenho do JavaScript é, como sempre, uma tarefa para os desenvolvedores.

Para isso, existem técnicas que melhoram o desempenho do JavaScript. A divisão de código é uma das técnicas que melhora o desempenho particionando o JavaScript do aplicativo em blocos e disponibilizando esses blocos apenas para as rotas de um aplicativo que precisam deles.

Embora essa técnica funcione, ela não aborda um problema comum de aplicativos com muitos JavaScript, que é a inclusão de código que nunca é usado. O tree shaking tenta resolver esse problema.

O que é tree shaking?

O tree shaking é uma forma de eliminação de códigos inativos. O termo foi popularizado pela Rollup, mas o conceito de eliminação de código morto já existia há algum tempo. Esse conceito também encontrou compra no webpack, o que é demonstrado neste artigo com um app de exemplo.

O termo "tree shaking" é proveniente do modelo mental do aplicativo e das dependências dele como uma estrutura semelhante a uma árvore. Cada nó da árvore representa uma dependência que oferece funcionalidades distintas ao app. Em apps modernos, essas dependências são trazidas por instruções import estáticas, da seguinte forma:

// Import all the array utilities!
import arrayUtils from "array-utils";

Quando um app é jovem (por exemplo, uma muda, ele pode ter poucas dependências). Ele também está usando a maioria das dependências adicionadas, se não todas. No entanto, à medida que seu app amadurece, mais dependências podem ser adicionadas. Para isso, as dependências mais antigas deixam de ser usadas, mas podem não ser removidas da base de código. O resultado final é que um app acaba tendo muito JavaScript não utilizado. O tree shaking resolve isso aproveitando como as instruções import estáticas extraem partes específicas dos módulos ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

A diferença entre este exemplo de import e o anterior é que, em vez de importar tudo do módulo "array-utils", o que pode ser muito código, ele importa apenas partes específicas dele. Em builds dev, isso não muda nada, já que o módulo inteiro é importado de qualquer maneira. Em builds de produção, o webpack pode ser configurado para "balançar" as exportações de módulos ES6 que não foram importados explicitamente, tornando essas versões de produção menores. Neste guia, você vai aprender a fazer exatamente isso.

Encontrar oportunidades para sacudir uma árvore

Para fins ilustrativos, disponibilizamos um exemplo de aplicativo de uma página que demonstra como funciona o tree shaking. Você pode cloná-la e acompanhar se quiser, mas abordaremos todas as etapas neste guia, portanto, a clonagem não é necessária (a menos que você aprenda na prática).

O app de exemplo é um banco de dados pesquisável dos pedais de efeito de guitarra. Você insere uma consulta e uma lista de pedais de efeito aparece.

Captura de tela de um aplicativo de exemplo de uma página para pesquisar em um banco de dados de pedais de efeito de guitarra.
Uma captura de tela do app de exemplo.

O comportamento que impulsiona esse app é separado por fornecedor (ou seja, Preact e Emotion) e pacotes de código específicos do app (ou "blocos", como os chamados pelo webpack):

Uma captura de tela de dois pacotes de código do aplicativo (ou fragmentos) mostrados no painel de rede do DevTools do Chrome.
Os dois pacotes JavaScript do app. Esses tamanhos não são compactados.

Os pacotes JavaScript mostrados na figura acima são builds de produção, o que significa que são otimizados por uglificação. 21,1 KB para um pacote específico de app não é ruim, mas não está acontecendo o tree shaking. Vamos analisar o código do app e descobrir o que pode ser feito para corrigir isso.

Em qualquer aplicativo, a busca por oportunidades de tree shaking envolve a busca por instruções import estáticas. Perto da parte superior do arquivo do componente principal, você verá uma linha como esta:

import * as utils from "../../utils/utils";

É possível importar módulos ES6 de várias maneiras, mas coisas como essa podem chamar sua atenção. Essa linha específica diz "import tudo do módulo utils e coloque-o em um namespace chamado utils". A grande pergunta a ser feita aqui é "quantas coisas tem nesse módulo?"

Você verá que há cerca de 1.300 linhas de código no código-fonte do módulo utils.

Você precisa de tudo isso? Vamos verificar novamente pesquisando o arquivo de componente principal que importa o módulo utils para ver quantas instâncias desse namespace são exibidas.

Captura de tela de uma pesquisa por "utils." em um editor de texto, retornando apenas três resultados.
O namespace utils do qual importamos toneladas de módulos é invocado apenas três vezes no arquivo do componente principal.

O namespace utils aparece em apenas três pontos do aplicativo, mas para quais funções? Se você observar o arquivo do componente principal novamente, ele parece ser apenas uma função, utils.simpleSort, usada para classificar a lista de resultados da pesquisa por vários critérios quando os menus suspensos de classificação são alterados:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

De um arquivo de 1.300 linhas com várias exportações, apenas uma delas é usada. Isso resulta no envio de muitos JavaScript não utilizados.

Embora esse app de exemplo seja um pouco irreal, ele não muda o fato de que esse tipo de cenário sintético se assemelha a oportunidades reais de otimização que você pode encontrar em um app da Web de produção. Agora que você identificou uma oportunidade para o tree shaking ser útil, como ele pode ser feito?

Como impedir que o Babel transcompile módulos ES6 em módulos CommonJS

O Babel é uma ferramenta indispensável, mas pode dificultar a observação dos efeitos de agitação das árvores. Se você usar @babel/preset-env, o Babel pode transformar os módulos ES6 em módulos CommonJS mais amplamente compatíveis, ou seja, módulos que você require em vez de import.

Como o tree shaking é mais difícil de fazer nos módulos CommonJS, o webpack não sabe o que remover dos pacotes se você decidir usá-los. A solução é configurar @babel/preset-env para deixar explicitamente os módulos ES6 sozinhos. Independentemente de onde você configurar o Babel, seja em babel.config.js ou package.json, isso envolve adicionar algo extra:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Especificar modules: false na configuração @babel/preset-env faz com que o Babel se comporte como desejado, o que permite que o webpack analise sua árvore de dependências e elimine as dependências não usadas.

Considerações para os efeitos colaterais

Outro aspecto a ser considerado ao agitar dependências do app é se os módulos do projeto têm efeitos colaterais. Um exemplo de efeito colateral é quando uma função modifica algo fora do próprio escopo, que é um efeito colateral da execução:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

Neste exemplo, addFruit produz um efeito colateral quando modifica a matriz fruits, que está fora do escopo.

Os efeitos colaterais também se aplicam aos módulos ES6, o que é importante no contexto do tree shaking. Os módulos que recebem entradas previsíveis e produzem saídas igualmente previsíveis sem modificar nada fora do próprio escopo são dependências que podem ser descartadas com segurança se não forem usados. Elas são partes de código modulares e independentes. Portanto, "módulos".

Quando o webpack está relacionado, uma dica pode ser usada para especificar que um pacote e as dependências dele não têm efeitos colaterais, especificando "sideEffects": false no arquivo package.json de um projeto:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

Como alternativa, você pode informar ao webpack quais arquivos específicos não estão livres de efeitos colaterais:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

No último exemplo, será considerado que qualquer arquivo não especificado não tenha efeitos colaterais. Se você não quiser adicionar isso ao arquivo package.json, também é possível especificar essa flag na configuração do webpack usando module.rules.

Você só precisa do que é necessário

Depois de instruir o Babel a deixar os módulos ES6 sozinhos, é necessário fazer um pequeno ajuste na sintaxe import para trazer apenas as funções necessárias do módulo utils. Neste exemplo do guia, só é necessário ter a função simpleSort:

import { simpleSort } from "../../utils/utils";

Como apenas simpleSort está sendo importado em vez de todo o módulo utils, todas as instâncias de utils.simpleSort precisarão ser alteradas para simpleSort:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

Isso é tudo o que é necessário para que o tree shaking funcione neste exemplo. Esta é a saída do webpack antes de balancear a árvore de dependências:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

Esta é a saída após a conclusão do tree shaking:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

Embora os dois pacotes tenham diminuído, é a main que mais se beneficia. Ao absorver as partes não usadas do módulo utils, o pacote main é reduzido em cerca de 60%. Isso não apenas diminui o tempo que o script leva para o download, mas também o tempo de processamento.

Vai chacoalhar as árvores!

O impacto do tree shaking depende do seu app, das dependências e da arquitetura dele. Confira! Se você sabe mesmo que ainda não configurou o bundler de módulos para realizar essa otimização, não tem problema tentar e conferir os benefícios para seu aplicativo.

Talvez você perceba um ganho de desempenho significativo com o tree shaking ou não terá grandes oportunidades. No entanto, ao configurar o sistema de compilação para aproveitar essa otimização em builds de produção e importar seletivamente apenas o que o aplicativo precisa, você vai manter os pacotes de aplicativos os menores possíveis.

Agradecimentos especiais a Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone e Philip Walton pelo feedback valioso, que melhorou significativamente a qualidade deste artigo.