На прошлой неделе разработчики Yarn (пакетного менеджера для Javascript) анонсировали новую фичу – Plug'n'Play установку. Эта возможность позволяет запускать Node.js проекты без использования папки node_modules, в которую обычно устанавливаются зависимости проекта перед запуском. Описание фичи декларирует, что node_modules больше не понадобится – модули будут загружаться из общего кеша пакетного менеджера.
Одновременно с ними разработчики NPM также анонсировали свое аналогичное решение проблемы.
Давайте посмотрим на эти решения повнимательнее и попробуем протестировать их в реальных проектах.
История проблемы
Изначально модульная система NodeJS была полностью основана на файловой системе. Любой вызов require()
маппится на файловую систему. Для организации third-party модулей была придумана папка node_modules, в которую должны скачиваться и устанавливаться переиспользуемые модули и библиотеки. Таким образом, каждый проект получал свой отдельный набор зависимостей, нерационально расходуя дисковое пространство.
Установка зависимостей отнимает большую часть времени сборки в CI-системах, поэтому ускорение этого шага благоприятно скажется на времени билда в целом.
Упрощенно, установка модулей состоит из следующих шагов:
- Вычисляется конкретная версия модуля из допустимого интервала
- Все модули необходимых версий выкачиваются из репозитория и сохраняются в локальный кеш
- Модули из локального кеша копируются в папку node_modules проекта
Если первые два шага уже достаточно соптимизированы и выполняются быстро, когда у вас уже есть закешированные модули, то третий шаг так и остался работать почти без изменений по сравнению с первыми версиями node и npm.
В новом подходе предлагается избавиться от третьего шага и заменить реальное копирование файлов на создание таблицы, которая смаппит запрашиваемые модули на их копии в локальном кеше.
Использование симлинков
Вместо реального копирования модулей, можно добавить симлинк на их местоположение в кеше. Такой подход реализован в PNPM, еще одном альтернативном пакетном менеджере. Подход вполне может работать, но с симлинками возникает множество проблем, связанных с двойственным местоположением файла, поиском смежных модулей и т.п. Кроме того, создание симлинков – это файловые операции, которых хотелось бы избежать в идеальном способе работы.
Пробуем Yarn PNP
Подробнее об этой фиче можно почитать в официальном описании. В этом параграфе содержится его краткий пересказ.
Версия Yarn с поддержкой PNP сейчас находится в feature-branch yarn-pnp.
Склонируем репозиторий локально с нужной веткой
git clone git@github.com:yarnpkg/yarn.git --branch yarn-pnp
Инструкция по сборке yarn находится здесь, набор шагов очень тривиальный.
После окончания сборки, добавляем себе алиас на кастомную версию yarn и можем начать c ней работать:
alias yarn-local="node $PWD/lib/cli/index.js"
Plug'n'play включается двумя способами: либо через флаг: yarn --pnp
, либо дополнительной конфигурацией в package.json
: "installConfig": {"pnp": true}
.
В качестве примера разработчики Yarn уже подготовили демо-проект. В нем есть Webpack, Babel и другие типичные для современного фронтенда инструменты. Попробуем установить его зависимости разными способами и получаем следующие результаты:
- Обычная установка
yarn
: 19s - Установка через
yarn --pnp
: 3s
Перед измерением была проведена одна холодная установка, чтобы все нужные модули уже были в кеше.
Давайте теперь разберемся как это работает. После pnp-установки в корне проекта создается дополнительный файл .pnp.js
который содержит переопределение нативной логики во встроенном в Node.js классе Module. Загружая этот файл в свой код, мы наделяем функцию require()
возможностью доставать модули из глобального кеша и не смотреть в node_modules
. Все встроенные yarn-команды, вроде yarn start
или yarn test
по умолчанию предзагружают этот файл, так что никаких изменений в вашем коде не потребуется, если вы уже использовали Yarn до этого.
В дополнение к маппингу модулей, pnp.js выполняет дополнительную валидацию зависимостей. Если вы попытаетесь вызвать require('test')
, без задекларированной зависимости в package.json
, вы получите следующую ошибку: Error: You cannot require a package ("test") that is not declared in your dependencies
. Это улучшение должно повысить надежность и предсказуемость кода.
Из недостатков нового подхода стоит отметить, что потребуется дополнительная интеграция для инструментов, которые работали с директорией node_modules напрямую без встроенных механизмов Node. Например, для Webpack и других сборщиков фронтенда понадобятся дополнительные плагины, чтобы они смогли находить нужные файлы для бандлинга.
В демо-проекте есть наброски резолверов, для Eslint, Jest, Rollup и Webpack.
В моем эксперименте ещё возникли проблемы с Typescript, который сильно завязан на наличие node_modules и здесь нет простой возможности переопределить стратегию поиска модулей.
Также будут проблемы с postintall-скриптами. Поскольку модуль остаётся в кеше, postinstall-скрипты, меняющие его состояние (например, докачивающие дополнительные файлы) могут повредить кеш и сломать остальные проекты, зависящие от него. Разработчики Yarn рекомендуют отключать исполнение скриптов флагом --ignore-scripts
. Они уже экспериментировали с включением этого флага по умолчанию для всех проектов внутри Facebook и не обнаружили серьезных проблем. В долгосрочной перспективе отказ от postinstall-скриптов кажется хорошим шагом в виду известных проблем с безопасностью.
Пробуем NPM tink
Команда NPM также анонсировала свое альтернативное решение. Их новый инструмент, tink поставляется отдельным, независимым от NPM, модулем. На вход tink принимает файл package-lock.json
, который автоматически генерируется при запуске npm install
. На основании lock-файла tink генерирует файл node_modules/.package-map.json
, в котором хранится проекция локальных модулей на их реальное местоположение в кеше.
В отличие от Yarn, здесь нет хук-файла, который можно предзагрузить в свой проект, чтобы пропатчить require. Взамен предлагается использовать команду tink
вместо node
, чтобы получить правильное окружение. Такой подход менее эргономичный, поскольку потребует модификаций в вашем коде, чтобы заставить его работать. Однако в качестве proof-of-concept подойдет.
Я попробовал сравнить скорость установки модулей командами npm ci
и tink
, но tink оказался даже медленнее, поэтому результаты приводить не буду. Очевидно, что этот проект намного более сырой по сравнению с Yarn и совсем не оптимизирован. Что ж, будем ждать новых релизов.
Заключение
Отказ от директории node_modules – закономерный шаг, учитывая опыт других языков, где такого подхода не было изначально. Это благоприятно скажется на скорости сборки с CI-системах, где есть возможность сохранить кеш пакетов между билдами. Кроме того, если перенести кеш пакетов и файл .pnp.js
с одного компьютера на другой, то можно воспроизвести окружение даже не запуская Yarn. Это может быть полезным в контейнерных системах сборки: монтируем директорию с кешем, кладем .pnp.js
файл, и можно сразу запускать тесты.
Новый подход выглядит непривычно и ломает некоторые устоявшиеся практики, основанные на том, что все модули всегда в наличии в node_modules. Но .pnp.js
файл предлагает API, которое позволит абстрагироваться от реального положения файлов и работать с виртуальным деревом. Кроме того, на крайний случай, есть команда yarn unplug --persist
, которая извлечет модуль из кеша и разместит его локально в node_modules
.
В любом случае, ещё ничего не финализировано, даже pull-request в Yarn еще не влит, стоит ожидать изменений. Но мне было интересно попробовать альфа-версию фичи в деле и протестировать их на паре своих личных проектов и убедиться, что этот подход действительно работает, делая установку быстрее.
Ссылки
Автор: Борис Сердюк