Менеджмент зависимостей в Javascript
Javascript стремительно развивается на протяжении уже более 20 лет. За это время появлялось огромное количество различных решений для разработки веб-приложений и, несмотря на развитие веб-стандартов и самой веб-платформы, сейчас уже достаточно тяжело представить себе проект, не использующий никаких сторонних библиотек. Для многих разработчиков процесс установки зависимостей представляет собой некую магию, которая происходит при выполнении npm install
.
На мой взгляд более чёткое понимание принципов работы этой “магии” позволит уменьшить головную боль при разработке больших сложных приложений и повысить эффективность процесса установки.
Чтобы разобраться в этих принципах, я предлагаю рассмотреть историю развития управления зависимостями в Javascript в хронологическом порядке.
Как мы делали раньше
До появления Node.js и NPM подключение библиотек к сайту осуществлялось с помощью тега script
прямо в HTML:
Чтобы это работало, нужно, чтобы по адресу <URL-библиотеки>
был размещён .js файл. Сделать это можно двумя способами:
-
Воспользоваться CDN, на котором уже размещён код библиотеки:
В таком случае у нас нет контроля над тем, что на самом деле получит пользователь, мы делегируем всю работу провайдеру CDN и доверяем ему.
В качестве бонуса пользователи получали кросс-доменный кеш и, если, например, они уже загрузили jQuery на другом сайте, при открытии нашего сайта они получали её из кеша вместо того, чтобы загружать его с CDN заново, так как URL совпадал. Но, к сожалению, этот механизм
более не актуален . -
Скачать код библиотеки и самостоятельно положить его, например, в директорию
vendors
:В таком случае мы имеем полный контроль над кодом библиотек и способом его получения пользователями, при необходимости можем производить над ними дополнительные преобразования (например, минифицировать).
Второй способ становился всё более и более актуальным, но с ростом экосистемы Javascript росло и количество библиотек, подключаемых к сайту. Скачивать все библиотеки вручную и хранить их в репозитории с кодом становилось всё более накладно, поэтому появился инструмент, именуемый
Bower
Bower — пакетный менеджер. Его основная задача — автоматизировать загрузку различных компонентов приложения со сторонних ресурсов. Непосредственно в репозитории с кодом мы в таком случае храним только информацию о том, что ему нужно скачать, в файле bower.json
:
(Ничего не напоминает?)
При выполнении команды bower install
Bower установит зависимости, указанные в поле dependencies
.
Bower имеет свой собственный реестр пакетов, из которого он их и скачивает.
Версионирование
Стоит отдельно отметить, что в bower.json
мы указываем не конкретный URL, по которому он должен загрузить библиотеку, а диапазон версий согласно
SemVer гарантирует, что при выборе любой версии из указанного диапазона проект будет работать.
Как это работает?
Например, мы хотим использовать в своём проекте библиотеку React.
Мы открываем документацию и изучаем API библиотеки, обращая внимание, для какой версии библиотеки написана эта документация (например, это версия 16.1.0
).
Первый разряд версии согласно SemVer означает изменения этого API, ломающие обратную совместимость (мажорные), а вторая — обратносовместимые изменения API (минорные). Соответственно минимальная версия, которая нам подойдёт для использования всего API, который мы видели в документации, — 16.1.0
, а максимальная версия, которую мы можем использовать, не опасаясь за то, что проект перестанет работать, — 17.0.0
. Записать такой диапазон можно в виде >=16.1.0 <17.0.0
, но для более краткой записи существуют модификаторы диапазона версий, с помощью которых мы можем обозначить тот же самый диапазон версий как ^16.1.0
.
На практике же всё не всегда так радужно и разработчик библиотеки может случайно выпустить ломающие изменения в минорном обновлении библиотеки, в таких случаях нам придётся внимательно следить за историей обновлений библиотеки и подбирать диапазон так, чтобы не напороться на эти проблемы, либо же указывать конкретную версию вместо диапазона.
Транзитивные зависимости
Bower позволил формализовать и автоматизировать управление зависимостями во фронтенд-разработке, что подтолкнуло экосистему Javascript к закономерному росту и соответственно усложнению.
Помимо появления пакетного менеджера, возникали
Зависимости зависимостей проекта называются транзитивными.
Разрешение зависимостей
Пакетный менеджер начинает установку с этапа разрешения (resolution) зависимостей. На этом этапе он анализирует зависимости в поле dependencies
и подбирает наиболее актуальные версии библиотек, соответствующие указанным в нём диапазонам. Но, поскольку у загружаемых библиотек могут быть свои зависимости, разрешение зависимостей производится и для них. В результате этот процесс становится рекурсивным и представляет собой обход дерева, которое постепенно достраивается.
Зависимости для локальной разработки
Помимо использования библиотек непосредственно в коде приложения, разработчики пишут автотесты, производят всяческие манипуляции с исходным кодом и делают множество других несомненно полезных вещей. Чтобы не изобретать свой велосипед, разумеется для этого также используются различные библиотеки. Но, когда мы добавляем библиотеку в свой проект, мы не хотим вместе с её исходным кодом загрузить ещё и тонну инструментов, которые несомненно полезны самой библиотеке, но нам они могут быть абсолютно не нужны, поэтому для экономии дискового пространства пользователей библиотек в bower.json
появилось поле devDependencies
.
devDependencies
— зависимости, которые пакетный менеджер установит только если они являются прямыми зависимостями проекта. Транзитивные devDependencies
пакетный менеджер игнорирует.
Плоская модель установки
Bower поддерживает только плоскую модель установки зависимостей, что подразумевает загрузку всех прямых и транзитивных зависимостей в одну директорию.
Для примера выше результат установки с Bower будет выглядеть так:
Такая структура имеет место быть, но с развитием экосистемы Javascript количество транзитивных зависимостей крайне быстро растёт, что рано или поздно неизбежно приводит к конфликтам их версий.
Конфликты могут возникнуть в случае, если зависимости проекта зависят от разных версий одной и той же библиотеки:
Bower устанавливает все зависимости в одну директорию и не может установить несколько версий одного и того же пакета, поэтому разработчику приходится решать такие конфликты вручную путём выбора версии, которая в результате будет использоваться, что довольно рискованно в случае с несколькими разными мажорными версиями.
Ручное разрешение конфликтов
Для разрешения подобных конфликтов в bower.json
появилось поле
Тем не менее выбор одной из нескольких мажорных версий зависимости — не самое лучший вариант, так как одна из транзитивных зависимостей с высокой долей вероятности может сломаться, более безопасно было бы установить обе версии, чего Bower не позволяет. Решение этой проблемы было найдено в смежной области — бекенд-разработке на Node.js. Для этой платформы был разработан свой пакетный менеджер — NPM.
NPM
NPM имел “nested” модель установки, которая подразумевает, что для каждой зависимости проекта создаётся своя директория node_modules
, в которой изолированно хранятся её зависимости — это позволяет избежать конфликтов версий.
Поскольку NPM изначально предназначается для Node.js, все пакеты в нём имели модульный формат CommonJS, который не поддерживается в браузере, соответственно использовать их для фронтенда было невозможно, однако с появлением --flat
, который менял модель установки на плоскую.
Переход на “nested” модель установки был не бесплатным: директория node_modules
представляла собой довольно глубокую иерархию пакетов, которая занимала колоссальное количество места на диске, а также могла приводить к проблемам из-за
Для бекенда это было приемлемо, но тянуть на сайт так много библиотек, среди которых множество дубликатов, никому не хотелось, поэтому в NPM 3 появилась новая “hoisted” модель установки и механизм дедупликации пакетов.
”Hoisted” модель установки представляет собой нечто среднее между плоской и “nested” моделями. В ней пакеты по возможности хранятся в самой верхней директории node_modules
, а вложенности возникают только в случае конфликтов версий.
Работа этой модели обеспечивается require
, Node.js проходит по всем директориям node_modules
снизу вверх, то есть “всплывает” (аналогично всплытию переменных в Javascript), поэтому модель и называется “hoisted”.
Конфигурация NPM
Управлять тем, как NPM производит различные операции (такие как установка и публикация), можно с помощью флагов командной строки и с помощью файла
В отличие от многих других конфигурационных файлов (например, .gitignore
или .prettierrc
) .npmrc
не ищется рекурсивно, в общем случае NPM ожидает его только в двух местах: непосредственно в директории проекта и в домашней директории текущего пользователя (~/
для Linux и Mac OS или %homepath%
для Windows). Оба файла будут объединены, при этом значения параметров проекта будут иметь приоритет над пользовательскими.
Чаще всего в .npmrc
указывается параметр registry
, который отвечает за выбор реестра пакетов. По умолчанию его значение равно “
Стоит отметить, что можно указать отдельный registry
для пакетов определённой организации. Предположим, компания, в которой вы работаете, публикует внутренние пакеты в приватном репозитории с префиксом @my-company/
(например, @my-company/awesome-library
). В таком случае содержимое .npmrc
будет выглядеть примерно так:
Авторизация в NPM
Чтобы публиковать пакеты или устанавливать из приватного репозитория, необходимо авторизоваться в NPM. Это можно сделать с помощью команды .npmrc
, так как это более явный способ и при этом не сильно более сложный.
Авторизация с токеном <MY_TOKEN>
для npmjs выглядит в .npmrc
следующим образом:
Обратите внимание, что //
в начале строки не обозначает комментарий — это обычная часть URL, которая следует после протокола, но в данном случае протокол не имеет значения, так как авторизация для http и https будет одинаковой.
Авторизационные данные для репозиториев лучше хранить в .npmrc
, находящемся в домашней директории, в таком случае они будут использоваться для всех проектов на вашей машине и вы точно случайно не закоммитите их в GIT.
Публикация пакетов
Чтобы сделать свой NPM-пакет доступным для загрузки другими разработчиками, его необходимо опубликовать в реестре пакетов (registry). Глобальным реестром NPM-пакетов является
Для публикации пакета существует команда
По умолчанию в архив попадает всё содержимое проекта, в связи с чем размер пакета может оказаться неоправданно большим.
Если в package.json
определено поле
Также можно указать исключения в файле .npmignore
— это работает аналогично тому, как работает .gitignore
.
Предположим, вы собираете свою библиотеку с помощью компилятора Typescript в директорию lib
. В таком случае в поле files
следует указать ["/lib"]
. Далее можно, например, исключить из публикации файлы тестов, добавив в .npmignore
строчку *.test.*
.
Некоторые критичные для пакета файлы, например package.json
и README.md
будут опубликованы в любом случае, а некоторые файлы и директории, например, .git
или node_modules
никогда не попадут в публикуемый архив, но с последним есть нюанс.
Публикация зависимостей вместе с пакетом
Если какие-либо из зависимостей публикуемого пакета указаны в виде пути в файловой системе (например, file:../my-awesome-library
, что не является хорошей практикой, но тем не менее имеет место быть), их можно опубликовать вместе с пакетом, указав их в поле package.json
. В таком случае директория node_modules
всё же попадёт в публикуемый архив, но в ней останутся только пакеты, указанные в этом поле.
Когда пользователь установит пакет, у которого есть bundledDependencies
, пакетный менеджер возьмёт такие зависимости из архива самого пакета вместо того, чтобы загружать их отдельно.
Основной сценарий использования bundledDependencies
в настоящий момент — дать пользователям возможность загружать утилиты одним файлом, снизив тем самым время загрузки, так как пакетный менеджер вместо нескольких последовательных запросов на сервер делает всего один, — так делает, например,
Необязательные зависимости
В package.json
существует поле optionalDependencies
, работающее аналогично dependencies
, но подразумевающее, что пакет в целом может работать и без них.
Его можно использовать, например, для каких-либо пакетов, которые нужны не всегда.
Например, установка cypress
не используется в некоторых окружениях, можно перенести его в секцию optionalDependencies
и выполнять установку с флагом --omit=optional
(--no-optional
в более ранних версиях NPM).
Ключевое отличие optionalDependencies
от dependencies
заключается в том, что в случае невозможности установки указанных в этом поле пакетов NPM не завершит процесс с ошибкой, а продолжит установку остальных зависимостей в штатном режиме. Эта особенность используется авторами NPM-пакетов, содержащих бинарные файлы для разных операционных систем. Например, сборщик package.json
и установит только те, что соответствуют текущей ОС.
”Плагины” для пакетов
Когда мы устанавливаем, например, расширение для Chrome, мы ожидаем, что оно будет использовать нашу версию Chrome, а не установит какую-то свою. С NPM-пакетами принцип то же — плагин должен использовать уже установленную в проекте версию хост-пакета.
Обратите внимание, что понятие “плагин” в данном случае довольно широкое и, например, библиотека React-компонентов будет фактически являться плагином для React.
При этом плагин может быть совместим только с определёнными версиями хост-пакета, поскольку использует его API, а значит может перестать работать, если этот API будет удалён.
Реализацией вышеописанного механизма являются peerDependencies
.
При разработке плагина стоит указать его хост-пакет в поле peerDependencies
в package.json
, чтобы подсказать пакетному менеджеру, как поступать в такой ситуации.
В таком случае мы не объявляем прямую зависимость библиотеки от хост-пакета, а предъявляем требования к пользователю, обязывая его установить его самостоятельно.
NPM 7 и выше автоматически установит недостающие peerDependencies
.
В peerDependencies
стоит указывать как можно более широкий диапазон версий, чтобы дать пользователю библиотеки возможность выбора. Так как если, например, библиотека будет ожидать "react": "^17.0.0"
, а её пользователь использует "react": "18.0.0"
, то возникнет конфликт версий зависимостей, что приведёт к ошибке установки при использовании NPM 7 и выше.
Пользователю эта ошибка может быть непонятна и он весьма вероятно попытается установить зависимости с флагом --force
или --legacy-peer-deps
, как подсказывает сам текст ошибки, что заставит NPM работать “по старинке” (как до NPM 7), но это может привести к проблемам с дубликатами.
Переопределение версий
Решить такие проблемы можно по старинке — вручную. Для этого в package.json
появилось поле resolutions
из Bower, но поддерживает каскад, как в CSS.
Похожее поле есть и в других пакетных менеджерах, но, поскольку для package.json
нет никакой общей спецификации, работает и называется оно по разному. Например, в Yarn для решения этой проблемы есть поле
Опциональный хост
Может случиться так, что библиотека окажется достаточно универсальна, что будет способна работать без хост-пакета, но при его наличии будет производить какие-то дополнительные действия. В таком случае мы не хотим заставлять пользователя устанавливать хост-пакет, но, если он его всё-таки установит, нам всё ещё необходимо проследить, что установленная им версия будет совместима с нашей библиотекой.
Для решения этой задачи в package.json
существует поле peerDependenciesMeta
— оно позволяет предоставить пакетному менеджеру дополнительный контекст для установки зависимостей.
На текущий момент в peerDependenciesMeta
доступен только параметр optional
, который говорит о том, что наличие пакета необязательно.
То есть peerDependenciesMeta.optional
является аналогом optionalDependencies
, но для peerDependencies
.
Воспроизводимость
Как мы выяснили ранее, пакетный менеджер начинает установку с разрешения зависимостей. В большинстве случаев зависимости пакетов задаются не фиксированными версиями, а диапазонами версий, что даёт пакетному менеджеру некоторый простор для манёвра, но лишает нас гарантии, что две выполненные друг за другом установки дадут одинаковый результат. Но чем это грозит?
Допустим, мы установили зависимости проекта, реализовали в нём новую фичу, протестировали все возможные сценарии и со спокойной душой отправили код в продакшен. Но на момент установки зависимостей в CI пакетный менеджер обнаружил, что может установить чуть более свежую версию одной из транзитивных зависимостей. В результате наш идеально выверенный код неожиданно начинает работать несколько иначе. Возможно риск напороться на неприятности из-за этого невелик, но этому риску будет подвержена абсолютно каждая установка зависимостей проекта.
Решил эту проблему альтернативный пакетный менеджер — package.json
и yarn.lock
соответствуют друг другу и, полностью пропустив этап разрешения зависимостей, фактически просто загрузит пакеты по списку. Такой подход ускоряет установку, поскольку сетевых запросов в результате совершается меньше, и, что самое главное, делает её предсказуемой — теперь две последующие установки точно дадут одинаковый результат, даже на другой машине.
Yarn подтолкнул NPM к развитию и впоследствии он тоже научился генерировать свои
Чистая установка
Чтобы добиться действительно предсказуемой установки в автоматизированных средах, важно использовать команду npm install
.
npm install | npm ci | |
---|---|---|
package.json | Используется как основной источник истины | Используется для валидации package-lock.json |
package-lock.json | Используется как вспомогательный источник информации о версиях | Используется как основной источник истины |
Команда npm ci
расшифровывается как “clean install”, поскольку при её выполнении NPM полностью удаляет директорию node_modules
и загружает все зависимости “с чистого листа”, что также улучшает воспроизводимость.
Yarn
Помимо вышеописанного механизма фиксации версий зависимостей Yarn также имел ряд других преимуществ перед NPM, таких как простота использования, безопасность и скорость. Давайте рассмотрим подробнее, в чём именно заключаются эти преимущества.
Простота использования
Функциональность NPM расширялась постепенно, новые фичи появлялись и его API разрастался, а кардинально менять его и заставлять разработчиков привыкать к новым командам при переходе на новую версию не хотелось. Создавать удобный DX в таких условиях довольно проблематично. Yarn же создавался с нуля, учитывая опыт использования NPM, поэтому его CLI получился несколько более интуитивным и простым в использовании.
NPM | Yarn |
---|---|
npm install | yarn install /yarn |
npm install --save react | yarn add react |
npm ci | yarn install --frozen-lockfile |
Часто используемые команды стали короче, а команды для CI — читабельнее.
Безопасность
Помимо фиксированных версий зависимостей в yarn.lock
сохраняется также их контрольная сумма (integrity
каждого пакета. Она позволяет при установке из локфайла убедиться, что его никто не подменил и устанавливается ровно то же самое, что и при генерации локфайла.
Позже эту информацию стал сохранять и NPM.
Скорость
Основная причина быстроты Yarn — кеш. Он позволяет создать на своей машине собственный реестр пакетов, чтобы в процессе установки заменять сетевой запрос на копирование папок в файловой системе. Меньше сетевых запросов — меньше времени занимает установка.
При этом кеш может работать не только для отдельно взятого проекта. Его можно переиспользовать между всеми проектами, которые вы разрабатываете на своей машине.
Собственный реестр пакетов
Чтобы получить контроль над пакетами, которые используются в проектах, большие компании организуют собственные репозитории пакетов, которые могут проксировать глобальный реестр NPM. Обычно для этого используется
Также собственный репозиторий может использоваться в качестве удалённого кеша, чтобы ускорять установку зависимостей за счёт того, что такой кеш будет находиться ближе к разработчикам. Для этого можно воспользоваться более легковесным и опенсорсным аналогом Nexus — .npmrc
адрес сервера с Verdaccio.
С Verdaccio можно и локально попрактиковаться в публикации пакетов, если у вас не было опыта в этом.
Связывание пакетов локально
При разработке нескольких пакетов в едином монорепозитории возникает задача связать их между собой, чтобы они могли переиспользовать код друг друга. Очевидно, что публиковать их в NPM при каждом изменении и переустанавливать заново было бы весьма накладно, а код уже находятся рядом, нужно просто локально подключить один пакет к другому. Это можно сделать несколькими способами:
-
Просто импортировать код из библиотеки или вложить их друг в друга.
Пожалуй, это худшее, что можно придумать в данной ситуации, поскольку
связанность кода в таком случае будет неконтролируема и все преимущества разбиения на пакеты сойдут на нет, а проект превратится в один большой монолит. -
Указать в
package.json
одного пакета вместо версии зависимости путь в файловой системе до другого (например,file:../my-library
).В целом рабочий вариант, но нарушается инверсия зависимостей — пакет перестаёт зависеть от абстракции и начинает зависеть от конкретного кода. Если такой пакет понадобится опубликовать, придётся включать в архив все подобные его зависимости с помощью поля
bundledDependencies
. -
Использовать
npm link .Можно указать в
package.json
пакета последнюю опубликованную в NPM версию зависимости и заменить еёсимлинком на локальную версию командойnpm link
, но делать это придётся после каждой установки зависимостей, что довольно неудобно. -
Использовать
Lerna .Lerna фактически была создана для автоматизации выполнения
npm link
с целью организации монорепозитория. -
Использовать
Workspaces .С появлением во всех актуальных пакетных менеджерах механизма Workspaces использование Lerna стало бесполезным, поскольку практически всё то же самое можно получить из коробки просто создав в корне монорепозитория
package.json
с полемworkspaces
:
Фантомные зависимости
Как мы выяснили ранее, механизм всплытия пакетов в node_modules
помогает избежать дублирования пакетов. Но также такие зависимости становятся доступными в нашем пакете, из-за чего мы можем столкнуться с довольно непредсказуемыми проблемами.
Например, мы используем библиотеку library-a
версии “1.0.0”, которая в свою очередь зависит от библиотеки library-b
. Поскольку library-b
всплывёт на верхний уровень node_modules
, мы сможем импортировать её в проект.
Может случиться так, что, например, в следующей патч-версии “1.0.1” library-a
больше не будет зависеть от library-b
, что вполне валидная ситуация, поскольку внешний API библиотеки не изменился. В таком случае library-b
не установится и мы больше не сможем использовать её в своём проекте, но весьма вероятно мы узнаем это только перед продакшен сборкой в CI, поскольку там производим чистую установку с npm ci
.
Использование транзитивной зависимости без явного указания её в package.json
называется фантомной зависимостью.
Простое решение этой проблемы заключается в валидации импортов в проекте с помощью
Структура зависимостей
Многие называют структуру зависимостей деревом, что не совсем верно с концептуальной точки зрения. Зависимости представляют собой
Самое важное отличие графа от дерева заключается в возможности возникновения ромбовидных зависимостей.
Файловая система же представляет собой именно дерево и не может иметь ромбовидных зависимостей, поэтому пакетному менеджеру и приходится делать некоторые преобразования, чтобы записать пакеты на диск в node_modules
. “Nested” модель установки наиболее близка к исходной структуре данных, но фактически она предлагает дублировать узлы графа, в которых возникли ромбовидные зависимости, что приводит к огромному количеству дубликатов, но на самом деле в файловых системах есть более эффективный инструмент для решения этой задачи — симлинки, которые позволяют создать ссылку на файл или директорию, вместо дублирования содержимого.
На основе этой идеи был разработан новый пакетный менеджер —
PNPM
PNPM в отличие от NPM и Yarn не пытается сделать структуру node_modules
как можно более плоской, вместо этого он скорее нормализует граф зависимостей. После установки PNPM создаёт в node_modules
директорию .pnpm
, которая концептуально представляет собой хранилище ключ-значение, в котором ключом является название пакета и его версия, а значением — содержимое этой версии пакета. Такая структура данных исключает возможность возникновения дубликатов. Структура самой директории node_modules
будет подобна “nested”-модели из NPM, но вместо физических файлов ней будут находиться симлинки, которые ведут в то самое хранилище пакетов.
В node_modules
каждого пакета будут находиться только симлинки на те пакеты, которые указаны у него в package.json
, что полностью избавляет нас от проблемы фантомных зависимостей и потребность в наличии ESLint-плагина отпадает.
Глобальное хранилище пакетов
PNPM может создать директорию .pnpm
не только в node_modules
проекта, но и глобально. В таком случае node_modules
у проектов будут содержать только симлинки, за счёт чего ускоряется установка зависимостей (создание симлинка занимает меньше времени, чем копирование файлов) и экономится колоссальное количество дискового пространства.
Переопределение зависимостей
Для переопределения зависимостей PNPM тоже имеет package.json
всех пакетов в дереве зависимостей на этапе разрешения. Это позволяет максимально точно исправлять ошибки, возникающие с транзитивными зависимостями.
Простота использования
PNPM имеет API, очень похожий на Yarn, что позволяет не привыкать к новым командам в третий раз.
По всем вышеописанным причинам я предпочитаю использовать PNPM во всех своих проектах.
Yarn PnP
Разработчики Yarn решили пойти по более революционному пути для решения проблемы фантомных зависимостей, добавив режим node_modules
создаёт файл .pnp.js
, в котором сохраняет всю необходимую ему информацию для разрешения зависимостей. Не все пакеты в NPM будут совместимы с этим режимом, поэтому его внедрение может вызвать некоторые трудности, но весьма вероятно, что для менеджмента зависимостей в Javascript это большой шаг в будущее.
Будущее менеджмента зависимостей
По моим наблюдениям инструменты, управляющие зависимостями в Javascript, постепенно идут к полному избавлению от директории node_modules
в проекте и, возможно, к разрешению зависимостей прямо в рантайме благодаря script
, но это тоже выглядит как промежуточный шаг к полному переходу на ES-модули.