Como a CommonJS está aumentando seus pacotes

Saiba como os módulos CommonJS estão afetando o tree-shaking do seu aplicativo

Nesta postagem, vamos analisar o que é o CommonJS e por que ele aumenta o tamanho dos seus pacotes de JavaScript.

Resumo: Para garantir que o bundler possa otimizar seu aplicativo, evite depender de módulos CommonJS e use a sintaxe do módulo ECMAScript em todo o aplicativo.

O que é o CommonJS?

O CommonJS é um padrão de 2009 que estabeleceu convenções para módulos JavaScript. Ele foi inicialmente destinado ao uso fora do navegador da Web, principalmente para aplicativos do lado do servidor.

Com o CommonJS, é possível definir módulos, exportar a funcionalidade deles e importá-los em outros módulos. Por exemplo, o snippet abaixo define um módulo que exporta cinco funções: add, subtract, multiply, divide e max:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Mais tarde, outro módulo pode importar e usar algumas ou todas essas funções:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

Invocar index.js com node vai gerar o número 3 no console.

Devido à falta de um sistema de módulos padronizado no navegador no início dos anos 2010, o CommonJS também se tornou um formato de módulo popular para bibliotecas JavaScript do lado do cliente.

Como o CommonJS afeta o tamanho do pacote final?

O tamanho do aplicativo JavaScript no lado do servidor não é tão crítico quanto no navegador. Por isso, o CommonJS não foi projetado para reduzir o tamanho do pacote de produção. Ao mesmo tempo, a análise mostra que o tamanho do pacote JavaScript ainda é o principal motivo para a lentidão dos apps para navegadores.

Os bundlers e minificadores de JavaScript, como webpack e terser, realizam diferentes otimizações para reduzir o tamanho do app. Ao analisar o app no momento da criação, eles tentam remover o máximo possível do código-fonte que você não está usando.

Por exemplo, no snippet acima, o pacote final só precisa incluir a função add, já que esse é o único símbolo de utils.js que você importa em index.js.

Vamos criar o app usando a seguinte configuração de webpack:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

Aqui, especificamos que queremos usar otimizações no modo de produção e usar index.js como ponto de entrada. Depois de invocar webpack, se analisarmos o tamanho da saída, veremos algo como isto:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

O pacote tem 625 KB. Se analisarmos a saída, vamos encontrar todas as funções de utils.js, além de muitos módulos de lodash. Embora não usemos lodash em index.js, ele faz parte da saída, o que aumenta muito o peso dos nossos recursos de produção.

Agora mude o formato do módulo para módulos ECMAScript e tente de novo. Desta vez, utils.js vai ficar assim:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

E index.js seria importado de utils.js usando a sintaxe do módulo ECMAScript:

import { add } from './utils.js';

console.log(add(1, 2));

Usando a mesma configuração de webpack, podemos criar nosso aplicativo e abrir o arquivo de saída. Agora ela tem 40 bytes com a seguinte saída:

(()=>{"use strict";console.log(1+2)})();

O pacote final não contém nenhuma das funções de utils.js que não usamos, e não há nenhum rastro de lodash. Além disso, terser (o minificador JavaScript usado por webpack) inlineou a função add em console.log.

Uma pergunta justa que você pode fazer é por que o uso do CommonJS faz com que o pacote de saída seja quase 16.000 vezes maior? Claro, este é um exemplo simples. Na realidade, a diferença de tamanho pode não ser tão grande, mas é provável que o CommonJS adicione peso significativo ao build de produção.

Os módulos CommonJS são mais difíceis de otimizar no caso geral porque são muito mais dinâmicos do que os módulos ES. Para garantir que o bundler e o minifier possam otimizar o aplicativo, evite depender de módulos CommonJS e use a sintaxe do módulo ECMAScript em todo o aplicativo.

Mesmo que você esteja usando módulos ECMAScript em index.js, se o módulo que você está consumindo for um módulo CommonJS, o tamanho do pacote do app vai aumentar.

Por que o CommonJS aumenta o tamanho do app?

