Reduzir payloads de JavaScript com tree shaking

Os aplicativos da Web atuais podem ficar bem grandes, principalmente a parte em JavaScript. Em meados de 2018, o HTTP Archive estimou o tamanho médio de transferência de JavaScript em dispositivos móveis em 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 é muito maior depois que o navegador descompacta o código. Isso é importante porque, 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 sejam aproximadamente 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 fazer o download e executar JavaScript. Embora o tamanho da transferência do script seja de 300 KB compactados, ainda são 900 KB de JavaScript que precisam ser analisados, compilados e executados.

O JavaScript é um recurso caro para processar. Ao contrário das imagens, que só incorrem em um tempo de decodificação relativamente trivial depois de baixadas, o JavaScript precisa ser analisado, compilado e, finalmente, executado. 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 consome muito mais recursos byte a 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 as melhorias sejam feitas continuamente para aumentar a eficiência dos mecanismos JavaScript, melhorar a performance do JavaScript é, como sempre, uma tarefa para desenvolvedores.

Para isso, existem técnicas que melhoram o desempenho do JavaScript. O separação de código é uma dessas técnicas que melhora a performance ao particionar o JavaScript do aplicativo em partes e veicular essas partes apenas para as rotas de um aplicativo que precisam delas.

Embora essa técnica funcione, ela não resolve um problema comum de aplicativos com muito 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ódigo inoperante. O termo foi popularizado pelo Rollup, mas o conceito de eliminação de código morto existe há algum tempo. O conceito também foi adotado no webpack, demonstrado neste artigo com um app de exemplo.

O termo "tree shaking" vem do modelo mental do aplicativo e das dependências dele como uma estrutura semelhante a uma árvore. Cada nó na árvore representa uma dependência que fornece funcionalidade distinta para seu app. Em apps modernos, essas dependências são incluídas por instruções import estáticas, assim:

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

Quando um app é novo, ele pode ter poucas dependências. Ele também usa a maioria, se não todas, as dependências que você adiciona. No entanto, à medida que o app amadurece, mais dependências podem ser adicionadas. Para piorar, dependências mais antigas caem em desuso, mas podem não ser removidas da sua base de código. O resultado final é que um app acaba sendo lançado com muito JavaScript não utilizado. O tree shaking resolve isso aproveitando como as instruções estáticas import 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, 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 remover exportações de módulos ES6 que não foram importados explicitamente, reduzindo o tamanho desses builds. Neste guia, você vai aprender a fazer isso.

Encontrar oportunidades para sacudir uma árvore

Para fins ilustrativos, um exemplo de app de uma página está disponível para demonstrar como o tree shaking funciona. Você pode clonar e acompanhar se quiser, mas vamos abordar todas as etapas juntos neste guia. Portanto, não é necessário clonar, a menos que você prefira 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 aparece.

Captura de tela de um exemplo de aplicativo de página única para pesquisar 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 em fornecedor (ou seja, Preact e Emotion) e pacotes de código específicos do app (ou "pedaços", como o webpack os chama):

Captura de tela de dois pacotes (ou partes) de código do 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, ou seja, otimizados por ofuscação. 21,1 KB para um pacote específico do app não é ruim, mas é importante observar que não há remoção de código não utilizado. Vamos analisar o código do app e ver o que pode ser feito para corrigir isso.

Em qualquer aplicativo, encontrar oportunidades de tree shaking envolve procurar instruções import estáticas. Perto da parte de cima do arquivo do componente principal, você vai encontrar uma linha como esta:

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

É possível importar módulos ES6 de várias maneiras, mas algumas como esta devem chamar sua atenção. Essa linha específica diz "import tudo do módulo utils e coloque em um namespace chamado utils". A grande questão aqui é: "quanto conteúdo há nesse módulo?"

Se você analisar 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 novamente pesquisando o arquivo do 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 três resultados.
O namespace utils, de onde importamos vários módulos, é invocado apenas três vezes no arquivo do componente principal.

Acontece que o namespace utils aparece em apenas três pontos do nosso aplicativo, mas para quais funções? Se você olhar o arquivo do componente principal novamente, vai notar que ele tem apenas uma função, que é utils.simpleSort. Ela é 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 muito JavaScript não utilizado.

Embora esse app de exemplo seja um pouco forçado, isso 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 a remoção de código não utilizado ser útil, como ela é feita?

Impedir que o Babel transpile módulos ES6 para módulos CommonJS

O Babel é uma ferramenta indispensável, mas pode dificultar um pouco a observação dos efeitos do tree shaking. Se você estiver usando @babel/preset-env, o Babel poderá transformar módulos ES6 em módulos CommonJS mais compatíveis, ou seja, módulos que você require em vez de import.

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

// 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, permitindo que o webpack analise sua árvore de dependências e elimine as não utilizadas.

Considerando os efeitos colaterais

Outro aspecto a ser considerado ao eliminar 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, o que é um efeito colateral da execução dela:

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 ao modificar a matriz fruits, que está fora do escopo dela.

Os efeitos colaterais também se aplicam aos módulos ES6, o que é importante no contexto da eliminação de código não utilizado. 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 estiverem sendo usadas. São partes de código modulares e independentes. Por isso, "módulos".

No webpack, uma dica pode ser usada para especificar que um pacote e suas dependências não têm efeitos colaterais. Para isso, especifique "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 são livres de efeitos colaterais:

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

No último exemplo, qualquer arquivo não especificado será considerado livre de 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.

Importar apenas o que é necessário

Depois de instruir o Babel a não mexer nos 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, tudo o que é necessário é 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á ser alterada 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 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 ser concluído:

                 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 main que mais se beneficia. Ao remover as partes não utilizadas do módulo utils, o pacote main diminui em cerca de 60%. Isso reduz não apenas o tempo de download do script, mas também o tempo de processamento.

Vamos sacudir umas árvores!

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

Você pode perceber um ganho significativo de desempenho com o tree shaking ou não notar muita diferença. Mas, 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 proativamente os pacotes de aplicativos o menor possível.

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