Agitação da árvore: um guia de referência

Publicados: 2022-03-10
Resumo rápido ↬ “Tree-shaking” é uma otimização de desempenho obrigatória ao agrupar JavaScript. Neste artigo, mergulhamos mais fundo em como exatamente funciona e como as especificações e a prática se entrelaçam para tornar os pacotes mais enxutos e com melhor desempenho. Além disso, você receberá uma lista de verificação para sacudir a árvore para usar em seus projetos.

Antes de iniciar nossa jornada para aprender o que é o tree-shaking e como nos preparar para o sucesso com ele, precisamos entender quais módulos estão no ecossistema JavaScript.

Desde seus primeiros dias, os programas JavaScript cresceram em complexidade e no número de tarefas que executam. A necessidade de compartimentar tais tarefas em escopos fechados de execução tornou-se evidente. Esses compartimentos de tarefas, ou valores, são o que chamamos de módulos . Seu principal objetivo é evitar a repetição e alavancar a reutilização. Assim, as arquiteturas foram concebidas para permitir tais tipos especiais de escopo, expor seus valores e tarefas e consumir valores e tarefas externas.

Para se aprofundar no que são módulos e como eles funcionam, recomendo “ES Modules: A Cartoon Deep-Dive”. Mas para entender as nuances da vibração da árvore e do consumo do módulo, a definição acima deve ser suficiente.

O que realmente significa balançar a árvore?

Simplificando, abanar a árvore significa remover código inacessível (também conhecido como código morto) de um pacote. Como a documentação do Webpack versão 3 afirma:

“Você pode imaginar sua aplicação como uma árvore. O código-fonte e as bibliotecas que você realmente usa representam as folhas verdes e vivas da árvore. Código morto representa as folhas marrons e mortas da árvore que são consumidas pelo outono. Para se livrar das folhas mortas, é preciso sacudir a árvore, fazendo com que caiam.”

O termo foi popularizado pela primeira vez na comunidade de front-end pela equipe Rollup. Mas os autores de todas as linguagens dinâmicas têm lutado com o problema desde muito antes. A ideia de um algoritmo de trepidação de árvores pode ser rastreada até pelo menos o início dos anos 1990.

Na terra do JavaScript, o tree-shaking é possível desde a especificação do módulo ECMAScript (ESM) no ES2015, anteriormente conhecido como ES6. Desde então, o tree-shaking foi habilitado por padrão na maioria dos bundlers porque eles reduzem o tamanho da saída sem alterar o comportamento do programa.

A principal razão para isso é que os ESMs são estáticos por natureza. Vamos dissecar o que isso significa.

Mais depois do salto! Continue lendo abaixo ↓

Módulos ES vs. CommonJS

CommonJS antecede a especificação ESM por alguns anos. Surgiu para resolver a falta de suporte para módulos reutilizáveis ​​no ecossistema JavaScript. CommonJS tem uma função require() que busca um módulo externo com base no caminho fornecido e o adiciona ao escopo durante o tempo de execução.

O fato de require ser uma function como qualquer outra em um programa torna difícil o suficiente avaliar o resultado de sua chamada em tempo de compilação. Além disso, é possível adicionar chamadas require em qualquer lugar do código — envolto em outra chamada de função, em instruções if/else, em instruções switch, etc.

Com o aprendizado e as lutas que resultaram da ampla adoção da arquitetura CommonJS, a especificação ESM se estabeleceu nesta nova arquitetura, na qual os módulos são importados e exportados pelas respectivas palavras-chave import e export . Portanto, não há mais chamadas funcionais. Os ESMs também são permitidos apenas como declarações de nível superior — aninhando-os em qualquer outra estrutura não é possível, pois são estáticos : os ESMs não dependem da execução em tempo de execução.

Escopo e efeitos colaterais

Há, no entanto, outro obstáculo que o abalo de árvores deve superar para evitar o inchaço: os efeitos colaterais. Uma função é considerada como tendo efeitos colaterais quando altera ou depende de fatores externos ao escopo de execução. Uma função com efeitos colaterais é considerada impura . Uma função pura sempre produzirá o mesmo resultado, independentemente do contexto ou do ambiente em que foi executada.

 const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c

Os empacotadores servem ao seu propósito avaliando o código fornecido o máximo possível para determinar se um módulo é puro. Mas a avaliação de código durante o tempo de compilação ou o tempo de agrupamento só pode ir até certo ponto. Portanto, assume-se que pacotes com efeitos colaterais não podem ser eliminados adequadamente, mesmo quando completamente inacessíveis.

Por causa disso, os empacotadores agora aceitam uma chave dentro do arquivo package.json do módulo que permite ao desenvolvedor declarar se um módulo não tem efeitos colaterais. Dessa forma, o desenvolvedor pode optar por não avaliar o código e dar dicas ao empacotador; o código dentro de um pacote específico pode ser eliminado se não houver importação alcançável ou require uma declaração vinculada a ele. Isso não apenas contribui para um pacote mais enxuto, mas também pode acelerar os tempos de compilação.

 { "name": "my-package", "sideEffects": false }

