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 coloca o tamanho médio de transferência de JavaScript em dispositivos móveis em aproximadamente 350 KB. E isso é só o tamanho da transferência. O JavaScript geralmente é compactado quando enviado pela rede, o que significa que a quantidade real de JavaScript será um pouco maior depois que o navegador o descompactar. 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 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 compactados, ele ainda tem 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ó geram um tempo de decodificação relativamente trivial após o download, o JavaScript precisa ser analisado, compilado e 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 por byte do que o JPEG.
O custo de processamento de 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).

As melhorias são contínuas para melhorar a eficiência dos mecanismos de JavaScript, mas melhorar a performance do JavaScript é, como sempre, uma tarefa para os desenvolvedores.

Para isso, há técnicas para melhorar a performance do JavaScript. O divisão de código é uma dessas técnicas que melhora a performance ao particionar o JavaScript do aplicativo em partes e servir 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 uso intenso de JavaScript, que é a inclusão de código que nunca é usado. A agitação de árvores tenta resolver esse problema.

O que é tree shaking?

O tree shaking é uma forma de eliminação de código inoperante. O termo foi popularizado por Rollup, mas o conceito de eliminação de código morto existe há algum tempo. O conceito também encontrou compra no webpack, que é demonstrado neste artigo por meio de um app de exemplo.

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

// 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 das dependências adicionadas. No entanto, à medida que o app amadurece, mais dependências podem ser adicionadas. Para piorar as coisas, 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 sendo enviado com muito JavaScript não usado. O tree shaking resolve isso aproveitando a forma 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 esse exemplo de import e o anterior é que, em vez de importar tudo do módulo "array-utils", que pode ser muito código, esse exemplo importa apenas partes específicas dele. Em builds de desenvolvimento, isso não muda nada, já que todo o módulo é importado. Em builds de produção, o webpack pode ser configurado para "sacudir" as exportações de módulos ES6 que não foram explicitamente importados, tornando esses builds de produção menores. Neste guia, você vai aprender a fazer exatamente isso.

Encontrar oportunidades para sacudir uma árvore

Para fins ilustrativos, um app de exemplo de uma página está disponível para demonstrar como o recurso 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 efeitos para 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 "chunks", 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, ou seja, eles são otimizados com a uglification. 21,1 KB para um pacote específico do app não é ruim, mas é importante observar que nenhum tremor de árvore está ocorrendo. Vamos analisar o código do app e ver 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";

É possível importar módulos ES6 de várias maneiras, mas exemplos como esse 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 principal do componente que importa o módulo utils para saber quantas instâncias desse namespace aparecem.

Uma captura de tela de uma pesquisa em um editor de texto para "utils.", que retorna apenas três resultados.
O namespace utils do qual importamos muitos módulos é invocado apenas três vezes no arquivo do componente principal.

O namespace utils aparece em apenas três pontos do 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);
}

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

Embora esse exemplo de aplicativo seja um pouco inventivo, 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 o tree shaking ser útil, como isso pode ser feito?

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

O Babel é uma ferramenta indispensável, mas pode dificultar a observação dos efeitos da agitação da árvore. 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 a redução de árvores é 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 @babel/preset-env para deixar os módulos ES6 em paz. Em qualquer lugar que você configure 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 do @babel/preset-env faz com que o Babel se comporte da forma desejada. Isso permite que o webpack analise a árvore de dependências e remova dependências não utilizadas.

Considerar os efeitos colaterais

Outro aspecto a ser considerado ao sacudir as 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 ao modificar a matriz fruits, que está fora do escopo.

Os efeitos colaterais também se aplicam aos módulos ES6, e isso é importante no contexto do desmonte de árvore. 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. Eles são partes de código modulares e independentes. Por isso, "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 têm 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 necessário

Depois de instruir o Babel a deixar os módulos ES6 em paz, um pequeno ajuste na sintaxe import é necessário 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, todas as instâncias de utils.simpleSort precisam 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 que é necessário para que o tree shaking funcione neste exemplo. Esta é a saída do webpack antes de sacudir 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 tree shaking ser bem-sucedida:

                 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 encolhido, o pacote main é o que se beneficia mais. Ao remover as partes não utilizadas do módulo utils, o pacote main é reduzido 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.

Vá sacudir algumas árvores!

O que você ganha com a eliminação de árvores depende do seu app, 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 notar um aumento significativo no desempenho com a agitação da árvore ou quase nenhum. No entanto, ao configurar o sistema de build para aproveitar essa otimização nos builds de produção e importar seletivamente apenas o que o aplicativo precisa, você vai manter os pacotes do aplicativo o menor possível de forma 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.