Написать пост

Менеджмент зависимостей в Javascript — управляем хаосом

Логотип компании Газпромбанк

В статье подробно поговорим про принципы управления зависимостями в Javascript и обсудим существующие пакетные менеджеры.

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

Менеджмент зависимостей в Javascript — управляем хаосом 1

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

  1. Как мы делали раньше.
  2. Bower.
  3. Версионирование.
  4. Транзитивные зависимости.
  5. Разрешение зависимостей.
  6. Зависимости для локальной разработки.
  7. Плоская модель установки.
  8. Ручное разрешение конфликтов.
  9. NPM.
  10. Конфигурация NPM.
  11. Авторизация в NPM.
  12. Публикация пакетов.
  13. Необязательные зависимости.
  14. «Плагины» для пакетов.
  15. Переопределение версий.
  16. Воспроизводимость.
  17. Yarn.
  18. Собственный реестр пакетов.
  19. Связывание пакетов локально.
  20. Фантомные зависимости.
  21. Структура зависимостей.
  22. PNPM.
  23. Будущее менеджмента зависимостей.

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

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

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

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

  1. Воспользоваться CDN, на котором уже размещён код библиотеки: <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>.
    В этом случае у нас не было контроля над тем, что на самом деле получал пользователь, мы делегируем всю работу провайдеру CDN и доверяем ему.
    В качестве бонуса пользователи получали кросс-доменный кеш и если, например, они уже загрузили jQuery на другом сайте, при открытии нашего сайта они получали её из кеша вместо того, чтобы загружать его с CDN заново, так как URL совпадал. К сожалению, этот механизм более не актуален.
  2. Скачать код библиотеки и самостоятельно положить его, например, в директорию 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).

Первый разряд версии означает изменения API, ломающие обратную совместимость (мажорные), второй — обратно совместимые изменения (минорные). Соответственно, минимальная версия, которая нам подойдёт для использования всего API из документации, — 16.1.0, максимальная, которую мы можем использовать, не опасаясь что проект перестанет работать, — 17.0.0.

Записать такой диапазон можно в виде >=16.1.0 <17.0.0. Для более краткой записи существуют модификаторы диапазона версий, с помощью которых мы можем обозначить тот же самый диапазон как ^16.1.0.

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

Транзитивные зависимости

Bower позволил формализовать и автоматизировать управление зависимостями во фронтенд-разработке. Это подтолкнуло экосистему JavaScript к закономерному росту и, соответственно, усложнению.

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

Зависимости зависимостей проекта называются транзитивными.

Менеджмент зависимостей в Javascript — управляем хаосом 2

Разрешение зависимостей

Пакетный менеджер начинает установку с разрешения (resolution) зависимостей. На этом этапе он анализирует зависимости в поле dependencies и подбирает версии библиотек, соответствующие указанным в нём диапазонам.

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

Зависимости для локальной разработки

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

Но когда мы добавляем библиотеку в свой проект, мы не хотим вместе с исходным кодом загрузить ещё и тонну инструментов (которые, несомненно, полезны самой библиотеке, но нам могут быть не нужн). Поэтому для экономии дискового пространства пользователей библиотек в bower.json появилось поле devDependencies.

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

Менеджмент зависимостей в Javascript — управляем хаосом 3

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

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

Менеджмент зависимостей в Javascript — управляем хаосом 4

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

Менеджмент зависимостей в Javascript — управляем хаосом 5

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

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

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

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

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

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

NPM

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

Менеджмент зависимостей в Javascript — управляем хаосом 6

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

Для безболезненной миграции с Bower в NPM появился флаг --flat, который меняет модель установки на плоскую.

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

Менеджмент зависимостей в Javascript — управляем хаосом 7

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

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

Менеджмент зависимостей в Javascript — управляем хаосом 8

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

Менеджмент зависимостей в Javascript — управляем хаосом 9

Конфигурация 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-пакетами принцип тот же: плагин должен использовать уже установленную в проекте версию хост-пакета.

Менеджмент зависимостей в Javascript — управляем хаосом 10

Обратите внимание, что понятие «плагин» в данном случае довольно широкое и, например, библиотека 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 и выше.

Менеджмент зависимостей в Javascript — управляем хаосом 11

Пользователю эта ошибка может быть непонятна и он весьма вероятно попытается установить зависимости с флагом --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 соответствуют друг другу и, полностью пропустив этап разрешения зависимостей, загрузит пакеты по списку.

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

