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.
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.
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.
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):
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.
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.