Portanto, se você é um desenvolvedor de pacotes, faça uso consciente de sideEffects antes de publicar e, é claro, revise-o a cada lançamento para evitar alterações inesperadas.

Além da chave root sideEffects , também é possível determinar a pureza arquivo por arquivo, anotando um comentário embutido, /*@__PURE__*/ , em sua chamada de método.

 const x = */@__PURE__*/eliminated_if_not_called()

Considero essa anotação inline uma saída para o desenvolvedor consumidor, a ser feita caso um pacote não tenha declarado sideEffects: false ou caso a biblioteca realmente apresente um efeito colateral em um determinado método.

Otimizando o Webpack

Da versão 4 em diante, o Webpack exigiu progressivamente menos configuração para que as práticas recomendadas funcionassem. A funcionalidade de alguns plugins foi incorporada ao core. E como a equipe de desenvolvimento leva muito a sério o tamanho do pacote, eles facilitaram a agitação das árvores.

Se você não for muito experiente ou se seu aplicativo não tiver casos especiais, então o tree-shake de suas dependências é uma questão de apenas uma linha.

O arquivo webpack.config.js tem uma propriedade raiz chamada mode . Sempre que o valor desta propriedade for a production , ela irá agitar a árvore e otimizar totalmente seus módulos. Além de eliminar o código morto com o TerserPlugin , mode: 'production' habilitará nomes determinísticos desfigurados para módulos e partes, e ativará os seguintes plugins:

  • sinalizar uso de dependência,
  • bandeira incluiu pedaços,
  • concatenação de módulos,
  • nenhuma emissão em erros.

Não é por acaso que o valor do gatilho é production . Você não desejará que suas dependências sejam totalmente otimizadas em um ambiente de desenvolvimento, pois isso tornará os problemas muito mais difíceis de depurar. Então, eu sugiro ir sobre isso com uma das duas abordagens.

Por um lado, você pode passar um sinalizador de mode para a interface de linha de comando do Webpack:

 # This will override the setting in your webpack.config.js webpack --mode=production

Como alternativa, você pode usar a variável process.env.NODE_ENV em webpack.config.js :

 mode: process.env.NODE_ENV === 'production' ? 'production' : development

Nesse caso, você deve se lembrar de passar --NODE_ENV=production em seu pipeline de implantação.

Ambas as abordagens são uma abstração em cima do muito conhecido definePlugin do Webpack versão 3 e abaixo. Qual opção você escolhe não faz absolutamente nenhuma diferença.

Webpack versão 3 e abaixo

Vale a pena mencionar que os cenários e exemplos nesta seção podem não se aplicar a versões recentes do Webpack e outros empacotadores. Esta seção considera o uso do UglifyJS versão 2, em vez do Terser. UglifyJS é o pacote do qual Terser foi bifurcado, então a avaliação do código pode diferir entre eles.

Como o Webpack versão 3 e inferior não oferece suporte à propriedade sideEffects em package.json , todos os pacotes devem ser avaliados completamente antes que o código seja eliminado. Isso por si só torna a abordagem menos eficaz, mas várias ressalvas também devem ser consideradas.

Como mencionado acima, o compilador não tem como descobrir sozinho quando um pacote está adulterando o escopo global. Mas essa não é a única situação em que ele pula a sacudida de árvores. Existem cenários mais confusos.

Pegue este exemplo de pacote da documentação do Webpack:

 // transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });

E aqui está o ponto de entrada de um pacote de consumo:

 // index.js import { someVar } from './transforms.js'; // Use `someVar`...

Não há como determinar se mylib.transform efeitos colaterais. Portanto, nenhum código será eliminado.

Aqui estão outras situações com um resultado semelhante:

  • invocando uma função de um módulo de terceiros que o compilador não pode inspecionar,
  • reexportando funções importadas de módulos de terceiros.

Uma ferramenta que pode ajudar o compilador a fazer o tree-shake funcionar é o babel-plugin-transform-imports. Ele dividirá todas as exportações de membros e nomeadas em exportações padrão, permitindo que os módulos sejam avaliados individualmente.

 // before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';

Ele também possui uma propriedade de configuração que avisa o desenvolvedor para evitar instruções de importação problemáticas. Se você estiver no Webpack versão 3 ou superior e tiver feito a devida diligência com a configuração básica e adicionado os plug-ins recomendados, mas seu pacote ainda parecer inchado, recomendo experimentar este pacote.

Tempos de Içamento e Compilação do Escopo