Менеджмент зависимостей в Javascript — управляем хаосом 12

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

npm ci

Чтобы добиться действительно предсказуемой установки в автоматизированных средах, важно использовать команду npm ci вместо npm install.

Менеджмент зависимостей в Javascript — управляем хаосом 13

Команда npm ci расшифровывается как clean install, поскольку при её выполнении NPM полностью удаляет директорию node_modules и загружает все зависимости с чистого листа, что также улучшает воспроизводимость.

Yarn

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

Давайте рассмотрим подробнее, в чём именно заключаются эти преимущества.

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

Функциональность NPM расширялась постепенно, новые фичи появлялись и его API разрастался, а кардинально менять его и заставлять разработчиков привыкать к новым командам при переходе на новую версию не хотелось. Создавать удобный DX в таких условиях довольно проблематично. Yarn же создавался с нуля, учитывая опыт использования NPM, поэтому его CLI получился несколько более интуитивным и простым в использовании.

Часто используемые команды стали короче, а команды для CI — читабельнее:

  • npm install —> yarn install/yarn;
  • npm install --save react —> yarn add react;
  • npm ci —> yarn install --frozen lockfile;

Безопасность

Помимо фиксированных версий зависимостей, в yarn.lock сохраняется также их контрольная сумма (Subresource Integrity) в поле integrity каждого пакета. Она позволяет при установке из локфайла убедиться, что его никто не подменил и инсталлируется то же, что и при генерации локфайла.

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

Скорость

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

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

Менеджмент зависимостей в Javascript — управляем хаосом 14

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

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

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

Для этого необходимо будет указать в .npmrc адрес сервера с Verdaccio.

Менеджмент зависимостей в Javascript — управляем хаосом 15

С 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, мы сможем импортировать её в проект.

Менеджмент зависимостей в Javascript — управляем хаосом 16

Может случиться так, что в следующей патч-версии library-a больше не будет зависеть от library-b. Это вполне валидная ситуация, поскольку внешний API библиотеки не изменился.

В таком случае library-b не установится, и мы больше не сможем использовать её в своём проекте. Но весьма вероятно мы узнаем это только перед продакшн сборкой в CI, поскольку производим чистую установку с npm ci там.

Менеджмент зависимостей в Javascript — управляем хаосом 17

Использование транзитивной зависимости без явного указания её в package.json называется фантомной зависимостью.

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

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

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

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

Менеджмент зависимостей в Javascript — управляем хаосом 18

Файловая система же представляет собой именно дерево и не может иметь ромбовидных зависимостей. Поэтому пакетному менеджеру приходится делать некоторые преобразования, чтобы записать пакеты на диск в node_modules.

Nested-модель установки наиболее близка к исходной структуре данных, но фактически она предлагает дублировать узлы графа, в которых возникли ромбовидные зависимости, что приводит к огромному количеству дубликатов.

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

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

PNPM

PNPM, в отличие от NPM и Yarn, не пытается сделать структуру node_modules как можно более плоской. Вместо этого он скорее нормализует граф зависимостей.

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

Структура самой директории node_modules будет подобна nested-модели из NPM, но вместо физических файлов ней будут находиться симлинки, которые ведут в то самое хранилище пакетов.

Менеджмент зависимостей в Javascript — управляем хаосом 19

В 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 во всех своих проектах.

Разработчики Yarn решили пойти по более революционному пути для решения​​ проблемы фантомных зависимостей, добавив режим Plug’n’Play. В этом режиме Yarn заменяет собой механизм разрешения модулей из Node.js и вместо директории node_modules создаёт файл .pnp.js, в котором сохраняет всю необходимую ему информацию для разрешения зависимостей. Не все пакеты в NPM совместимы с этим режимом, поэтому его внедрение может вызвать некоторые трудности. Но весьма вероятно, что для менеджмента зависимостей в JavaScript это большой шаг в будущее.

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

По моим наблюдениям, управляющие зависимостями в JavaScript инструменты постепенно идут к полному избавлению от директории node_modules в проекте. И, возможно, к разрешению зависимостей прямо в рантайме благодаря ES-модулям, которые уже поддерживаются всеми современными браузерами, а также в Deno — альтернативе Node.js, в которой в принципе нет пакетного менеджера как такового.

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

Больше про JavaScript и всё, что с ним связано, можете почитать в канале. И пишите в комментариях, что бы ещё хотели узнать по этой теме!

Следите за новыми постами
Следите за новыми постами по любимым темам
9К открытий9К показов