Javascript стремительно развивается на протяжении уже более 20 лет. За это время появлялось огромное количество различных решений для разработки веб-приложений и, несмотря на развитие веб-стандартов и самой веб-платформы, сейчас уже достаточно тяжело представить себе проект, не использующий никаких сторонних библиотек. Для многих разработчиков процесс установки зависимостей представляет собой некую магию, которая происходит при выполнении npm install.

Магический npm install
Магический npm install

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

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

Как мы делали раньше

До появления Node.js и NPM подключение библиотек к сайту осуществлялось с помощью тега script прямо в HTML:

<script src="<URL-библиотеки>"></script>

Чтобы это работало, нужно, чтобы по адресу <URL-библиотеки> был размещён .js файл. Сделать это можно двумя способами:

  • Воспользоваться CDN, на котором уже размещён код библиотеки:

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>

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

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

  • Скачать код библиотеки и самостоятельно положить его, например, в директорию vendors:

    <script src="vendors/jquery-3.6.1.min.js"></script>

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

Второй способ становился всё более и более актуальным, но с ростом экосистемы Javascript росло и количество библиотек, подключаемых к сайту. Скачивать все библиотеки вручную и хранить их в репозитории с кодом становилось всё более накладно, поэтому появился инструмент, именуемый Bower.

Bower

Bower — пакетный менеджер. Его основная задача — автоматизировать загрузку различных компонентов приложения со сторонних ресурсов. Непосредственно в репозитории с кодом мы в таком случае храним только информацию о том, что ему нужно скачать, в файле bower.json:

{
  "name": "my-app",
  "dependencies": {
    "react": "^16.1.0"
  }
}

(Ничего не напоминает?)

При выполнении команды bower install Bower установит зависимости, указанные в поле dependencies.

Bower имеет свой собственный реестр пакетов, из которого он их и скачивает.

Версионирование

Стоит отдельно отметить, что в bower.json мы указываем не конкретный URL, по которому он должен загрузить библиотеку, а диапазон версий согласно SemVer, что фактически является реализацией принципа инверсии зависимостей. Проект зависит не от конкретного кода, хранящегося на удалённом сервере, а от абстракции в виде диапазона версий, за выбор соответствующей версии и загрузку кода отвечает пакетный менеджер.

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 пакетный менеджер игнорирует.

Установка зависимостей с devDependencies
Установка зависимостей с devDependencies

Плоская модель установки

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

Для примера выше результат установки с Bower будет выглядеть так:

Пример установки зависимостей с Bower
Пример установки зависимостей с Bower

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

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

Конфликт версий зависимостей
Конфликт версий зависимостей

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

Ручное разрешение конфликтов

Для разрешения подобных конфликтов в bower.json появилось поле resolutions, позволяющее вручную произвести разрешение транзитивной зависимости.

{
  "resolutions": {
    "library-d": "2.0.0"
  }
}

Тем не менее выбор одной из нескольких мажорных версий зависимости — не самое лучший вариант, так как одна из транзитивных зависимостей с высокой долей вероятности может сломаться, более безопасно было бы установить обе версии, чего Bower не позволяет. Решение этой проблемы было найдено в смежной области — бекенд-разработке на Node.js. Для этой платформы был разработан свой пакетный менеджер — NPM.

NPM

NPM имел "nested" модель установки, которая подразумевает, что для каждой зависимости проекта создаётся своя директория node_modules, в которой изолированно хранятся её зависимости — это позволяет избежать конфликтов версий.

"Nested" модель установки
"Nested" модель установки

Поскольку NPM изначально предназначается для Node.js, все пакеты в нём имели модульный формат CommonJS, который не поддерживается в браузере, соответственно использовать их для фронтенда было невозможно, однако с появлением Browserify (инструмента, собирающего все CommonJS модули в один файл), пост которого впоследствии занял Webpack, эта проблема была решена и разработчики постепенно начали переходить с Bower на NPM. Для более безболезненной миграции с Bower в NPM появился флаг --flat, который менял модель установки на плоскую.

Переход на "nested" модель установки был не бесплатным: директория node_modules представляла собой довольно глубокую иерархию пакетов, которая занимала колоссальное количество места на диске, а также могла приводить к проблемам из-за ограничения максимальной длины путей на Windows.

Классический мем про node_modules
Классический мем про node_modules

Для бекенда это было приемлемо (наверное), но тянуть на сайт так много библиотек, среди которых множество дубликатов, никому не хотелось, поэтому в NPM 3 появилась новая "hoisted" модель установки и механизм дедупликации пакетов.

"Hoisted" модель установки представляет собой нечто среднее между плоской и "nested" моделями. В ней пакеты по возможности хранятся в самой верхней директории node_modules, а вложенности возникают только в случае конфликтов версий.

"Hoisted" модель установки
"Hoisted" модель установки