Para responder a essa pergunta, vamos analisar o comportamento do ModuleConcatenationPlugin em webpack e, depois, discutir a capacidade de análise estática. Esse plug-in concatena o escopo de todos os módulos em um fechamento e permite que o código tenha um tempo de execução mais rápido no navegador. Vejamos um exemplo:

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

Acima, temos um módulo ECMAScript, que importamos em index.js. Também definimos uma função subtract. Podemos criar o projeto usando a mesma configuração de webpack acima, mas, desta vez, vamos desativar a minimização:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

Vamos conferir o resultado:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

Na saída acima, todas as funções estão no mesmo namespace. Para evitar colisões, o webpack renomeou a função subtract em index.js para index_subtract.

Se um minificador processar o código-fonte acima, ele irá:

  • Remova as funções não usadas subtract e index_subtract.
  • Remova todos os comentários e espaços em branco redundantes
  • In-line o corpo da função add na chamada console.log.

Geralmente, os desenvolvedores se referem a essa remoção de importações não utilizadas como tree shaking. O tree-shaking só foi possível porque o webpack conseguiu entender de forma estática (no momento da criação) quais símbolos estamos importando de utils.js e quais símbolos ele exporta.

Esse comportamento é ativado por padrão para módulos ES porque eles são mais estáticos para análise em comparação com o CommonJS.

Vamos analisar o mesmo exemplo, mas desta vez mude o utils.js para usar CommonJS em vez de módulos ES:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Essa pequena atualização vai mudar significativamente a saída. Como o vídeo é muito longo para ser incorporado a esta página, compartilhei apenas uma pequena parte dele:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

O pacote final contém um pouco de webpack "runtime": código injetado responsável por importar/exportar a funcionalidade dos módulos agrupados. Desta vez, em vez de colocar todos os símbolos de utils.js e index.js no mesmo namespace, exigimos dinamicamente, no momento da execução, a função add usando __webpack_require__.

Isso é necessário porque, com o CommonJS, podemos extrair o nome da exportação de uma expressão arbitrária. Por exemplo, o código abaixo é um construct totalmente válido:

module.exports[localStorage.getItem(Math.random())] = () => {  };

Não há como o bundler saber no momento da compilação qual é o nome do símbolo exportado, já que isso exige informações que só estão disponíveis no momento da execução, no contexto do navegador do usuário.

Dessa forma, o minificador não consegue entender exatamente o que index.js usa das dependências, então não consegue fazer a eliminação de árvores. Vamos observar o mesmo comportamento para módulos de terceiros. Se importarmos um módulo CommonJS de node_modules, o conjunto de ferramentas de build não vai conseguir otimizar o módulo corretamente.

Como fazer o tree shaking com CommonJS

É muito mais difícil analisar módulos CommonJS, já que eles são dinâmicos por definição. Por exemplo, o local de importação nos módulos ES é sempre um literal de string, comparado a CommonJS, em que é uma expressão.

Em alguns casos, se a biblioteca que você está usando segue convenções específicas sobre como usar o CommonJS, é possível remover as exportações não utilizadas no momento do build usando um plug-in webpack de terceiros. Embora esse plug-in adicione suporte ao tree-shaking, ele não abrange todas as maneiras diferentes de usar o CommonJS nas suas dependências. Isso significa que você não tem as mesmas garantias que com os módulos ES. Além disso, ele adiciona um custo extra como parte do processo de build, além do comportamento webpack padrão.

Conclusão

Para garantir que o bundler possa otimizar seu aplicativo, evite depender de módulos CommonJS e use a sintaxe do módulo ECMAScript em todo o aplicativo.

Confira algumas dicas práticas para verificar se você está no caminho ideal:

  • Use o plug-in node-resolve do Rollup.js e defina a flag modulesOnly para especificar que você quer depender apenas de módulos ECMAScript.
  • Use o pacote is-esm para verificar se um pacote npm usa módulos ECMAScript.
  • Se você estiver usando o Angular, vai receber um aviso por padrão se depender de módulos que não podem ser removidos da árvore.