На данный момент у нас используются три самых популярных менеджера пакетов (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.

npm
npm
Pnpm
Pnpm

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 имеется ввиду. С кешем ситуация более ощутимая, нормально экономит время в повседневной разработке.

Результаты

Я рассказываю команде о Pnpm.
Я рассказываю команде о Pnpm.

По окончании исследования надо принести результат команде и подготовить ответы на два главных вопроса:

  1. Как мигрировать? С Npm на Pnpm переезжать максимально легко, Pnpm делает упор на то, что это тот же Npm, только быстрый. В большинстве случаев достаточно к Npm добавлять буковку p. Есть отличия в командах чистой установке (CI), очистке кеша, просмотре листа зависимостей и таких же мелочах, в остальном всё то же самое. К тому же Pnpm поддерживает команды из Yarn вроде add и remove, и можно выполнять скрипты без добавления слова run. Для нас это тоже хорошо, так как есть и Npm, и Yarn. От себя добавлю. что можно столкнуться с проблемами при переезде, но по моему опыту все они из‑за уже имеющихся проблем с зависимостями, которые просто не успели выстрелить, поэтому переезд на Pnpm поможет от них избавиться.

  2. Что нам даст переезд на Pnpm? Я для себя выделил несколько самых важных преимуществ:

    1. Консистентность. Один инструмент на все типы фронтенд-проектов в компании, от клиентских приложений до монореп. Это поможет улучшить DX, убрать зоопарк пакетных менеджеров, легче поддерживать CI и описывать документацию.

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

    3. Кеширование. Благодаря ссылкам и единому хранилищу мы можем без больших усилий ускорить сборку в CI-конвейерах. Авторы Pnpm в статье привели примеры из популярных CI-инструментов. Если ставить зависимости через pnpm install --prod, то можно сэкономить место в хранилище благодаря тому, что не будут устанавливаться dev-зависимости, нужные только на этапе разработки.

    4. Скорость.

    5. Фишки. Выделю strict‑peer‑dependencies, resolution‑mode, node‑linker и даже управление версиями Node.js. Pnpm интересный инструмент, он и про качество, и про возможности

Рекомендации

Дам свои рекомендации по работе с Pnpm:

  1. В package.json. Помогает в случаях, когда случайно вызвал Npm, особенно для новеньких.

    "scripts": {
      "preinstall": "npx only-allow pnpm", 
      ...
    }
  1. В package.json. Лучше всегда указывать актуальные версии Node.js и менеджера пакетов. Эта информация может сэкономить время тем, кто разворачивает у себя ваш проект.

    {
        "engines": {
            "node": "20",
            "pnpm": "8"
        }
    }

    .npmrc: engine-strict=true. При несоответствии версий установка будет завершаться с ошибкой

  2. В .npmrc : strict-peer-dependencies=true. Если будет несоответствие версий с peerDependencies, установка будет завершаться с ошибкой. Это гигиена, помогает не вляпываться в потенциальные проблемы. Rush тоже рекомендует включать эту настройку.

  3. В .npmrc: resolution-mode=time-based. Интересная опция, по умолчанию стоит highest. Pnpm рекомендует указывать значение time-based, я попробовал, но мне не понравилось. В случае packageA -> packageB сработало ограничение на packageB, но вот packageA -> packageB -> packageC — на packageC не сработало, и я получил две версии зависимости. Рассчитывал, что оно дедуплицируется в мою версию прямой зависимости, но нет. Возможно, просто баг.

  4. pnpm import — полезная команда для миграции для тех, кому очень важно не пересобирать lock-файл. Но если есть возможность, я рекомендую при миграции с других пакетных менеджеров удалить предыдущие lock-файлы и сгенерировать pnpm-lock заново.

Итоги

По моему мнению, Pnpm перекрывает все (по крайней мере, в нашей компании) возможные запросы. Можно организовать монорепы, он надёжней, в большинстве случаев быстрее. Он активно развивается и берёт из других инструментов то, что считает лучшим, например, тот же plug'n'play. Его смело можно брать за стандарт в компании, он стабильно набирает популярность последнее время.

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

Вероятно, в будущем мы придём к ES-модулям и runtime-зависимостям, и нам не нужны будут пакетные менеджеры. Но я думаю, что не в ближайшие пару тройку лет. Что вы думаете по этому поводу, пишите в комментариях.

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


  1. gev
    21.12.2023 08:36

    Мой вариант cabal =)


    1. mauphes Автор
      21.12.2023 08:36

      сильно, крутой выбор, мы до такого в компании пока не доросли :)


  1. Moonlization
    21.12.2023 08:36

    Я пользуюсь Bun в последнее время, преимущественно из-за скорости и совместимости с npm.


    1. mauphes Автор
      21.12.2023 08:36

      В продакшн? Как Bun себя показывает, помимо скорости, хватает ли текущих возможностей? Есть же гипотеза, что когда Bun допилит всё то что умеют другие сборщики/менеджеры/Node.js и тп, то по скорости он уже не так сильно выигрывать будет


      1. Dimava2
        21.12.2023 08:36

        Будет
        Банально потому что он не написан на JS

        Npm, yarn, pnpm все написаны на JS, так что написанный на Zig (условно, современном эквиваленте C) Bun, очевидно, будет намного быстрее

        Bun транспилирует Typescript немного быстрее esbuild (написанно на go), поскольку лучше оптимизирован

        Bun немного медленнее NodeJS, поскольку JSCode немного медленнее V8, но на простых примерах когда сравнивается скорость рантайма а не JS-движка (http-запросы, в которых JS не занимает 80% времени) Bun может быть в несколько раз быстрее


        1. mauphes Автор
          21.12.2023 08:36

          Скорость это один из критериев выбора инструмента, и мне было бы очень интересно увидеть чей-нибудь реальный опыт переезда с Node.js на Bun в SSR проекте и посмотреть на разницу в rps, если и тут будет ощутимый прирост, то это весомый аргумент посмотреть в его сторону. Но нельзя забывать про другие критерии вроде безопасности и возможностей


        1. gmtd
          21.12.2023 08:36

          Как уже говорилось, на реальных приложениях самые медленные процессы будут задавать производительность всей системы. Работы с сетью, БД, файловой системой - это будет давать 95% времени выполнения скрипта микросервиса или бэкенда, и разница между Node.js и Bun будет в пределах 5%-10% в итоге. Сложные вычислительные вещи на JS вряд ли кто-то будет писать.


    1. shasoftX
      21.12.2023 08:36

      Так вроде bun это аналог node?


      1. mauphes Автор
        21.12.2023 08:36

        Bun завернул в себя и пакетный менеджер, бандлер, транспайлер и утилиты для тестирования, я правда не знаю, можно ли что-то своё использовать, но по-моему нельзя


  1. Elendiar1
    21.12.2023 08:36

    Yarn же так же имеет возможность pnp.


    1. mauphes Автор
      21.12.2023 08:36

      Да, в ссылке я ссылаюсь на yarn, это их разработка, посыл был в том что pnpm объективен и если видит что-то хорошее, то старается поддержать, а где-то взять идею и даже улучшить уже по их субъективному мнению


  1. gmtd
    21.12.2023 08:36

    Недавно много раз тестировал установку проекта. Выяснилось, что Window 10 удаляет директорию node_modules медленней, чем потом pnpm её ставит. Из кэша, конечно, но тем не менее впечатляет.

    pnpm давно уже лучший однозначно.


  1. Zarom
    21.12.2023 08:36

    Монорепы - зло


  1. rock
    21.12.2023 08:36

    1. mauphes Автор
      21.12.2023 08:36

      Такой риск присутствует во многих инструментах, в идеале обезопасить себя внутренним хранилищем Nexus. То что Pnpm открыл доступ к документации это обнадёживает, ну и хочется верить что этот тренд прошлого года прошел и программирование затрагиваться больше не будет. Тем более если действительно автор что-то вредоносное сделает с исходниками, то это прецендент и он быстро потеряет популярность и аудиторию не только России.


      1. rock
        21.12.2023 08:36

        Тем более если действительно автор что-то вредоносное сделает с исходниками, то это прецендент и он быстро потеряет популярность и аудиторию не только России.

        Ой, сомнительно. Например (а это один пример из многих), после февраля 22го пакет event-source-polyfill в российских часовых поясах в части окружений показывал алерт о злых русских, блокировав страницу, а в части окружений её просто крашил. Особенно весело, когда подобное приходит как зависимость зависимости. ЕМНИП, NPM отказался это дело удалять, сочтя приемлемым protestware. Через полгодика, вроде, после срача на MDN, изменения откатили назад. И что, хоть как-то сказалось на популярности? Мало того, слишком многие западные коллеги писали, что так и надо. А некоторые, в десятки раз более популярные пакеты (es5-ext, например), до сих пор не откатились.

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


        1. mauphes Автор
          21.12.2023 08:36

          Интересно что event-source-polyfill от российского разработчика. Мой посыл в том что вредоносное ПО было всегда и везде, сейчас просто некоторые люди просто себе нашли дополнительный повод, и ранее и в дальнейшем надо защищаться и проверять абсолютно весь код устанавливаемый из open source.


          1. rock
            21.12.2023 08:36

            Но всё же согласитесь, что позиция разработчиков pnpm серьезно повышает риски. Как по мне - так неприемлемо высоко для серьезного бизнеса.


            1. gmtd
              21.12.2023 08:36

              Переход с pnpm на yarn в случае чего - это какие-то серьезные переделки?


              1. rock
                21.12.2023 08:36

                Когда используешь что-то большее, чем единый для npm / pnpm / yarn функционал, когда это достаточно крупный проект - да, это достаточно серьезные переделки.


  1. Genrehopper
    21.12.2023 08:36

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


  1. Nichon4
    21.12.2023 08:36

    npm overrides для решения вашей проблемы с версиями не помог?


    1. mauphes Автор
      21.12.2023 08:36

      хотелось как-то типы готовить так что бы правильно резолвилось, версия к версии, без оверрайда, а то придётся всем проектам гайд писать :)