Работа этой модели обеспечивается механизмом разрешения модулей в Node.js, суть которого заключается в том, что при поиске пакета, указанного в require, Node.js проходит по всем директориям node_modules снизу вверх, то есть "всплывает" (аналогично всплытию переменных в Javascript), поэтому модель и называется "hoisted".

Разрешение модулей в Node.js
Разрешение модулей в Node.js

Конфигурация NPM

Управлять тем, как NPM производит различные операции (такие как установка и публикация), можно с помощью флагов командной строки и с помощью файла .npmrc.

В отличие от многих других конфигурационных файлов (например, .gitignore или .prettierrc) .npmrc не ищется рекурсивно, в общем случае NPM ожидает его только в двух местах: непосредственно в директории проекта и в домашней директории текущего пользователя (~/ для Linux и Mac OS или %homepath% для Windows). Оба файла будут объединены, при этом значения параметров проекта будут иметь приоритет над пользовательскими.

Чаще всего в .npmrc указывается параметр registry, который отвечает за выбор реестра пакетов. По умолчанию его значение равно "https://registry.npmjs.com".

Стоит отметить, что можно указать отдельный registry для пакетов определённой организации. Предположим, компания, в которой вы работаете, публикует внутренние пакеты в приватном репозитории с префиксом @my-company/ (например, @my-company/awesome-library). В таком случае содержимое .npmrc будет выглядеть примерно так:

registry=https://registry.npmjs.com
@my-company:registry=https://nexus.my-company.com/npm

Авторизация в NPM

Чтобы публиковать пакеты или устанавливать из приватного репозитория, необходимо авторизоваться в NPM. Это можно сделать с помощью команды npm login, но я предпочитаю вручную указывать их в .npmrc, так как это более явный способ и при этом не сильно более сложный.

Авторизация с токеном <MY_TOKEN> для npmjs выглядит в .npmrc следующим образом:

//registry.npmjs.org/:_authToken=<MY_TOKEN>

Обратите внимание, что // в начале строки не обозначает комментарий — это обычная часть URL, которая следует после протокола, но в данном случае протокол не имеет значения, так как авторизация для http и https будет одинаковой.

Авторизационные данные для репозиториев лучше хранить в .npmrc, находящемся в домашней директории, в таком случае они будут использоваться для всех проектов на вашей машине и вы точно случайно не закоммитите их в GIT.

Публикация пакетов

Чтобы сделать свой NPM-пакет доступным для загрузки другими разработчиками, его необходимо опубликовать в реестре пакетов (registry). Глобальным реестром NPM-пакетов является https://registry.npmjs.com. Существуют и другие зеркала, например, https://registry.yarnpkg.com, но зачастую они просто проксируют npmjs, который на текущий момент по своей сути является главным источником истины для Javascript-пакетов.

Для публикации пакета существует команда npm publish — она упаковывает всё содержимое пакета в ".tgz"-архив (это можно сделать отдельно командой npm pack) и отправляет его в реестр пакетов.

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

Если в package.json определено поле files, NPM упакует в архив только указанные в нём файлы и директории.

Также можно указать исключения в файле .npmignore — это работает аналогично тому, как работает .gitignore.

Предположим, вы собираете свою библиотеку с помощью компилятора Typescript в директорию lib. В таком случае в поле files следует указать ["/lib"]. Далее можно, например, исключить из публикации файлы тестов, добавив в .npmignore строчку *.test.*.

Некоторые критичные для пакета файлы, например package.json и README.md будут опубликованы в любом случае, а некоторые файлы и директории, например, .git или node_modules никогда не попадут в публикуемый архив, но с последним есть нюанс.

Публикация зависимостей вместе с пакетом

Если какие-либо из зависимостей публикуемого пакета указаны в виде пути в файловой системе (например, file:../my-awesome-library, что не является хорошей практикой, но тем не менее имеет место быть), их можно опубликовать вместе с пакетом, указав их в поле bundledDependencies файла package.json. В таком случае директория node_modules всё же попадёт в публикуемый архив, но в ней останутся только пакеты, указанные в этом поле.

Когда пользователь установит пакет, у которого есть bundledDependencies, пакетный менеджер возьмёт такие зависимости из архива самого пакета вместо того, чтобы загружать их отдельно.

Основной сценарий использования bundledDependencies в настоящий момент — дать пользователям возможность загружать утилиты одним файлом, снизив тем самым время загрузки, так как пакетный менеджер вместо нескольких последовательных запросов на сервер делает всего один, — так делает, например, сам NPM.

Необязательные зависимости

В package.json существует поле optionalDependencies, работающее аналогично dependencies, но подразумевающее, что пакет в целом может работать и без них.

Его можно использовать, например, для каких-либо пакетов, которые нужны не всегда.

