Два года назад я писал о методике, которую сейчас обычно называют паттерном module/nomodule. Её применение позволяет писать JavaScript-код, используя возможности ES2015+, а потом применять бандлеры и транспиляторы для создания двух версий кодовой базы. Одна из них содержит современный синтаксис (она загружается с помощью конструкции вида <script type="module">
, а вторая — синтаксис ES5 (её загружают с помощью <script nomodule>
). Паттерн module/nomodule позволяет отправлять в браузеры, поддерживающие модули, гораздо меньше кода, чем в браузеры, эту возможность не поддерживающие. Теперь этот паттерн поддерживает большинство веб-фреймворков и инструментов командной строки.
Раньше, даже учитывая возможность отправлять современный JavaScript-код в продакшн, и даже хотя большинство браузеров поддерживало модули, я рекомендовал собирать код в бандлы.
Почему? В основном — потому, что у меня было ощущение того, что загрузка модулей в браузер была медленной. Даже несмотря на то, что свежие протоколы, вроде HTTP/2, теоретически поддерживали эффективную загрузку множества файлов, все исследования производительности в то время приходили к выводу о том, что использование бандлеров всё ещё более эффективно, чем использование модулей.
Но надо признать, что те исследования были неполными. Тестовые примеры с использованием модулей, которые в них изучались, состояли из неоптимизированных и неминифицированных файлов исходного кода, которые развёртывались в продакшне. Не проводилось сравнений оптимизированного бандла с модулями с оптимизированным классическим скриптом.
Однако, честно говоря, тогда не было некоего оптимального способа развёртывания модулей. Но сейчас, благодаря некоторым современным улучшениям в технологиях бандлеров, можно развёртывать продакшн-код в виде ES2015-модулей с использованием и статических, и динамических команд импорта, и получать при этом производительность более высокого уровня, чем можно достичь с применением доступных вариантов, в которых модули не используются.
Надо отметить, что на сайте, на котором опубликован оригинал материала, первую часть перевода которого мы сегодня публикуем, модули уже несколько месяцев используются в продакшне.
Ошибочные представления о модулях
Многие люди, с которыми мне доводилось беседовать, полностью отвергают модули, не рассматривая их даже как один из вариантов для крупномасштабных продакшн-приложений. Многие из них цитируют то самое исследование, которое я уже упоминал. А именно — ту его часть, в которой говорится о том, что модули в продакшне использовать не следует, если только речь не идёт о «маленьких веб-приложениях, в которые входит менее 100 модулей, отличающихся сравнительно «мелким» деревом зависимостей (то есть — таким, глубина которого не превышает 5 уровней)».
Если вы когда-нибудь заглядывали в директорию node_modules
какого-нибудь своего проекта, то вы, вероятно, знаете о том, что даже маленькое приложение легко может иметь более 100 модулей-зависимостей. Я хочу предложить вам взглянуть на то, как много модулей имеется в некоторых из самых популярных npm-пакетов.
Здесь-то и коренится главное заблуждение, касающееся модулей. Программисты полагают, что, когда дело доходит до использования модулей в продакшне, у них есть всего два варианта. Первый — развёртывать весь исходный код в его существующем виде (включая директорию 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-зависимости группируются в одном и том же фрагменте.
Продолжение следует…
Уважаемые читатели! Как вы подходите к проблеме разделения кода в своих проектах?
Автор: ru_vds