Как CommonJS увеличивает размер ваших пакетов

Узнайте, как модули CommonJS влияют на структуру дерева вашего приложения.

В этом посте мы рассмотрим, что такое CommonJS и почему он делает ваши пакеты JavaScript больше, чем необходимо.

Резюме: Чтобы сборщик мог успешно оптимизировать ваше приложение, избегайте зависимости от модулей CommonJS и используйте синтаксис модулей ECMAScript во всем вашем приложении.

Что такое CommonJS?

CommonJS — это стандарт 2009 года, устанавливающий соглашения для модулей JavaScript. Изначально он предназначался для использования вне веб-браузера, в первую очередь для серверных приложений.

С помощью CommonJS вы можете определять модули, экспортировать из них функциональность и импортировать их в другие модули. Например, фрагмент ниже определяет модуль, который экспортирует пять функций: add , subtract , multiply , divide и 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]);

Позже другой модуль может импортировать и использовать некоторые или все эти функции:

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

Вызов index.js с node выведет в консоль число 3 .

Из-за отсутствия стандартизированной системы модулей в браузере в начале 2010-х годов CommonJS стал также популярным форматом модулей для клиентских библиотек JavaScript.

Как CommonJS влияет на окончательный размер вашего пакета?

Размер вашего серверного приложения JavaScript не так важен, как в браузере, поэтому CommonJS не был разработан с учетом уменьшения размера производственного пакета. В то же время анализ показывает, что размер пакета JavaScript по-прежнему является основной причиной замедления работы браузерных приложений.

Упаковщики и минификаторы JavaScript, такие как webpack и terser , выполняют различные оптимизации для уменьшения размера вашего приложения. Анализируя ваше приложение во время сборки, они стараются удалить как можно больше из исходного кода, который вы не используете.

Например, в приведенном выше фрагменте ваш окончательный пакет должен включать только функцию add , поскольку это единственный символ из utils.js , который вы импортируете в index.js .

Давайте создадим приложение, используя следующую конфигурацию webpack :

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

Здесь мы указываем, что хотим использовать оптимизацию производственного режима, и используем index.js в качестве точки входа. Если после вызова webpack мы изучим размер вывода , мы увидим что-то вроде этого:

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

Обратите внимание, что размер пакета составляет 625 КБ . Если мы посмотрим на выходные данные, то обнаружим все функции из utils.js плюс множество модулей из lodash . Хотя мы не используем lodash в index.js , он является частью вывода , что добавляет дополнительный вес нашим производственным активам.

Теперь давайте изменим формат модуля на модули ECMAScript и попробуем еще раз. На этот раз utils.js будет выглядеть так:

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);

И index.js будет импортироваться из utils.js , используя синтаксис модуля ECMAScript:

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

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

Используя ту же конфигурацию webpack , мы можем создать наше приложение и открыть выходной файл. Теперь это 40 байт со следующим выводом :

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

Обратите внимание, что финальный пакет не содержит ни одной функции из utils.js , которую мы не используем, и нет никаких следов lodash ! Более того, terser (минификатор JavaScript, который использует webpack ) встроил функцию add в console.log .

Вы можете задать справедливый вопрос: почему использование CommonJS приводит к увеличению выходного пакета почти в 16 000 раз ? Конечно, это игрушечный пример, на самом деле разница в размерах может быть не такой уж большой, но есть вероятность, что CommonJS добавит значительный вес вашей производственной сборке.

Модули CommonJS в общем случае сложнее оптимизировать, поскольку они гораздо более динамичны, чем модули ES. Чтобы ваш упаковщик и минификатор могли успешно оптимизировать ваше приложение, избегайте зависимости от модулей CommonJS и используйте синтаксис модулей ECMAScript во всем вашем приложении.

Обратите внимание: даже если вы используете модули ECMAScript в index.js , если используемый вами модуль является модулем CommonJS, размер пакета вашего приложения пострадает.

Почему CommonJS увеличивает ваше приложение?

