Como a CommonJS está aumentando seus pacotes

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

Nesta postagem, analisaremos o que é o CommonJS e por que ele está tornando seus pacotes JavaScript maiores que o necessário.

Resumo: para garantir que o bundler possa otimizar seu aplicativo, evite depender de módulos CommonJS e use a sintaxe de 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 desenvolvido para uso fora do navegador da Web, principalmente em aplicativos do lado do servidor.

Com o CommonJS, é possível 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]);

Posteriormente, outro módulo poderá 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 se tornou um formato de módulo popular também para bibliotecas JavaScript do lado do cliente.

Como a CommonJS afeta o tamanho final do pacote?

O tamanho do seu aplicativo JavaScript do lado do servidor não é tão importante quanto no navegador. É por isso que o CommonJS não foi projetado com a redução do tamanho do pacote de produção. Ao mesmo tempo, a análise mostra que o tamanho do pacote do JavaScript ainda é o principal motivo para deixar os apps de navegação mais lentos.

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

Por exemplo, no snippet acima, seu pacote final precisa incluir apenas a função add, já que esse é o único símbolo de utils.js importado no 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 do modo de produção e index.js como um ponto de entrada. Depois de invocar webpack, se analisarmos o tamanho da output, veremos algo assim:

$ 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 e vários módulos de lodash. Embora não usemos lodash em index.js, ele faz parte da saída, o que adiciona muito peso aos nossos recursos de produção.

Agora vamos alterar o formato do módulo para módulos ECMAScript e tentar novamente. Desta vez, o 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 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 ele 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. Ainda mais, o terser (o minificador de JavaScript usado pelo webpack) in-lineu a função add no console.log.

Uma boa pergunta seria: por que o uso do CommonJS faz com que o pacote de saída seja quase 16.000 vezes maior? É claro que esse é um exemplo de brinquedo. Na realidade, a diferença de tamanho pode não ser tão grande, mas é provável que a CommonJS dê um peso significativo ao build de produção.

Em 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 minifier otimizem o aplicativo, evite depender de módulos CommonJS e use a sintaxe de módulo ECMAScript em todo o aplicativo.

Mesmo que você esteja usando módulos ECMAScript no index.js, se o módulo que você está consumindo for CommonJS, o tamanho do pacote do seu app será prejudicado.

Por que a CommonJS aumenta o tamanho do seu app?

Para responder a essa pergunta, vamos analisar o comportamento da ModuleConcatenationPlugin em webpack e, depois disso, discutir a análise estática. Esse plug-in concatena o escopo de todos os seus 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 importamos em index.js. Também definimos uma função subtract. É possível criar o projeto usando a mesma configuração 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 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 como index_subtract.

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

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

Muitas vezes, os desenvolvedores consultam essa remoção de importações não utilizadas como tree-shaking. O tree shaking só foi possível porque o webpack conseguiu entender estaticamente (no tempo de compilação) quais símbolos estamos importando do utils.js e quais símbolos ele exporta.

Esse comportamento é ativado por padrão para módulos ES porque são mais estaticamente analisáveis em comparação com o CommonJS.

Vamos analisar exatamente o mesmo exemplo, mas, desta vez, vamos mudar 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 a incorporação nesta página é muito longa, 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 um "tempo de execução" webpack: código injetado que é responsável pela funcionalidade de importação/exportação 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 a função add no tempo de execução usando __webpack_require__.

Isso é necessário porque, com CommonJS, podemos obter 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 estão disponíveis apenas no momento da execução, no contexto do navegador do usuário.

Dessa forma, o minificador é incapaz de entender exatamente o que o index.js usa nas dependências, de modo que não consegue saí-lo da árvore. Também observaremos o mesmo comportamento com módulos de terceiros. Se importarmos um módulo CommonJS de node_modules, seu conjunto de ferramentas de build não vai poder otimizá-lo corretamente.

Árvore com o 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, em comparação com CommonJS, em que é uma expressão.

Em alguns casos, se a biblioteca que você estiver usando seguir convenções específicas sobre como usar o CommonJS, será possível remover exportações não utilizadas no tempo de compilação usando um plugin webpack de terceiros. Embora esse plug-in adicione suporte para tree shaking, ele não abrange todas as diferentes maneiras pelas quais as dependências podem usar o CommonJS. Isso significa que você não recebe as mesmas garantias dos módulos ES. Além disso, esse processo adiciona um custo extra como parte do processo de build, além do comportamento padrão do webpack.

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.

Aqui estão algumas dicas práticas para verificar se você está no caminho ideal:

  • Use o plug-in node-resolve do Rollup.js e defina a sinalização 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 são compatíveis com tree shaking.