Como a CommonJS está aumentando seus pacotes

Saiba como os módulos CommonJS estão afetando o tree shaking no aplicativo

Nesta postagem, veremos o que é o CommonJS e por que ele aumenta o tamanho dos pacotes JavaScript do que o necessário.

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 é CommonJS?

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

Com o CommonJS, você pode definir módulos, exportar a funcionalidade deles e importá-los para 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 estas funções:

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

Invocar index.js com node produzirá 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 final do seu pacote?

O tamanho do aplicativo JavaScript do lado do servidor não é tão importante quanto no navegador. É por isso que 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 tornar os navegadores mais lentos.

Os bundlers e minificadores de JavaScript, como webpack e terser, realizam diferentes otimizações para reduzir o tamanho do app. Ao analisar seu aplicativo no tempo de build, 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 precisa incluir apenas a função add, já que esse é o único símbolo de utils.js importado em index.js.

Vamos criar o app usando a seguinte configuração do 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 do modo de produção e usar index.js como um ponto de entrada. Depois de invocar webpack, se analisarmos o tamanho da saída, você verá algo parecido com isto:

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

O pacote tem 625 KB. Se observarmos a saída, encontraremos todas as funções de utils.js e muitos módulos de lodash. Não usamos lodash em index.js, mas ele faz parte da saída, o que adiciona um peso extra aos recursos de produção.

Agora vamos mudar o formato do módulo para módulos ECMAScript e tentar de novo. Desta vez, utils.js ficaria 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 importaria 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á rastreamento de lodash. Além disso, o terser (o minificador de JavaScript usado pelo webpack) incorporou a função add no console.log.

Uma boa pergunta a se 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 de brinquedo. Na realidade, a diferença de tamanho pode não ser tão grande, mas é provável que o CommonJS acrescente um peso significativo à sua versão de produção.

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

Mesmo que você use módulos ECMAScript no index.js, se o módulo que você está consumindo for um CommonJS, o tamanho do pacote do app vai ser afetado.

Por que o CommonJS torna seu app maior?

Para responder a essa pergunta, vamos examinar o comportamento da ModuleConcatenationPlugin no webpack e, depois disso, discutir a análise estática. Esse plug-in concatena o escopo de todos os módulos em um fechamento e permite que seu 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 é importado 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',
};

Vejamos a saída produzida:

/******/ (() => { // 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 dentro do 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 os 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 usadas como tree shaking. Esse recurso só foi possível porque o webpack conseguiu entender estaticamente (durante o tempo de build) quais símbolos estamos importando do utils.js e quais símbolos ele exporta.

Esse comportamento é ativado por padrão para os módulos ES porque eles são mais estaticamente analisáveis 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 bastante a saída. Como é muito longo para incorporar nesta página, compartilhei apenas uma pequena parte dela:

...
(() => {

"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 algum código webpack "runtime": injetado que é responsável pela importação/exportação da 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 CommonJS, podemos conseguir o nome da exportação de uma expressão arbitrária. Por exemplo, o código abaixo é uma construção absolutamente válida:

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 é capaz de entender exatamente o que o index.js usa nas dependências, então ele não consegue distingui-lo. Também observaremos o mesmo comportamento para módulos de terceiros. Se importarmos um módulo CommonJS de node_modules, seu conjunto de ferramentas de build não poderá otimizá-lo corretamente.

Tree-shaking com CommonJS

É muito mais difícil analisar os módulos CommonJS, porque 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 ela usa CommonJS, é possível remover exportações não utilizadas no momento da criação com um plug-in webpack de terceiros. Embora este plug-in adicione suporte ao tree shaking, ele não abrange todas as maneiras pelas quais suas dependências podem usar o CommonJS. Isso significa que você não tem as mesmas garantias dos 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:

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