Reduzir payloads de JavaScript com tree shaking

Os aplicativos da Web atuais podem ficar muito grandes, especialmente a parte JavaScript deles. Desde meados de 2018, o HTTP Archive coloca o tamanho de transferência médio do JavaScript em dispositivos móveis em aproximadamente 350 KB. Isso é apenas o tamanho da transferência. O JavaScript geralmente é compactado quando enviado pela rede, o que significa que a quantidade real de JavaScript é bem 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 são 900 KB para o analisador e o compilador, mesmo que possam ter cerca de 300 KB quando compactados.

Um diagrama que ilustra o processo de download, descompactação, análise, compilação e execução de JavaScript.
O 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.

O JavaScript é um recurso caro de processar. Ao contrário das imagens que só geram um tempo de decodificação relativamente trivial após o download, o JavaScript precisa ser analisado, compilado e executado. Byte por byte, o que torna o JavaScript mais caro do que outros tipos de recursos.

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

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

Para isso, existem técnicas para melhorar o desempenho do JavaScript. A divisão de código é uma técnica 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 resolve um problema comum de aplicativos com uso intenso de 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 eliminar códigos inativos. O termo foi popularizado pela Rollup, mas o conceito de eliminação de códigos inativos já existe há algum tempo. O conceito também foi encontrado em webpack, que é demonstrado neste artigo como um app de exemplo.

Termo "tree shaking" vem 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 uma funcionalidade distinta para o app. Em apps modernos, essas dependências são trazidas por instruções import estáticas, como a seguir:

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

Quando um aplicativo é jovem (um broto, por exemplo), ele pode ter poucas dependências. Ele também está usando a maioria (se não todas) das dependências adicionadas. No entanto, à medida que o app é desenvolvido, mais dependências podem ser adicionadas. Para aumentar a importância, as dependências mais antigas ficam fora de uso, mas podem não ser removidas da sua base de código. O resultado final é que um app acaba sendo carregado com muito JavaScript não utilizado. O Tree shaking resolve isso aproveitando a forma como as instruções import estáticas extraem partes específicas de 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", que pode exigir muito código, este exemplo importa apenas partes específicas dele. Em builds de desenvolvimento, 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 "agitar" as exportações de módulos ES6 que não foram importadas explicitamente, o que reduz o tamanho dos builds de produção. Neste guia, você vai aprender a fazer isso.

Encontrando oportunidades para sacudir uma árvore

Para fins ilustrativos, há um exemplo de aplicativo de uma página que demonstra como o tree shaking funciona. Você pode clonar e acompanhar se quiser, mas abordaremos cada etapa do caminho juntos neste guia, portanto, a clonagem não é necessária (a menos que você goste de aprender na prática).

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

Captura de tela de um exemplo de aplicativo 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 o webpack os chama):

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

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

Em qualquer aplicativo, para encontrar oportunidades de tree shaking, é preciso procurar instruções import estáticas. Próximo à parte superior do arquivo de componentes principal, você verá uma linha como esta:

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

Você pode importar módulos ES6 de várias maneiras, mas algumas como essa devem chamar sua atenção. Essa linha específica diz "import tudo do módulo utils e o coloca em um namespace chamado utils". A grande pergunta a ser feita é "quantas coisas têm naquele módulo?".

Se você observar o código-fonte do módulo utils, vai encontrar cerca de 1.300 linhas de código.

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

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

O namespace utils aparece em apenas três locais no nosso aplicativo, mas para quais funções? Se você analisar o arquivo de componente principal novamente, ele parece ter apenas uma função, utils.simpleSort, que é 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 uma grande quantidade de JavaScript não utilizado.

Embora esse exemplo de aplicativo seja um pouco inventado, 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 aplicativo da Web de produção. Agora que você identificou uma oportunidade para que o tree shaking seja útil, como isso é feito?

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

O Babel é uma ferramenta indispensável, mas pode dificultar a observação dos efeitos do balanço de árvores. Se você estiver usando o @babel/preset-env, o Babel pode transformar módulos ES6 em módulos CommonJS mais amplamente compatíveis, ou seja, módulos que você usa 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 podar dos pacotes se você decidir usá-los. A solução é configurar @babel/preset-env para deixar explicitamente os módulos ES6 em paz. Não importa onde o Babel é configurado, seja no babel.config.js ou no package.json, é preciso adicionar um pouco mais:

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

Especificar modules: false na configuração do @babel/preset-env faz com que o Babel se comporte da forma esperada, o que permite que o webpack analise a árvore de dependências e remova dependências não utilizadas.

Pensando nos efeitos colaterais

Outro aspecto a ser considerado ao agitar dependências do seu aplicativo é se módulos do seu projeto têm efeitos colaterais. Um exemplo de efeito colateral é quando uma modifica algo fora de seu próprio escopo, o 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, e isso é importante no contexto de tree shaking. Módulos que recebem entradas previsíveis e produzem saídas igualmente previsíveis sem modificar nada fora do escopo são dependências que podem ser descartadas com segurança se não forem usadas. Eles são trechos de código modulares e independentes. Portanto, usamos "módulos".

No caso do webpack, uma dica pode ser usada para especificar que um pacote e as dependências dele não têm efeitos colaterais. Para isso, basta especificar "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 isentos de efeitos colaterais:

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

No último exemplo, presume-se 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.

Importação apenas do necessário

Depois de instruir o Babel a não ignorar os módulos ES6, é necessário fazer um pequeno ajuste na sintaxe import para trazer apenas as funções necessárias do módulo utils. No exemplo deste guia, basta usar a função simpleSort:

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

Como apenas simpleSort está sendo importado, em vez de todo o módulo utils, cada instância de utils.simpleSort precisará mudar 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 é necessário para que o tree shaking funcione neste exemplo. Esta é a saída do webpack antes de agitar 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 o 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, é o pacote main que mais se beneficia. Ao sacudir as partes não usadas do módulo utils, o pacote main diminui em cerca de 60%. Isso não apenas reduz o tempo que o script leva para fazer o download, mas também o tempo de processamento.

Vai chacoalhar algumas árvores!

A quilometragem obtida com o tree shaking depende do aplicativo, das dependências e da arquitetura dele. Confira! Se você sabe de fato que não configurou seu bundler de módulo para realizar essa otimização, não há problema em tentar e ver como isso beneficia seu aplicativo.

Você pode ter um ganho de desempenho significativo com o tree shaking, ou não muito. No entanto, ao configurar seu sistema de build 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 o menor possível de maneira proativa.

Um agradecimento especial a Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone e Philip Walton pelos comentários valiosos, que melhoraram significativamente a qualidade deste artigo.