Module folding: matando o código morto
E aí galera!
O tópico de hoje é module folding, também conhecido como tree-shaking. Estes termos são um tanto complicados de traduzir para o português, logo, utilizarei os nomes em inglês ao longo deste artigo.
Module folding
Module folding, ou tree-shaking, trata-se de uma técnica de eliminação de código morto (não utilizado), com o intuito de aumentar o desempenho e reduzir o peso da sua aplicação. Esta técnica tem um impacto muito grande no mundo front-end, pois consegue reduzir drasticamente o tamanho dos arquivos JavaScript através da eliminação de código não utilizado de bibliotecas, frameworks e pacotes. Esta técnica também pode ser utilizada visando o ambiente Node.js, pois a remoção de código morto acaba também por aumentar o desempenho e reduzir o consumo de memória da sua aplicação.
Neste artigo abordaremos o que realmente é module folding, como utizá-lo, boas práticas, além de algumas previsões sobre seu futuro.
Module folding na prática
Para utilizar module folding, é recomendável escrever o código de sua aplicação usando a sintaxe de módulos nativa do ECMAScript 2015 (vulgo "ES6"). Esta nova sintaxe faz com que todas importações e exportações sejam estaticamente analisáveis, facilitando muito o trabalho de ferramentas como IDEs e empacotadores de saber o que realmente cada arquivo está importando e exportando, assim viabilizando a prática do module folding.
Já existem alguns empacotadores que realizam o module folding: Rollup e (a versão atualmente beta do) Webpack. Neste artigo utilizaremos o Webpack, pois ele é bem completo e é meu empacotador de preferência.
Você pode instalar o Webpack globalmente para deixá-lo disponível na linha de comando:
npm install -g webpack
Em seguida, criaremos dois arquivos utilizando a sintaxe de módulos nativa:
`\
js // index.js
import { soma } from './matematica.js';
console.log('Resposta para a vida, o universo e tudo mais:', soma(10, 32)); `\
`\
js // matematica.js
export function soma(a, b) { return a + b; }
export function multiplica(a, b) { return a * b; }
export function elevaAoQuadrado(n) { return multiplica(n, n); }
var dois = 2;
export function metade(n) { return n / dois; }
export function ehPar(n) { return n % dois === 0; }
export function ehImpar(n) { return !ehPar(n); } `\
Nosso index.js
contém a lógica da aplicação, e o arquivo matematica.js
contém diversas funções utilitárias. Como podem ver, o arquivo matematica.js
contém várias funções que não são utilizadas no fluxo principal da aplicação e que portanto podem ser eliminadas do pacote que será enviado para produção.
Claro que o exemplo acima é extremamente simplificado, mas é bem similar a casos de uso real: quando você inclui uma biblioteca como Lodash, Underscore ou jQuery, você dificilmente fará uso de todas os recursos que esta biblioteca oferece, e portanto você acaba incorporando mais peso do que o necessário em sua aplicação.
É aí que entra o module folding. Com um simples comando conseguimos eliminar o código não utilizado da nossa aplicação e bibliotecas! O comando é bem simples:
webpack -p index.js bundle.js
A flag -p
habilita os plugins de otimização para produção, os quais são responsáveis por minificar o seu código e remover o código morto (module folding). Os argumentos seguintes são o ponto de entrada da sua aplicação e a saída onde será salvo o bundle, que é um único arquivo JavaScript contendo todo código necessário para rodar a sua aplicação, já minificado e com código morto removido.
Você pode também criar um npm script em seu package.json
para não ter que decorar o comando acima:
js "scripts": { "build": "webpack -p index.js bundle.js" }
Desta forma, você pode simplesmente rodar npm run build
invés de digitar o comando inteiro acima, e outra vantagem é que você pode usar o webpack
instalado localmente no node_modules
do seu projeto, assim evitando dependências globais. É uma boa prática escopar dependências dentro do projeto, pois dependências globais são mais difíceis de comunicar para o resto da equipe e podem conflitar quando você está trabalhando em diferentes projetos que dependem de versões diferentes de uma pacote global—veja também The Twelve-Factor App - II. Dependencies (em inglês).
Voltando ao tópico principal, rodando este comando você verá o código morto sendo eliminado:
Agora se você importar a função ehImpar
do arquivo matematica.js
e utilizá-la no index.js
, verá que as funções ehImpar
, ehPar
e a variável dois
não são mais eliminadas do bundle, já que agora elas são necessárias para a execução da aplicação. Bem legal e inteligente, não é mesmo?
Caso você esteja afim de ver e brincar mais um pouco com o exemplo acima, você pode conferi-lo no repositório do exemplo.
Em meu teste, o tamanho do arquivo final foi reduzido de 705 bytes (minifcado, sem module folding) para 388 bytes (minifcado, com module folding). A redução de tamanho pode e geralmente será bem maior levando em consideração projetos com muitas dependências.
Tendo o bundle gerado em mãos, que nada mais é do que um arquivo JavaScript, você pode jogá-lo em uma tag <script src="bundle.js"></script>
no HTML ou executá-lo diretamente no Node.js com node bundle.js
, dependendo do(s) ambiente(s) que você pretende suportar.
Module folding na vida real
Agora que já exploramos os aspectos básicos do module folding, vamos abordar casos de uso reais.
Praticamente toda aplicação depende de alguns ou vários pacotes, geralmente gerenciados pelo npm, Bower, ou algum outro gerenciador de pacotes, ou ainda manualmente. Você pode ter percebido que para o module folding funcionar corretamente é necessário escrever seu código utilizando a sintaxe de módulos nativa (introduzida no ECMAScript 2015 / ES6), mas pouquíssimos pacotes são publicados desta forma!
A solução para este problema é simples, mas nem tanto. A ideia atual é que autores comecem a publicar seus pacotes em pelo menos dois formatos, um deles sendo o que o autor já está acostumado a publicar (CommonJS, AMD, UMD, etc.) e outro utilizando a sintaxe de módulos nativa. Estas versões podem ser publicadas em pacotes separados ou até mesmo dentro do mesmo pacote, por exemplo:
`\
js // Importa a versão CommonJS do pacote, // para quem quer que apenas funcione no Node.js // e não se importa com module folding. var pacote = require('nome-do-pacote');
// ------ OU ------
// Importa a versão do pacote que utiliza módulos nativos, // possibilitando o module folding. import pacote from 'nome-do-pacote/native-modules/index.js'; `\
Isto ainda é relativamente novo e portanto os padrões e boas práticas ainda estão sendo estabelecidos. O Lodash, por exemplo, disponibiliza um pacote separado chamado lodash-es. O "es" não é de espanhol, e sim de ECMAScript, pois esta versão do pacote utiliza a sintaxe de módulos nativa introduzida no ECMAScript 2015 (ES6).
Caso você já está esteja utilizando os novos recursos do ECMAScript 2015 incluindo a sua sintaxe de módulos, e utilizando o Babel para compilar seu pacote para ES5 antes de publicá-lo, este processo torna-se bem mais simples. Você pode continuar publicando uma versão inteiramente compilada para ES5, e aí você só precisa fazer outra build excluindo o plugin babel-plugin-transform-es2015-modules-commonjs
(ou substituindo o preset babel-preset-es2015
pelo babel-preset-es2015-webpack
) para gerar uma versão do seu pacote compatível com module folding.
Note que algumas outras ferramentas como o Rollup dispõe de plugins que convertem CommonJS para a sintaxe de módulos nativa, como o rollup-plugin-commonjs, assim, em teoria, possibilitando realizar o module folding até mesmo com pacotes que não foram escritos na sintaxe de módulos nativa. É possível escrever um plugin para o Babel que possua esta mesma funcionalidade e utilizá-lo com o plugin babel-loader do Webpack, porém eu pessoalmente não recomendaria fazer isto nem utilizar o plugin rollup-plugin-commonjs
do Rollup. Isto se deve porque a sintaxe de módulos nativa é bem mais restritiva que o CommonJS, considerando que (entenda "módulo" como um arquivo JavaScript):
O CommonJS permite modificar o objeto de exportação de um módulo a partir de qualquer módulo que o importe (como adicionar novas propriedades e alterar propriedades existentes).
O CommonJS permite modificar o objeto de exportação assim como importar novos módulos após a inicialização do módulo (por exemplo, dentro de uma função).
O CommonJS permite utilizar expressões (valores dinâmicos) tanto na definição de exportações quanto nas importações e caminhos de módulos a serem importados.
Nada disto é permitido com a sintaxe de módulos nativa, e com boa razão—a restritividade é o aspecto principal que permite que ferramentas determinem todas as relações entre os módulos através de análise estática (sem necessitar executar o código), e que portanto viabiliza o module folding. Ainda existem várias outras discrepâncias, como, por exemplo, as regras de resolução em relação a dependências cíclicas.
Então, utilizar uma ferramenta para converter CommonJS para sintaxe de módulos nativa é uma receita para o desastre que irá quebrar a sua build quando você menos espera! O melhor mesmo é que autores de pacotes mantenham uma versão oficial de seus pacotes que utiliza a sintaxe de módulos nativa.
Boas práticas
Não realize o module folding antes de publicar sua biblioteca ou pacote
Deixe o module folding para o usuário final do seu pacote (aquele que escreverá uma aplicação utilizando esta pacote). Como autor de pacote, certifique-se de dispor uma versão do seu pacote que utilize a sintaxe de módulos nativa, para que o usuário final seja capaz de realizar o module folding. Este ponto é um pouco difícil de explicar, então vamos exemplificar.
Primeiramente, partimos do princípio que você está escrevendo um pacote (ou seja, um conjunto de código reutilizável e independente) e que, portanto, você não sabe exatamente quais outros pacotes o seu usuário final estará usando na aplicação que está desenvolvendo com seu pacote.
Vamos assumir que você é o autor do pacote a
, e que um certo usuário final usa os pacotes a
e b
em sua aplicação. Ambos pacotes a
e b
dependem da mesma versão de um pacote específico, por exemplo, lodash@3
, e que ambos pacotes a
e b
usam a mesma função deste pacote em comum, por exemplo, map
.
Caso ambos autores dos pacotes a
e b
realizem o module folding antes de publicar seus respectivos pacotes, o usuário final do nosso exemplo acabará então com duas cópias da função map
do pacote lodash
, uma dentro de cada bundle dos pacotes a
e b
.
Agora, se ambos os autores dos pacotes a
e b
publicarem seus pacotes sem realizar o module folding e com a dependência no pacote lodash@3
propriamente declarada em seus respectivos arquivos package.json
, o usuário final terá a seguinte estrutura de pacotes ao instalar os pacotes a
e b
utilizando npm >= v3:
node_modules ├───a@1.0.0 ├───b@1.0.0 └───lodash@3.10.1
Isto se dá porque o npm a partir da v3 instala pacotes de forma maximamente achatada, ou seja, desde que não haja conflitos de versão entre as dependências de suas dependências, elas serão colocadas no node_modules
mais perto da raiz do seu projeto possível e portanto serão compartilhadas entre diferentes pacotes, evitando a duplicação desnecessária de pacotes. Isto é possível pois o algoritmo de resolução de módulos do Node.js (que é o mesmo implementado por empacotadores como Webpack) busca pacotes em diretórios node_modules
mais altos na hierarquia do sistema de arquivos quando o pacote requerido não é encontrado no mesmo nível do arquivo que o requere.
Logo, o usuário final do nosso exemplo que está desenvolvendo uma aplicação utilizando os pacotes a
e b
poderá então realizar o module folding de sua aplicação inteira, onde ambos pacotes a
e b
fazem uso da mesma instalação do pacote lodash
e assim o bundle final do usuário não possuirá código duplicado do lodash
!
Note que, novamente, este exemplo foi extremamente simplificado. Foi considerado um caso de aplicação com apenas duas dependências onde cada dependência tem apenas uma dependência, com o intuito de facilitar o entendimento deste conceito. Em casos reais, é comum ter dezenas de dependências onde cada dependência possui mais uma dezena de dependências e cada dependência de cada dependência possui mais tantas dep—Bom você consegue entender a ideia. Nestes casos, faz uma grande diferença evitar a duplicação de pacotes utilizados como dependências.
Compile tudo para o denominador comum, exceto a sintaxe de módulos
Caso você esteja utilizando algum passo de compilação antes de publicar seu pacote (Babel, TypeScript, CoffeeScript etc.), quando desejar gerar a versão do seu pacote compatível com module folding certifique-se de compilar tudo para o denominador comum dos ambientes que você deseja suportar (por exemplo, compilar para ES5 para suportar versões mais antigas do Node.js e/ou IE9+), exceto a sintaxe de módulos nativa.
Como já mencionado, a sintaxe de módulos nativa é essencial para que o module folding seja realizado corretamente. O empacotador (Webpack, Rollup etc.) se encarregará de resolver as relações entre os módulos e transformar esta sintaxe em algo que o ambiente onde o código será executado entenda.
No entanto, publicar seu pacote utilizando outros recursos experimentais que precisam ser compilados pode ser um grande problema para o usuário, que poderá perder muito tempo tentando configurar o processo de build e o empacotador para que consiga compilar corretamente o pacote que está sendo usado como dependência.
Então, a sintaxe de módulos nativa é necessária e não é problema algum para um usuário que deseja fazer uso do module folding. Todos outros recursos experimentais devem ser apropriamente compilados antes da publicação do pacote, para evitar dor de cabeça e perda de tempo do usuário final.
Previsões futuras
É hora das previsões da Mãe Dináh.
Ok, mas falando sério. Um dos grandes problemas que vejo com o module folding é que atualmente ele está diretamente atrelado ao bundling (empacotar vários arquivos em um só).
O mundo front-end está entrando na era do HTTP/2, onde baixar vários arquivos invés de um só não mais tem um custo adicional significativo—múltiplos arquivos compartilham headers e são transferidos na mesma conexão. Isto também permite um melhor controle de (invalidação de) cache e controle sobre a priorização de cada arquivo.
Isto pode parecer meio complicado, então vamos exemplificar.
Assuma que você possui uma aplicação front-end com cerca de 500 arquivos JavaScript, contando arquivos da sua própria aplicação e dependências. Você está usando Webpack e gera um bundle com module folding, um único arquivo contendo todo o código da sua aplicação. Sua aplicação já está em produção e vários usuários possuem o bundle no cache de seus navegadores.
Agora você precisa editar uma única linha da sua aplicação. Então você precisa gerar um bundle inteiramente novo, que invalida todo o cache do bundle anterior inteiro e força os usuários a baixarem todo o JavaScript da sua aplicação novamente.
Com HTTP/2, sem bundling e sem module folding, os usuários da aplicação teriam que baixar apenas o arquivo modificado, pois o cache é muito mais modular quando não se utiliza bundling. Isto faria com que atualizações na sua aplicação causassem bem menos transferência de dados, seriam mais rápidas e menos perceptíveis para seus usuários. Porém, já que neste caso não há module folding, o tamanho total e consumo de memória da aplicação seriam bem maiores.
É possível aliviar este problema utilizando o recurso de code splitting do Webpack, mas ainda não está bem claro como isto vai funcionar com o module folding e eu sinto que provavelmente não seja a solução adequada.
O grande problema é que, atualmente, quando você opta por module folding, está implicitamente optando pelo bundling também, e isto já está deixando de ser uma coisa boa no mundo front-end.
Eu prevejo um futuro onde será possível eliminar todo o código morto sem realizar o processo de bundling propriamente dito. Ou seja, gerar uma estrutura de arquivos idêntica à original, mas com o código morto removido dos arquivos de sua aplicação e bibliotecas.
Mas, mesmo com esta minha sugestão, nem tudo são flores. Assuma um caso imaginário em que você está usando apenas a função map
do Lodash em sua aplicação. No meu sistema de module folding, o Lodash seria um arquivo isolado que possuiria apenas a função map
. Aí você edita um arquivo da sua aplicação, fazendo uso da função filter
do Lodash. Isto invalidaria o cache tanto do arquivo da sua aplicação que foi editado quanto do Lodash, que agora possui uma nova função que deixou de ser código morto. Bom, mesmo assim, acredito que isto atinja um bom equilíbrio entre o tamanho total da aplicação e a granularidade do cache.
Considerações finais
Bom galera, agora vocês já sabem do que se trata esse tal de module folding ou tree-shaking. Espero que tenham aprendido algo novo hoje. :)
Por favor, note que isto tudo ainda é bastante novo: são relativamente poucos pacotes que dispõe de uma versão compatível com module folding, e as ferramentas que realizam este processo ainda não estão finalizadas.
Se você curtiu a ideia, achou legal e quer que ela cresça, só há um jeito: teste, use, encontre bugs, reporte bugs e, caso tenhas tempo e disposição, conserte os bugs e ajude a aperfeiçoar esta tecnologia.
Muito obrigado, e parabéns por chegar ao final desta postagem. :D
Foto no topo da página por Brett Jordan