Два года назад я писал о методике, которую сейчас обычно называют паттерном module/nomodule. Её применение позволяет писать JavaScript-код, используя возможности ES2015+, а потом применять бандлеры и транспиляторы для создания двух версий кодовой базы. Одна из них содержит современный синтаксис (она загружается с помощью конструкции вида <script type="module">, а вторая — синтаксис ES5 (её загружают с помощью <script nomodule>). Паттерн module/nomodule позволяет отправлять в браузеры, поддерживающие модули, гораздо меньше кода, чем в браузеры, эту возможность не поддерживающие. Теперь этот паттерн поддерживает большинство веб-фреймворков и инструментов командной строки.



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

Почему? В основном — потому, что у меня было ощущение того, что загрузка модулей в браузер была медленной. Даже несмотря на то, что свежие протоколы, вроде HTTP/2, теоретически поддерживали эффективную загрузку множества файлов, все исследования производительности в то время приходили к выводу о том, что использование бандлеров всё ещё более эффективно, чем использование модулей.

Но надо признать, что те исследования были неполными. Тестовые примеры с использованием модулей, которые в них изучались, состояли из неоптимизированных и неминифицированных файлов исходного кода, которые развёртывались в продакшне. Не проводилось сравнений оптимизированного бандла с модулями с оптимизированным классическим скриптом.

Однако, честно говоря, тогда не было некоего оптимального способа развёртывания модулей. Но сейчас, благодаря некоторым современным улучшениям в технологиях бандлеров, можно развёртывать продакшн-код в виде ES2015-модулей с использованием и статических, и динамических команд импорта, и получать при этом производительность более высокого уровня, чем можно достичь с применением доступных вариантов, в которых модули не используются. 

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

Ошибочные представления о модулях


Многие люди, с которыми мне доводилось беседовать, полностью отвергают модули, не рассматривая их даже как один из вариантов для крупномасштабных продакшн-приложений. Многие из них цитируют то самое исследование, которое я уже упоминал. А именно — ту его часть, в которой говорится о том, что модули в продакшне использовать не следует, если только речь не идёт о «маленьких веб-приложениях, в которые входит менее 100 модулей, отличающихся сравнительно «мелким» деревом зависимостей (то есть — таким, глубина которого не превышает 5 уровней)».

Если вы когда-нибудь заглядывали в директорию node_modules какого-нибудь своего проекта, то вы, вероятно, знаете о том, что даже маленькое приложение легко может иметь более 100 модулей-зависимостей. Я хочу предложить вам взглянуть на то, как много модулей имеется в некоторых из самых популярных npm-пакетов.
Пакет
Число модулей
date-fns
729
lodash-es
643
rxjs
226

Здесь-то и коренится главное заблуждение, касающееся модулей. Программисты полагают, что, когда дело доходит до использования модулей в продакшне, у них есть всего два варианта. Первый — развёртывать весь исходный код в его существующем виде (включая директорию node_modules). Второй — совсем не использовать модули.

Однако если присмотреться к рекомендации из процитированного выше исследования, можно обнаружить, что там ничего не сказано о том, что загрузка модулей — это медленнее, чем загрузка обычных скриптов. Там ничего не говорится о том, что модули вообще не надо использовать. Там лишь идёт речь о том, что если некто разворачивает сотни неминифицированных файлов модулей в продкашне, Chrome не сможет загрузить их так же быстро, как единственный минифицированный бандл. В результате исследование советует продолжать использовать бандлеры, компиляторы и минификаторы.

Но знаете что? Дело в том, что можно и применять всё это, и использовать модули в продакшне.

На самом деле, модули — это формат, к преобразованию к которому кода нам стоит стремиться, так как браузеры уже знают о том, как загружать модули (а браузеры, которые этого не могут, способны загрузить запасной вариант кода с использованием механизма nomodule). Если вы посмотрите код, который генерируют самые популярные бандлеры, то вы обнаружите множество шаблонных фрагментов, цель которых заключается лишь в том, чтобы динамически загружать другой код и управлять зависимостями. Но всё это будет не нужно в том случае, если мы просто будем пользоваться модулями и выражениями import и export.

К счастью, по крайней мере один из популярных современных бандлеров (Rollup) поддерживает модули в виде формата выходных данных. Это означает, что можно и обрабатывать бандлером код, и разворачивать в продакшне модули (без использования шаблонных фрагментов для загрузки кода). И, так как в Rollup имеется прекрасная реализация алгоритма tree-shaking (лучшая из тех, что мне доводилось видеть в бандлерах), сборка программ в виде модулей с использованием Rollup позволяет получать код, размеры которого меньше, чем размеры аналогичного кода, полученного при применении других доступных сегодня механизмов.

Надо отметить, что поддержку модулей планируют добавить в следующую версию Parcel. Webpack пока не поддерживает модули в качестве выходного формата, но вот, вот и вот — обсуждения, которые посвящены этому вопросу.

Ещё одно заблуждение, касающееся модулей, заключается в том, что некоторые полагают, что модули можно использоват�� только в том случае, если 100% зависимостей проекта использует модули. К сожалению (я считаю — к огромному сожалению), большинство npm-пакетов всё ещё готовятся к публикации использованием формата CommonJS (некоторые модули, даже написанные с использованием возможностей ES2015, перед публикацией в npm транспилируются в формат CommonJS)!

Тут, опять же, хочу отметить, что Rollup имеет плагин (rollup-plugin-commonjs), который принимает на вход исходный код, написанный с использованием CommonJS, и конвертирует его в ES2015-код. Определённо, лучше будет, если в используемых зависимостях с самого начала применяется формат модулей ES2015. Но если некоторые зависимости таковыми не являются, это не мешает разворачивать в продакшне проекты, использующие модули.

В следующих частях этого материала я собираюсь продемонстрировать вам то, как я собираю проекты в бандлы, использующие модули (включая применение динамических импортов и разделения кода), собираюсь рассказать о том, почему подобные решения обычно более производительны, чем классические скрипты, и показать методики работы с браузерами, которые не поддерживают модули.

Оптимальная стратегия сборки кода


Сборка кода для продакшна — это всегда попытка сбалансировать плюсы и минусы различных решений. С одной стороны — разработчику хочется, чтобы его код загружался и выполнялся бы как можно быстрее. С другой — ему не хочется загружать код, который не будет задействован пользователями проекта.

Кроме того, разработчикам нужна уверенность в том, что их код как можно лучше подходит для кэширования. Большая проблема бандлинга кода заключается в том, что любое изменение кода, даже одна изменённая строка, приводит к инвалидации кэша всего бандла. Если вы разворачиваете приложение, состоящее из тысяч маленьких модулей (представленных в точности в том виде, в котором они присутствуют в исходном коде), тогда вы можете спокойно вносить в код мелкие изменения и при этом знать о том, что большая часть кода приложения окажется кэшированной. Но, как я уже говорил, такой подход к разработке, вероятно, может означать и то, что загрузка кода при первом посещении ресурса может занять больше времени, чем при использовании более традиционных подходов.

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

Большинство бандлеров, по умолчанию, применяют техники разделения кода с учётом команд динамического импорта. Но я бы сказал, что разделение кода лишь с ориентацией на динамический импорт не позволяет разбить его на достаточно мелкие фрагменты. Особенно это справедливо для сайтов с множеством возвращающихся пользователей (то есть — в тех ситуациях, когда важно кэширование).

Я полагаю, что код стоит разбивать на настолько мелкие фрагменты, насколько это возможно. Уменьшать размер фрагментов стоит до тех пор, пока их количество возрастёт настолько, что это станет воздейст��овать на скорость загрузки проекта. И хотя я, определённо, рекомендую каждому выполнять собственный анализ ситуации, если верить приблизительным подсчётам, выполненным в упомянутом мной исследовании, при загрузке менее чем 100 модулей заметного замедления загрузки не наблюдается. Отдельное исследование, посвящённое производительности HTTP/2, не выявило заметного замедления проекта при загрузке менее чем 50 файлов. Там, правда, тестировали только варианты, в которых число файлов составляло 1, 6, 50 и 1000. В результате, вероятно, 100 файлов — это то значение, на которое вполне можно ориентироваться, не боясь потерять в скорости загрузки.

Итак, каков же лучший способ агрессивного, но при этом не слишком агрессивного разделения кода на части? В дополнение к разделению кода, основанного на командах динамического импорта, я посоветовал бы ещё присмотреться к разделению кода по npm-пакетам. При таком подходе то, что импортируется в проект из папки node_modules, попадает в отдельный фрагмент готового кода на основании имени пакета.

Разделение кода на уровне пакетов


Выше я сказал, что некоторые современные возможности бандлеров делают возможным организацию высокопроизводительной схемы развёртывания проектов, основанных на модулях. То, о чём я говорил, представлено двумя новыми возможностями Rollup. Первая — это автоматическое разделение кода через динамические команды import() (добавлена в v1.0.0). Вторая возможность — это ручное разделение кода, выполняемое программой на основании опции manualChunks (добавлена в v1.11.0).

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

Вот пример конфигурации, в которой используется опция manualChunks, благодаря которой каждый модуль, импортированный из node_modules, попадает в отдельный фрагмент кода, имя которого соответствует имени пакета (технически — имени директории пакета в папке node_modules):

export default {
  input: {
    main: 'src/main.mjs',
  },
  output: {
    dir: 'build',
    format: 'esm',
    entryFileNames: '[name].[hash].mjs',
  },
  manualChunks(id) {
    if (id.includes('node_modules')) {
      // Возвращает имя директории, идущей после последнего `node_modules`.

      // Обычно это - пакет, хотя это может быть и пространством имён.

      const dirs = id.split(path.sep);
      return dirs[dirs.lastIndexOf('node_modules') + 1];
    }
  },
}

Опция manualChunk принимает функцию, которая принимает, в качестве единственного аргумента, путь к файлу модуля. Эта функция может возвратить строковое имя. То, что она возвратит, укажет на фрагмент сборки, к которому должен быть добавлен текущий модуль. Если функция не возвращает ничего — то модуль будет добавлен во фрагмент, используемый по умолчанию.

Рассмотрим приложение, которое импортирует модули cloneDeep(), debounce() и find() из пакета lodash-es. Если применить при сборке этого приложения вышеприведённую конфигурацию, то каждый из этих модулей (а так же каждый модуль lodash, импортируемый этими модулями) будет помещён в единственный выходной файл с именем наподобие npm.lodash-es.XXXX.mjs (здесь XXXX — это уникальный хэш файла модулей во фрагменте lodash-es).

В конце файла можно будет увидеть выражение экспорта наподобие следующего. Обратите внимание на то, что это выражение содержит только команды экспорта модулей, добавленных во фрагмент, а не всех модулей lodash.

export {cloneDeep, debounce, find};

Затем, если код в любом из других фрагментов использует эти модули lodash (возможно — лишь метод debounce()), в этих фрагментах, в их верхней части, будет иметься выражение импорта, выглядящее так:

import {debounce} from './npm.lodash.XXXX.mjs';

Надеюсь, этот пример прояснил вопрос о том, как работает ручное разделение кода в Rollup. Кроме того, я думаю, что результаты разделения кода, в которых используются выражения import и export, гораздо легче читать и понимать, чем код фрагментов, при формировании которых применялись нестандартные механизмы, использующиеся только в некоем бандлере.

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

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["import1"],{

/***/ "tLzr":
/*!*********************************!*\
  !*** ./app/scripts/import-1.js ***!
  \*********************************/
/*! exports provided: import1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "import1", function() { return import1; });
/* harmony import */ var _dep_1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./dep-1 */ "6xPP");

const import1 = "imported: " + _dep_1__WEBPACK_IMPORTED_MODULE_0__["dep1"];

/***/ })

}]);