Чтобы ответить на этот вопрос, мы рассмотрим поведение ModuleConcatenationPlugin в webpack , а затем обсудим возможности статического анализа. Этот плагин объединяет область действия всех ваших модулей в одно замыкание и позволяет ускорить выполнение вашего кода в браузере. Давайте посмотрим на пример:

// 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));

Выше у нас есть модуль ECMAScript, который мы импортируем в index.js . Мы также определяем функцию subtract . Мы можем собрать проект, используя ту же конфигурацию webpack , что и выше, но на этот раз мы отключим минимизацию:

const path = require('path');

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

Давайте посмотрим на полученный результат:

/******/ (() => { // 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));**

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

В приведенном выше выводе все функции находятся в одном пространстве имен. Чтобы предотвратить коллизии, веб-пакет переименовал функцию subtract в index.js в index_subtract .

Если минификатор обрабатывает приведенный выше исходный код, он:

  • Удалите неиспользуемые функции subtract и index_subtract
  • Удалите все комментарии и лишние пробелы.
  • Встроить тело функции add в вызов console.log .

Часто разработчики называют такое удаление неиспользуемого импорта «встряхиванием дерева» . Встряхивание дерева было возможно только потому, что веб-пакет мог статически (во время сборки) понимать, какие символы мы импортируем из utils.js и какие символы он экспортирует.

Такое поведение включено по умолчанию для модулей ES , поскольку они более поддаются статическому анализу по сравнению с CommonJS.

Давайте посмотрим на тот же пример, но на этот раз изменим utils.js , чтобы использовать CommonJS вместо модулей 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]);

Это небольшое обновление существенно изменит результат. Поскольку он слишком длинный для вставки на эту страницу, я поделился лишь небольшой его частью:

...
(() => {

"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));

})();

Обратите внимание, что окончательный пакет содержит некоторую «время выполнения» webpack : внедренный код, который отвечает за импорт/экспорт функций из связанных модулей. На этот раз вместо того, чтобы размещать все символы из utils.js и index.js в одном пространстве имен, мы динамически, во время выполнения, требуем функцию add , используя __webpack_require__ .

Это необходимо, поскольку с помощью CommonJS мы можем получить имя экспорта из произвольного выражения. Например, приведенный ниже код является абсолютно допустимой конструкцией:

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

У сборщика нет возможности узнать во время сборки, каково имя экспортируемого символа, поскольку для этого требуется информация, доступная только во время выполнения, в контексте браузера пользователя.

Таким образом, минификатор не способен понять, что именно использует index.js из своих зависимостей, поэтому он не может избавиться от этого в дереве. Точно такое же поведение мы будем наблюдать и для сторонних модулей. Если мы импортируем модуль CommonJS из node_modules , ваша цепочка инструментов сборки не сможет его правильно оптимизировать.

Встряхивание дерева с помощью CommonJS

Анализировать модули CommonJS гораздо сложнее, поскольку они по определению динамичны. Например, местоположение импорта в модулях ES всегда является строковым литералом, в отличие от CommonJS, где это выражение.

В некоторых случаях, если используемая вами библиотека соответствует определенным соглашениям о том, как она использует CommonJS, можно удалить неиспользуемые экспорты во время сборки с помощью стороннего плагина webpack . Хотя этот плагин добавляет поддержку встряхивания деревьев, он не охватывает все способы использования CommonJS вашими зависимостями. Это означает, что вы не получаете тех же гарантий, что и при использовании модулей ES. Кроме того, это добавляет дополнительные затраты как часть процесса сборки в дополнение к поведению webpack по умолчанию.

Заключение

Чтобы сборщик мог успешно оптимизировать ваше приложение, избегайте зависимости от модулей CommonJS и используйте синтаксис модуля ECMAScript во всем вашем приложении.

Вот несколько практических советов, которые помогут убедиться, что вы находитесь на оптимальном пути:

  • Используйте плагин Node-Resolve Rollup.js и установите флаг modulesOnly , чтобы указать, что вы хотите зависеть только от модулей ECMAScript.
  • Используйте пакет is-esm , чтобы убедиться, что пакет npm использует модули ECMAScript.
  • Если вы используете Angular, по умолчанию вы получите предупреждение, если вы зависите от модулей, не поддерживающих дерево.