Например, установка cypress предполагает загрузку около 500 мегабайт, что может негативно сказаться на времени выполнения CI. Если, например, cypress не используется в некоторых окружениях, можно перенести его в секцию optionalDependencies и выполнять установку с флагом --omit=optional (--no-optional в более ранних версиях NPM).

Ключевое отличие optionalDependencies от dependencies заключается в том, что в случае невозможности установки указанных в этом поле пакетов NPM не завершит процесс с ошибкой, а продолжит установку остальных зависимостей в штатном режиме. Эта особенность используется авторами NPM-пакетов, содержащих бинарные файлы для разных операционных систем. Например, сборщик esbuild написан на языке Go. При установке его зависимостей пакетный менеджер обратит внимание на поля os (операционная система) и cpu (архитектура процессора) в их package.json и установит только те, что соответствуют текущей ОС.

"Плагины" для пакетов

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

Дубликат зависимости для плагина
Дубликат зависимости для плагина

Обратите внимание, что понятие "плагин" в данном случае довольно широкое и, например, библиотека React-компонентов будет фактически являться плагином для React (кстати, React в приложении должен присутствовать в единственном экземпляре, и если пакетный менеджер установит для библиотеки компонентов собственный React, то помимо засорения node_modules приложение может перестать работать).

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

Реализацией вышеописанного механизма являются peerDependencies.

При разработке плагина стоит указать его хост-пакет в поле peerDependencies в package.json, чтобы подсказать пакетному менеджеру, как поступать в такой ситуации.

{
  "peerDependencies": {
    "react": ">= 16"
  }
}

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

NPM 7 и выше автоматически установит недостающие peerDependencies.

В peerDependencies стоит указывать как можно более широкий диапазон версий, чтобы дать пользователю библиотеки возможность выбора. Так как если, например, "react": "^17.0.0", а её пользователь использует "react": "18.0.0", то возникнет конфликт версий зависимостей, что приведёт к ошибке установки при использовании NPM 7 и выше.

Конфликт peerDependencies
Конфликт peerDependencies

Пользователю эта ошибка может быть непонятна и он весьма вероятно попытается установить зависимости с флагом --force или --legacy-peer-deps, как подсказывает сам текст ошибки, что заставит NPM работать "по старинке" (как до NPM 7), но это может привести к проблемам с дубликатами.

Переопределение версий

Решить такие проблемы можно по старинке — вручную. Для этого в package.json появилось поле overrides, которое работает подобно полю resolutions из Bower, но поддерживает каскад, как в CSS.

{
  "dependencies": {
    "react": "18.2.0"
  },
  "devDependencies": {
    "@storybook/react": "6.3.13"
  },
  "overrides": {
    "@storybook/react": {
      "react": "18.2.0"
    }
  }
}

Кстати, это не единственная для NPM аналогия с СSS, команда npm query поддерживает СSS-селекторы для анализа дерева зависимостей.

Похожее поле есть и в других пакетных менеджерах, но, поскольку для package.json нет никакой общей спецификации, работает и называется оно по разному. Например, в Yarn для решения этой проблемы есть поле resolutions.

Опциональный хост

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

Для решения этой задачи в package.json существует поле peerDependenciesMeta — оно позволяет предоставить пакетному менеджеру дополнительный контекст для установки зависимостей.

На текущий момент в peerDependenciesMeta доступен только параметр optional, который говорит о том, что наличие пакета необязательно.

{
  "peerDependencies": {
    "react": ">= 16"
  },
  "peerDependenciesMeta": {
    "react": {
      "optional": true
    }
  }
}

То есть peerDependenciesMeta.optional является аналогом optionalDependencies, но для peerDependencies.

Воспроизводимость

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

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

Решил эту проблему альтернативный пакетный менеджер — Yarn. По завершению установки он генерирует так называемый локфайл (yarn.lock), в котором сохраняется результат процесса разрешения зависимостей, а именно конкретные версии пакетов, которые подобрал пакетный менеджер. Если такой файл есть в проекте, при запуске установки пакетный менеджер проверит, что package.json и yarn.lock соответствуют друг другу и, полностью пропустив этап разрешения зависимостей, фактически просто загрузит пакеты по списку. Такой подход ускоряет установку, поскольку сетевых запросов в результате совершается меньше, и, что самое главное, делает её предсказуемой — теперь две последующие установки точно дадут одинаковый результат, даже на другой машине.

Установка при наличии yarn.lock
Установка при наличии yarn.lock

Yarn подтолкнул NPM к развитию и впоследствии он тоже научился генерировать свои npm-shrinkwrap.json и package-lock.json файлы для реализации подобного механизма.

npm ci

Чтобы добиться действительно предсказуемой установки в автоматизированных средах, важно использовать команду npm ci вместо 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 сохраняется также их контрольная сумма (Subresource Integrity) в поле integrity каждого пакета. Она позволяет при установке из локфайла убедиться, что его никто не подменил и устанавливается ровно то же самое, что и при генерации локфайла.