Na época do CommonJS, a maioria dos empacotadores simplesmente agrupava cada módulo dentro de outra declaração de função e os mapeava dentro de um objeto. Isso não é diferente de qualquer objeto de mapa por aí:

 (function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")

Além de ser difícil de analisar estaticamente, isso é fundamentalmente incompatível com ESMs, porque vimos que não podemos agrupar instruções de import e export . Então, hoje em dia, os bundlers elevam cada módulo ao nível superior:

 // moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()

Essa abordagem é totalmente compatível com ESMs; além disso, permite que a avaliação de código identifique facilmente os módulos que não estão sendo chamados e elimine-os. A ressalva dessa abordagem é que, durante a compilação, ela leva muito mais tempo porque toca em todas as instruções e armazena o pacote na memória durante o processo. Essa é uma grande razão pela qual o desempenho do pacote se tornou uma preocupação ainda maior para todos e porque as linguagens compiladas estão sendo aproveitadas em ferramentas para desenvolvimento web. Por exemplo, esbuild é um empacotador escrito em Go e SWC é um compilador TypeScript escrito em Rust que se integra ao Spark, um empacotador também escrito em Rust.

Para entender melhor o içamento do escopo, recomendo a documentação do Parcel versão 2.

Evite transpilação prematura

Há um problema específico que infelizmente é bastante comum e pode ser devastador para o abalo de árvores. Resumindo, isso acontece quando você está trabalhando com carregadores especiais, integrando diferentes compiladores ao seu bundler. As combinações comuns são TypeScript, Babel e Webpack — em todas as permutações possíveis.

Tanto o Babel quanto o TypeScript têm seus próprios compiladores, e seus respectivos carregadores permitem que o desenvolvedor os use, para facilitar a integração. E é aí que reside a ameaça oculta.

Esses compiladores alcançam seu código antes da otimização do código. E seja por padrão ou configuração incorreta, esses compiladores geralmente produzem módulos CommonJS, em vez de ESMs. Conforme mencionado em uma seção anterior, os módulos CommonJS são dinâmicos e, portanto, não podem ser avaliados adequadamente para eliminação de código morto.

Esse cenário está se tornando ainda mais comum hoje em dia, com o crescimento de aplicativos “isomórficos” (ou seja, aplicativos que executam o mesmo código tanto no lado do servidor quanto no lado do cliente). Como o Node.js ainda não tem suporte padrão para ESMs, quando os compiladores são direcionados para o ambiente do node , eles produzem CommonJS.

Portanto, certifique-se de verificar o código que seu algoritmo de otimização está recebendo .

Lista de Verificação de Agitação de Árvores

Agora que você conhece os meandros de como o agrupamento e o tree-shaking funcionam, vamos desenhar uma lista de verificação que você pode imprimir em algum lugar útil para quando revisitar sua implementação atual e base de código. Espero que isso economize tempo e permita otimizar não apenas o desempenho percebido do seu código, mas talvez até os tempos de construção do seu pipeline!

  1. Use ESMs, e não apenas em sua própria base de código, mas também favoreça pacotes que geram ESM como seus consumíveis.
  2. Certifique-se de saber exatamente quais (se houver) de suas dependências não declararam sideEffects ou defini-las como true .
  3. Faça uso de anotação embutida para declarar chamadas de método puras ao consumir pacotes com efeitos colaterais.
  4. Se você estiver gerando módulos CommonJS, certifique-se de otimizar seu pacote antes de transformar as instruções de importação e exportação.

Criação de pacotes

Felizmente, neste ponto, todos concordamos que os ESMs são o caminho a seguir no ecossistema JavaScript. Como sempre no desenvolvimento de software, porém, as transições podem ser complicadas. Felizmente, os autores de pacotes podem adotar medidas ininterruptas para facilitar a migração rápida e contínua para seus usuários.

Com algumas pequenas adições ao package.json , seu pacote poderá informar aos empacotadores os ambientes aos quais o pacote oferece suporte e como eles são melhor suportados. Aqui está uma lista de verificação do Skypack:

  • Inclua uma exportação ESM.
  • Adicione "type": "module" .
  • Indique um ponto de entrada por meio de "module": "./path/entry.js" (uma convenção da comunidade).

E aqui está um exemplo que resulta quando todas as práticas recomendadas são seguidas e você deseja oferecer suporte a ambientes Web e Node.js:

 { // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }

Além disso, a equipe do Skypack introduziu um índice de qualidade do pacote como referência para determinar se um determinado pacote está configurado para longevidade e melhores práticas. A ferramenta é de código aberto no GitHub e pode ser adicionada como um devDependency ao seu pacote para realizar as verificações facilmente antes de cada lançamento.

Empacotando

Espero que este artigo tenha sido útil para você. Em caso afirmativo, considere compartilhá-lo com sua rede. Estou ansioso para interagir com você nos comentários ou no Twitter.

Recursos úteis

Artigos e Documentação

  • “Módulos ES: Um mergulho profundo dos desenhos animados”, Lin Clark, Mozilla Hacks
  • “Agitação da árvore”, Webpack
  • “Configuração”, Webpack
  • “Otimização”, Webpack
  • “Scope Hoisting”, documentação do Parcel versão 2

Projetos e Ferramentas

  • Terser
  • babel-plugin-transform-imports
  • Skypack
  • Webpack
  • Parcela
  • Rolar
  • desconstruir
  • SWC
  • Verificação do pacote