Reduzir payloads de JavaScript com tree shaking

Os aplicativos da Web de hoje podem ficar muito grandes, especialmente a parte JavaScript. Em meados de 2018, o HTTP Archive coloca o tamanho médio de transferência do 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 têm um tempo de decodificação relativamente trivial após o download, o JavaScript precisa ser analisado, compilado e, por fim, 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 muito 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 já 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 que você adiciona, se não todas. 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" exportações de módulos ES6 que não foram importados explicitamente, 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 exemplo de app de uma página está disponível para demonstrar como o recurso funciona. Você pode clonar e seguir o exemplo, mas vamos abordar cada etapa do processo neste guia, então 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ê digita uma consulta e uma lista de pedais de efeito aparece.

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

O comportamento que impulsiona esse app é dividido em 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 de código de aplicativo (ou blocos) mostrados no painel de rede do Chrome DevTools.
Os dois pacotes JavaScript do app. Estes são tamanhos descompactados.

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, encontrar oportunidades de agitação da árvore vai envolver a busca por instruções import estáticas. Perto do início 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 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 de onde importamos muitos módulos é invocado apenas três vezes no arquivo de componente principal.

O namespace utils aparece em apenas três pontos do nosso aplicativo, mas para quais funções? Se você olhar o arquivo de componente principal novamente, vai notar que há apenas uma função, que é 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);
}

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 app de exemplo seja um pouco artificial, 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 que o tree shaking seja útil, como ele é 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 @babel/preset-env, o Babel pode transformar módulos ES6 em módulos CommonJS mais compatíveis, ou seja, módulos que você 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 @babel/preset-env faz com que o Babel se comporte como desejado, o que permite que o Webpack analise a árvore de dependências e elimine as 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 desligamento de árvores. 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, é possível usar uma dica para especificar que um pacote e as dependências dele estão livres de 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 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 não usar os módulos ES6, 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 árvore de agitação ter sido 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ê tiver certeza de que não configurou o bundler de módulos para realizar essa otimização, não há problema em tentar e conferir como isso beneficia seu aplicativo.

Você pode notar um ganho de performance significativo com a agitação de árvores ou quase nenhum. No entanto, ao configurar seu 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.

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.