Всем привет! Меня зовут Слава Фомин, я ведущий разработчик в компании DomClick. За свою 16-ти летнюю практику я в первых рядах наблюдал за становлением и развитием JavaScript как стандарта и экосистемы. В нашей компании мы используем JavaScript, в первую очередь, для продвинутой front-end разработки и успели перепробовать достаточно большое количество различных технологий, инструментов и подходов, набить много шишек. Результатом этого кропотливого труда стал ценнейший опыт, которым я и хочу поделиться с вами.
Изначально, я хотел рассказать о том как мы работаем над сотнями пакетов с использованием монорепозиториев, но в результате пришел к выводу, что для углубления в эту довольно сложную тему стоило бы сначала объяснить более простые концепции. В итоге решил оформить весь материал в виде блога с небольшими постами, в которых буду подробнее раскрывать отдельные темы, плавно водя читателя от простых вопросов к сложным. Надеюсь, это сделает материал полезным не только состоявшимся профессионалам, но и новичкам.
Вот список тем, которые я планирую затронуть в этом блоге:
- Что такое пакет, манифест пакета и зависимости.
- Как правильно описывать зависимости для различных типов проектов.
- Как работает semver и как правильно использовать диапазоны версий в манифесте проекта.
- Как установленные зависимости могут быть представлены в файловой системе, плюсы и минусы разных решений.
- Как работает поиск зависимостей (resolving).
- Какие существуют инструменты для работы с зависимостями.
- Как правильно обновлять зависимости.
- Как следить за безопасностью, отслеживать и предупреждать угрозы.
- Для чего нужны lock-файлы и как правильно ими пользоваться.
- Как можно эффективно работать над сотнями пакетов одновременно, используя монорепозитории и специальные инструменты.
- Что такое фантомные пакеты, откуда берется проблема дублирующихся пакетов и как с этим можно бороться.
- Как эффективно и безопасно использовать менеджер пакетов в контексте CI/CD.
- и многое другое.
Итак, не будем терять времени!
…Мы подобны карликам, усевшимся на плечах великанов; мы видим больше и дальше, чем они, не потому, что обладаем лучшим зрением, и не потому, что выше их, но потому, что они нас подняли и увеличили наш рост собственным величием…
— Бернар Шартрский
Я думаю мало кто станет отрицать, что того успеха, который сейчас наблюдается в области веб-разработки мы смогли добиться во многом благодаря всем тем людям, которые создали и поддерживают каждый день тысячи полезных библиотек и инструментов. Действительно, вы можете представить себе эффективную разработку современного веб-приложения без использования фреймворков, библиотек и инструментов?
Несмотря на огромный прогресс в фундаментальных веб-технологиях, создание даже мало-мальски сложного приложения потребовало бы достаточно высокой квалификации разработчиков и написания огромного количества базового кода с нуля. Но, слава богу, существуют такие решения, как Angular, React, Express, Lodash, Webpack и многие другие, и нам не нужно каждый раз изобретать колесо.
Немного истории JavaScript
Мы можем вспомнить «дикие времена», когда код популярных библиотек (таких как jQuery) и плагины к ним разработчику нужно было напрямую скачивать с официального сайта, а затем распаковывать из архивов в директорию проекта. Разумеется, обновление таких библиотек происходило точно так же: вручную. Сборка такого приложения тоже требовала ручного и достаточно творческого, уникального подхода. Про оптимизацию сборки я даже не стану упоминать.
К огромному счастью, которое более молодые разработчики вряд ли смогут сейчас испытать, эти времена ручного управления зависимостями в прошлом. Сейчас мы имеем хоть и не самые совершенные, но вполне рабочие инструменты, которые позволяют взять под контроль управление зависимостями даже в довольно сложных проектах.
Node.js приходит на помощь
Современную веб-разработку уже совершенно невозможно представить без Node.js, технологии, которая изначально разрабатывалась для сервера, но впоследствии стала платформой для любых JavaScript-проектов, как front-end приложений, так и всевозможных инструментов, а с популяризацией SSR граница между средами начала окончательно стираться. Таким образом, менеджер пакетов для Node.js (Node Package Manager, или npm), постепенно стал универсальным менеджером пакетов для всех библиотек и инструментов, написанных на JavaScript.
Также стоит заметить, что до появления ESM стандарт языка JavaScript в принципе не имел концепции модулей и зависимостей: весь код просто загружался через тег script
в браузере и выполнялся в одной большой глобальной области видимости. По этой причине разработчики Node внедрили собственный формат модулей. Он был основан на неофициальном стандарте CommonJS (от слов «распространенный/универсальный JavaScript», или CJS), который впоследствии стал де-факто стандартом в индустрии. Сам же алгоритм поиска зависимостей Node (Node.js module resolution algorithm) стал стандартом представления пакетов в файловой системе проекта, который сейчас используется всеми загрузчиками и инструментами сборки.
Пакет всему голова
Как было упомянуто выше, Node.js ввел свой формат представления и поиска зависимостей, который сейчас де-факто является общим стандартом для JavaScript-проектов.
В основе системы лежит концепция пакета: npm-пакет — это минимальная единица распространения кода на JavaScript. Любая библиотека или фреймворк представляются как один или несколько связанных пакетов. Ваше приложение также является пакетом.
Перед публикацией пакет, как правило, компилируется, а потом загружается в хранилище, которое называется npm registry. В основном используется централизованный официальный npm registry, который находится в публичном доступе на домене registry.npmjs.org. Однако использование частных закрытых npm registry также распространено (мы в ДомКлике активно используем такой для внутренних пакетов). Другие разработчики могут установить опубликованный пакет как зависимость в свой проект, загрузив его из registry. Это происходит автоматически при помощи менеджера пакетов (вроде npm).
Найти нужный пакет или изучить их список можно на официальном сайте npm.
Опубликованный пакет физически представляет собой версионированный архив с кодом, который готов к выполнению или внедрению в другой проект. Этот код может представлять собой библиотеку, которую вы можете включить в свое приложение (например, lodash), либо полноценную программу, которую можно напрямую вызвать у вас на компьютере (например, webpack).
При этом код из одного пакета может обращаться к коду из другого. В этом случае говорят, что один пакет зависит от другого. У каждого пакета может быть множество зависимостей, которые, в свою очередь, также могут иметь свои зависимости. Таким образом, связи между всеми пакетами образуют дерево зависимостей:
На изображении показан результат команды npm ls
— дерево зависимостей проекта, в котором установлено всего два пакета: HTTP-сервер Express (с множеством дочерних зависимостей) и библиотека Lodash (без зависимостей). Обратите внимание, что одна и та же зависимость debug
встречается 4 раза в разных частях дерева. Надпись deduped
означает, что npm обнаружил дублирующиеся зависимости и установил пакет только один раз (подробнее о дубликации мы поговорим в следующих постах).
Поскольку экосистема Node проповедует философию Unix, когда один пакет должен решать какую-то свою узкую задачу и делать это хорошо, то количество зависимостей в среднестатистическом проекте может быть очень велико и легко переваливает за несколько сотен. Это приводит к тому, что дерево зависимостей сильно разрастается как в ширину, так и в глубину. Наверное, только ленивый не шутил про размеры директории node_modules
, в которой устанавливаются все эти зависимости. Нередко, люди со стороны критикуют JavaScript за это:
Манифест пакета
Что же является пакетом и как мы можем его создать? По сути, пакетом может являться любая директория, содержащая специальный файл-манифест: package.json. Он может содержать множество полезной информации о пакете, такой как:
- название, версия и описание,
- тип лицензии,
- URL домашней страницы, URL git-репозитория, URL страницы для баг-репортинга,
- имена и контакты авторов и мейнтейнеров,
- ключевые слова, чтобы пакет можно было найти,
- файловые пути к коду библиотеки или выполняемым файлам,
- список зависимостей,
- вспомогательные локальные команды (scripts) для работы над пакетом,
- и др. (см. полный список).
Пример манифеста package.json.
Описание зависимостей пакета
Манифест пакета содержит ряд опциональных полей, которые позволяют задавать список зависимостей:
dependencies
,devDependencies
,peerDependencies
,optionalDependencies
.
Каждое из этих полей является JSON-объектом, где в качестве ключа указывается название пакета, а в качестве значения — диапазон версий, которые поддерживаются в вашем проекте.
Пример:
{
…
"dependencies": {
"lodash": "^4.17.15",
"chalk": "~2.3",
"debug": ">2 <4",
},
…
}
Давайте рассмотрим назначение каждого поля в отдельности.
dependencies
Поле dependencies
определяет список зависимостей, без которых код вашего проекта не сможет корректно работать. Это главный и основной список зависимостей для библиотек и программ на Node.js. Если в вашем коде есть импорты каких-то сторонних зависимостей, например import { get } from 'lodash'
, то эта зависимость должна быть прописана в поле dependencies
. Ее отсутствие приведет к тому, что при выполнении, ваша программа упадет с ошибкой, потому что нужная зависимость не будет найдена.
devDependencies
Поле devDependencies
позволяет задать список зависимостей, которые необходимы только на этапе разработки пакета, но не для выполнения его кода в рантайме. Сюда можно отнести всевозможные инструменты разработки и сборки, такие как typescript, webpack, eslint и прочие. Если ваш пакет будет устанавливаться как зависимость для другого пакета, то зависимости из этого списка установлены не будут.
peerDependencies
Поле peerDependencies
играет особую роль при разработке вспомогательных пакетов для некоторых инструментов и фреймворков. К примеру, если вы пишете плагин для Webpack, то в поле peerDependencies
вы можете указать версию webpack, которую ваш плагин поддерживает.
Если ваш пакет будет установлен в проекте, где указанная зависимость отсутствует, либо условие по версии не удовлетворяется, то менеджер пакетов сообщит разработчику об этом.
При установке вашего пакета, зависимости из поля peerDependencies
автоматически не устанавливаются, разработчик должен сам установить их в свой родительский проект, прописав в манифесте своего пакета. Это также гарантирует, что в проекте будет использоваться только одна версия зависимости без дубликатов. Ведь если в проекте будет установлено несколько разных версий, скажем, Webpack, то это может привести к серьезным конфликтам.
optionalDependencies
И последнее поле optionalDependencies
позволяет вам указать некритические зависимости, без которых ваше приложение сможет всё равно выполнять свою работу. Если менеджер пакетов не сможет найти или установить такую зависимость, то он просто проигнорирует её и не вызовет ошибки.
Важно понимать, что ваш код должен корректно реагировать на отсутствие таких зависимостей, например, используя связку try… require… catch
.
Зависимости во front-end проектах
Выше мы рассмотрели четыре способа задания различных зависимостей для вашего проекта. Однако не стоит забывать, что эта система была изначально придумана для приложений и библиотек на Node.js, которые выполняются напрямую на машине пользователя, а не в особой песочнице, коей является браузер. Таким образом, стандарт никак не учитывает особенности разработки front-end приложений.
Если кто-то вам скажет, что один способ определения npm-зависимостей для front-end приложений является правильным, а другой нет, то не верьте: «правильного» способа не существует, потому что такой вариант использования просто не учтен в node и npm.
Однако для удобства работы над front-end приложениями я могу предложить вам проверенный временем и опытом формат определения зависимостей. Но для начала давайте попробуем разобраться, чем отличается front-end проект от проекта на Node.js.
Обычно конечная цель Node.js-разработчика заключается в том, чтобы опубликовать созданный им пакет в npm registry, а уже оттуда этот пакет скачивается, устанавливается и используется пользователем как готовое ПО или как библиотека в составе более сложного продукта. При этом зависимости из поля dependencies
в манифесте пакета устанавливаются в проект конечного пользователя.
В случае же с front-end приложением оно не публикуется в npm registry, а собирается как самостоятельный артефакт (статика) и выгружается, например, на CDN. По-сути, npm во front-end проектах используется только для того, чтобы устанавливать сторонние зависимости. По этой причине в манифесте подобного проекта рекомендуется использовать опцию private: true
, которая гарантирует, что файлы приложения не будут случайно отправлены в публичный npm-registry. Название же и версия самого пакета приложения не имеют смысла, т. к. «снаружи» нигде не используются.
Эта особенность front-end приложений позволяет нам использовать поле dependencies
не совсем по его прямому назначению, а как категорию для того, чтобы разделить список зависимостей на две части: в поле dependencies
вы пишите список прямых зависимостей, которые используются в коде приложения, например, lodash
, react
, date-fns
и т. д., а в поле devDependencies
— зависимости, которые нужны для разработки и сборки приложения: webpack
, eslint
, декларации из пакетов @types
и т. д.
Постойте, но это ведь ничем не отличается от того, как прописываются зависимости для пакетов на Node.js! Да, однако некоторые особо педантичные разработчики могут заявить, что раз сторонние зависимости объединяются в бандл приложения при сборке и фактически не импортируются в рантайме, то они должны находиться в поле devDependencies
. Теперь вы можете аргументировано защитить более практичный подход.
Семантическое версионирование
В экосистеме npm принят стандарт версионирования пакетов semver (от слов Semantic Versioning (семантическое версионирование)).
Суть стандарта заключается в том, что версия пакета состоит из трех чисел: основной (major) версии, младшей (minor) версии и patch-версии:
Например: 3.12.1.
Семантическим этот вид версионирования называется потому, что за каждым числом версии, а точнее, за увеличением числа стоит определенный смысл.
Увеличение patch-версии означает, что в пакет были внесены незначительные исправления или улучшения, которые не добавляют новой функциональности и не нарушают обратную совместимость.
Увеличение minor-версии означает, что в пакет была добавлена новая функциональность, но совместимость сохранилась.
Увеличение же major-версии означает, что в пакет были внесены серьезные изменения API, которые привели к потере обратной совместимости и пользователю пакета, возможно, необходимо внести изменения в свой код, чтобы перейти на новую версию. О таких изменениях и порядке миграции на новую версию обычно можно прочитать в файле CHANGELOG в корне пакета.
Нестабильные версии
Версии пакетов до 1.0.0, например, 0.0.3 или 0.1.2, в системе semver также имеют определенный смысл: такие версии считаются нестабильными и повышение первого ненулевого числа версии должно расцениваться как изменение с потенциальным нарушением обратной совместимости.
Продолжение следует
Мы рассмотрели самые основы управления зависимостями в JavaScript: узнали, что такое пакет, как он определяется и как задаются зависимости. В следующем посте мы подробнее рассмотрим, как на практике работает semver, как правильно прописывать диапазоны версий и обновлять зависимости.
Stay tuned!