Позже эту информацию стал сохранять и NPM.

Скорость

Основная причина быстроты Yarn — кеш. Он позволяет создать на своей машине собственный реестр пакетов, чтобы в процессе установки заменять сетевой запрос на копирование папок в файловой системе. Меньше сетевых запросов — меньше времени занимает установка.

При этом кеш может работать не только для отдельно взятого проекта. Его можно переиспользовать между всеми проектами, которые вы разрабатываете на своей машине.

Кеш пакетного менеджера
Кеш пакетного менеджера

Собственный реестр пакетов

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

Также собственный репозиторий может использоваться в качестве удалённого кеша, чтобы ускорять установку зависимостей за счёт того, что такой кеш будет находиться ближе к разработчикам. Для этого можно воспользоваться более легковесным и опенсорсным аналогом Nexus — Verdaccio. Его можно, например, запустить в Docker на своей машине, что позволит организовать кеш, переиспользуемый между всеми проектами и доступный для любого пакетного менеджера, либо установить на сервер, который находится недалеко от вас, чтобы не расходовать ресурсы своей машины. Для этого необходимо будет указать в .npmrc адрес сервера с Verdaccio.

Установка через Verdaccio
Установка через 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:

    {
      "workspaces": ["my-app", "my-library"]
    }

Фантомные зависимости

Как мы выяснили ранее, механизм всплытия пакетов в 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 называется фантомной зависимостью.

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

Структура зависимостей

Многие называют структуру зависимостей деревом, что не совсем верно с концептуальной точки зрения. Зависимости представляют собой ориентированный ациклический граф, а дерево является частным случаем этой структуры данных.

Самое важное отличие графа от дерева заключается в возможности возникновения ромбовидных зависимостей.

Ромбовидные зависимости
Ромбовидные зависимости

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

На основе этой идеи был разработан новый пакетный менеджер — PNPM.

PNPM

PNPM в отличие от NPM и Yarn не пытается сделать структуру node_modules как можно более плоской, вместо этого он скорее нормализует граф зависимостей. После установки PNPM создаёт в node_modules директорию .pnpm, которая концептуально представляет собой хранилище ключ-значение, в котором ключом является название пакета и его версия, а значением — содержимое этой версии пакета. Такая структура данных исключает возможность возникновения дубликатов. Структура самой директории node_modules будет подобна "nested"-модели из NPM, но вместо физических файлов ней будут находиться симлинки, которые ведут в то самое хранилище пакетов.

Структура node_modules с PNPM
Структура node_modules с PNPM

В node_modules каждого пакета будут находиться только симлинки на те пакеты, которые указаны у него в package.json, что полностью избавляет нас от проблемы фантомных зависимостей и потребность в наличии ESLint-плагина отпадает.

В версии NPM 9 появился флаг install-strategy, значение "linked" в нём включает подобную PNPM модель установки с симликами, но на текущий момент это экспериментальная фича.

Глобальное хранилище пакетов

PNPM может создать директорию .pnpm не только в node_modules проекта, но и глобально. В таком случае node_modules у проектов будут содержать только симлинки, за счёт чего ускоряется установка зависимостей (создание симлинка занимает меньше времени, чем копирование файлов) и экономится колоссальное количество дискового пространства.

Переопределение зависимостей

Для переопределения зависимостей PNPM тоже имеет свою версию поля overrides, но помимо этого он предлагает механизм хуков, которые позволяют вмешаться в процесс разрешения зависимостей. В .pnpmfile.cjs можно написать Javascript-код, который будет изменять package.json всех пакетов в дереве зависимостей на этапе разрешения. Это позволяет максимально точно исправлять ошибки, возникающие с транзитивными зависимостями.

Простота использования

PNPM имеет API, очень похожий на Yarn, что позволяет не привыкать к новым командам в третий раз.

По всем вышеописанным причинам я предпочитаю использовать PNPM во всех своих проектах.

Будущее менеджмента зависимостей

По моим наблюдениям инструменты, управляющие зависимостями в Javascript, постепенно идут к полному избавлению от директории node_modules в проекте и, возможно, к разрешению зависимостей прямо в рантайме благодаря ES-модулям, которые уже поддерживаются всеми современными браузерами, а также в Deno — альтернативе Node.js, в которой в принципе нет как такового пакетного менеджера. Также довольно большую популярность обрела концепция Module Federation, представленная в Webpack 5, фактически позволяющая выполнять часть работы пакетного менеджера прямо в браузере пользователя в рантайме за счёт старого доброго script, но это тоже выглядит как промежуточный шаг к полному переходу на ES-модули.

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

За будущими статьями можете следить в моём телеграм-канале.

Комментарии (0)