Как быть, если имеются сотни npm-зависимостей?


Как я уже говорил, я считаю, что разделение кода на уровне пакетов обычно позволяет разработчику попасть в выгодную позицию, когда разделение кода ведётся агрессивно, но не слишком агрессивно.

Конечно, если ваше приложение импортирует модули из сотен различных npm-пакетов, вы всё ещё можете пребывать в ситуации, когда браузер не может эффективно их все загрузить.

Однако если у вас действительно имеется множество npm-зависимостей, вам не стоит пока совсем отказываться от этой стратегии. Помните о том, что вы, вероятно, не будете загружать все npm-зависимости на каждой странице. Поэтому важно выяснить то, сколько зависимостей загружается на самом деле.

Тем не менее, я уверен, что существуют некие реальные приложения, которые имеют так много npm-зависимостей, что эти зависимости просто невозможно представить в виде отдельных фрагментов. Если ваш проект именно таков — я порекомендовал бы вам поискать способ группировки пакетов, код в которых с высокой долей вероятности может меняться в одно и то же время (вроде react и react-dom) так как инвалидация кэша фрагментов с этими пакетами тогда тоже будет выполняться в одно и то же время. Позже я покажу пример, в котором все React-зависимости группируются в одном и том же фрагменте.

Продолжение следует…

Уважаемые читатели! Как вы подходите к проблеме разделения кода в своих проектах?