Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
На данный момент у нас используются три самых популярных менеджера пакетов (Npm, Yarn и Pnpm). И всё бы ничего, но разные команды начали периодически обращаться с проблемой несоответствия типов Typescript из наших транзитивных зависимостей. Выяснилось что это проблема Npm и Yarn, но как же её решать?
выглядит это примерно так, только при реэкспорте enum из library-f@1.0.0
по факту получаем enum из library-f@2.0.0
- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0
- library-c/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0
- library-d/
- package.json
- library-e/
- package.json
- library-f/
- package.json <-- library-f@2.0.0
На ум сразу приходит самое очевидное решение: следить за версиями всех зависимостей в своих проектах и вовремя обновлять. К этому, естественно, необходимо стремиться всегда, но мы понимаем, на практике что это крайне сложно, а в legacy-проектах или в проектах, у которых нет постоянной поддержки и вовсе нереально. Следующим вариантом созрел Pnpm, тем более что в наших монорепах он себя уже продолжительное время отлично показывал. Я решил испытать его на действующих клиентских приложениях.
Предыстория
Когда я пришёл в компанию в конце 2018, то увидел что используется преимущественно Yarn. Тогда он применялся повсеместно, потому что работал гораздо быстрее Npm и имел интересные плюшки из коробки. Позже команды постепенно начали возвращаться на Npm, когда он подтянулся по скорости и от его использования перестали страдать.
Параллельно с этим у нас начали появляться монорепозитории на Rush для UI-kit'а, виджетов с обёртками и инструментов, на которые переходили с комбинации Lerna и Yarn. У нас была статья на тему ускорения сборки с помощью кеширования. Почему выбор пал на Rush, возможно, расскажу в одной из будущих статей. Сам инструмент позволяет использовать любой из трёх поддерживаемых менеджеров (Npm, Yarn и Pnpm), выбор за нами, но всё же его авторы в своих статьях намекают на преимущества Pnpm. Его мы и выбрали в монорепозиториях.
Исследование
Оговорюсь, что Yarn как целевое решение внутри компании уже не рассматривается, поэтому я сравнивал Pnpm исключительно с Npm. Помимо скорости, Yarn больше преимуществ для нас не имеет.
Детерминизм
Первым пунктом сразу обозначим разницу в установке зависимостей. Детерминизм означает, что при одной и той же операции должен возвращаться один и тот же результат.
У Npm структура модулей плоская: первая найденная версия каждой зависимости выносится на верхний уровень (это называется hoisting), благодаря чему в проекте можно использовать поднятые зависимости без их указания в package.json. Это так называемые фантомные зависимости, остальные версии остаются на своих местах. Какой здесь может быть недостаток? Представим ситуацию что первый разработчик установил зависимость B, которая зависит от C@^1.0.0. Зависимость C поднялась на верхний уровень с версией 1.0.1. Затем разработчик добавил зависимость A, которая зависит от C@1.0.0. У него всё будет хорошо, но если другой разработчик (или CI) установит зависимости из файла package-lock.json, то у него наверх поднимется зависимость C версии 1.0.0 из зависимости A, которая удовлетворяет B. И тогда зависимость B не получит патч-версию, которая могла быть для неё критична. Поэтому мы всегда должны помнить, что порядок установки зависимостей может влиять на конечные версии, и он может отличаться от того, что будет в CI.
А теперь разберём нашу ситуацию с типами, описанную в начале статьи. Возьмём наш пример с зависимостями и к пакету C добавим экспорт типов и enum'ов. В хост-проекте делаем import { ComponentB, MyEnum } from 'B';
. Пока всё хорошо. Затем обновляем пакет B, в котором было мажорное обновление зависимости C, версия 2.0.0 и breaking change enum'а. В TS получаем ошибку несоответствия параметра, а в проекте без поддержки TS — ошибку в проде, потому что ComponentB ожидает enum из версии C@2.0.0, а получает из C@1.0.0. Можно нехитрыми манипуляциями добиться поднятия на верхний уровень node_modules
более свежей версии (например, самостоятельно установить в корне эту зависимость нужной нам версии, у таких зависимостей приоритет). Но тогда может случиться такая проблема в зависимости A. Возможно, есть решения этой проблемы, если кто знает, как правильно разрешать типы и enum'ы в таких случаях, напишите в комментариях.
А что же Pnpm? Он в этом случае ведёт себя как npm@2: ничего не поднимает на верхний уровень, просто устанавливает по semver каждую версию зависимости, но не копирует, как это делал npm@2, а создаёт hardlink'и, поэтому node_modules
не раздувается. Благодаря этому значительно быстрее устанавливаются зависимости, если, конечно, они у вас уже были, например при установке в других проектах.
Зависимости и дедупликация
Я предполагал, что с Pnpm зависимостей станет больше, или как минимум столько же. Оказалось, что практически во всех случаях их меньше, иногда столько же. Покажу на наглядном примере, для анализа я взял инструмент Statoscope.
Package.json был взят один и тот же, из одного из наших реальных проектов. Основной показатель, за который цепляется глаз — это Package copies: их стало более чем вдвое меньше. Это повлияло на общий вес проекта, даже на количество чанков. Я собрал проект и запустил в тестовом контуре, результат соответствует. Мало того, что мы стали увереннее в наших зависимостях, так бонусом получили ещё и небольшую оптимизацию по весу — мелочь, а приятно.
Почему у Npm с этим проблемы? Авторы Rush хорошо описали проблему дедупликации. Если вкратце, то Npm плохо дедуплицирует транзитивные зависимости, которые не поднимаются на верхний уровень. Отчасти это может быть связано с тем, что он не может дедуплицировать зависимости из разных веток графа.
Скорость
Не буду подробно сравнивать все сценарии, можно опираться на Pnpm. По моему субъективному ощущению, Pnpm в среднем быстрее в 1,5-3 раза. Для эксперимента возьму самый частый случай: сборку из lock-файла без node_modules
и кеша. Каждый менеджер гонял по несколько раз, предварительно сбрасывая кеш, запускал командами npm ci
и pnpm i --frozen-lockfile
. Вот что получилось:
npm — 42–56 сек.
pnpm — 38–45 сек.
Разница не драматическая, к тому же Pnpm не поставляется из коробки, его нужно дополнительно ставить или встроить в базовый образ, для CI имеется ввиду. С кешем ситуация более ощутимая, нормально экономит время в повседневной разработке.
Результаты
По окончании исследования надо принести результат команде и подготовить ответы на два главных вопроса:
Как мигрировать? С Npm на Pnpm переезжать максимально легко, Pnpm делает упор на то, что это тот же Npm, только быстрый. В большинстве случаев достаточно к Npm добавлять буковку p. Есть отличия в командах чистой установке (CI), очистке кеша, просмотре листа зависимостей и таких же мелочах, в остальном всё то же самое. К тому же Pnpm поддерживает команды из Yarn вроде
add
иremove
, и можно выполнять скрипты без добавления словаrun
. Для нас это тоже хорошо, так как есть и Npm, и Yarn. От себя добавлю. что можно столкнуться с проблемами при переезде, но по моему опыту все они из‑за уже имеющихся проблем с зависимостями, которые просто не успели выстрелить, поэтому переезд на Pnpm поможет от них избавиться.Что нам даст переезд на Pnpm? Я для себя выделил несколько самых важных преимуществ:
Консистентность. Один инструмент на все типы фронтенд-проектов в компании, от клиентских приложений до монореп. Это поможет улучшить DX, убрать зоопарк пакетных менеджеров, легче поддерживать CI и описывать документацию.
Надёжность. Улучшение ситуации с зависимостями. Избавимся от магии с разрешением, сборки станут немного стабильнее, пропадут дубли, незначительно уменьшится вес вендоров, не будут приходить в поддержку с проблемами несоответствия зависимостей.
Кеширование. Благодаря ссылкам и единому хранилищу мы можем без больших усилий ускорить сборку в CI-конвейерах. Авторы Pnpm в статье привели примеры из популярных CI-инструментов. Если ставить зависимости через
pnpm install --prod
, то можно сэкономить место в хранилище благодаря тому, что не будут устанавливаться dev-зависимости, нужные только на этапе разработки.Скорость.
Фишки. Выделю strict‑peer‑dependencies, resolution‑mode, node‑linker и даже управление версиями Node.js. Pnpm интересный инструмент, он и про качество, и про возможности
Рекомендации
Дам свои рекомендации по работе с Pnpm:
В
package.json
. Помогает в случаях, когда случайно вызвал Npm, особенно для новеньких."scripts": { "preinstall": "npx only-allow pnpm", ... }
В
package.json
. Лучше всегда указывать актуальные версии Node.js и менеджера пакетов. Эта информация может сэкономить время тем, кто разворачивает у себя ваш проект.{ "engines": { "node": "20", "pnpm": "8" } }
.npmrc
:engine-strict=true
. При несоответствии версий установка будет завершаться с ошибкойВ
.npmrc
:strict-peer-dependencies=true
. Если будет несоответствие версий сpeerDependencies
, установка будет завершаться с ошибкой. Это гигиена, помогает не вляпываться в потенциальные проблемы. Rush тоже рекомендует включать эту настройку.В
.npmrc
:resolution-mode=time-based
. Интересная опция, по умолчанию стоитhighest
. Pnpm рекомендует указывать значениеtime-based
, я попробовал, но мне не понравилось. В случае packageA -> packageB сработало ограничение на packageB, но вот packageA -> packageB -> packageC — на packageC не сработало, и я получил две версии зависимости. Рассчитывал, что оно дедуплицируется в мою версию прямой зависимости, но нет. Возможно, просто баг.pnpm import
— полезная команда для миграции для тех, кому очень важно не пересобирать lock-файл. Но если есть возможность, я рекомендую при миграции с других пакетных менеджеров удалить предыдущие lock-файлы и сгенерировать pnpm-lock заново.
Итоги
По моему мнению, Pnpm перекрывает все (по крайней мере, в нашей компании) возможные запросы. Можно организовать монорепы, он надёжней, в большинстве случаев быстрее. Он активно развивается и берёт из других инструментов то, что считает лучшим, например, тот же plug'n'play. Его смело можно брать за стандарт в компании, он стабильно набирает популярность последнее время.
Если у вас Npm или Yarn, то после прочтения этой статьи вы не начнёте внезапно страдать, и переход на Pnpm не будет означать, что можно перестать следить за зависимостями. Но в больших компаниях это немного упростит жизнь.
Вероятно, в будущем мы придём к ES-модулям и runtime-зависимостям, и нам не нужны будут пакетные менеджеры. Но я думаю, что не в ближайшие пару тройку лет. Что вы думаете по этому поводу, пишите в комментариях.