npm и сторонние пакеты
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Почти любой проект на JavaScript состоит не только из вашего кода: заметную часть работы делают чужие пакеты, установленные командой npm install. Разберёмся, откуда они берутся, где лежат, что означают значки вроде ^1.4.2 — и почему выбирать пакет стоит внимательнее, чем кажется.
В корне проекта лежит файл package.json — обычный JSON, который npm читает перед каждой командой. Там записано, как проект называется, какая у него версия, какие пакеты ему нужны и какие команды он умеет запускать. Создаётся файл командой npm init -y, а дальше растёт сам по мере установки пакетов.
Отдельного внимания стоит поле scripts — словарь коротких имён для длинных команд. Написали npm run test, npm нашёл строку в словаре и выполнил её в терминале. Это способ не держать в голове флаги сборщика и не пересылать их коллегам в чат.
Команда npm install dayjs выглядит мгновенной, но делает сразу несколько вещей: спрашивает у реестра registry.npmjs.org, где лежит пакет с таким именем, скачивает архив, распаковывает его в папку node_modules, дописывает строчку в dependencies и запоминает точную скачанную версию в файле package-lock.json.
Дальше имя пакета работает как адрес: свой файл вы подключаете путём — ./utils.js, а пакет просто именем — import dayjs from "dayjs". Внутри пакет устроен ровно так же, как ваши модули: файл, который что-то экспортирует. Никакой магии, просто чужой код рядом с вашим.
Имя вместо пути понимают Node и сборщики: они знают, что пакет надо искать в node_modules. Браузер про эту папку ничего не знает, ему нужен адрес файла, а голое имя "dayjs" адресом не является. Примеры учебника выполняет сам браузер, без всякого сборщика, так что проверить можно прямо здесь. Прогноз: загрузится ли пакет?
Не загрузилось: браузер не смог превратить имя в адрес и отклонил промис с TypeError. Адрес имени даёт карта импортов — тег <script type="importmap"> со словарём в формате JSON: слева имя, справа адрес. На странице следующего примера такая карта уже стоит, и под именем dayjs в ней не файл, а крошечный модуль, записанный прямо в адресе: data:text/javascript,export default …. Прогноз: что напечатает пример?
Имя нашлось в карте, и модуль загрузился так, будто это настоящий пакет. Карты импортов понимают все основные браузеры: последним их поддержал Safari, в марте 2023 года. На настоящей странице карта указывает на файл рядом со страницей, а точку входа подключают, как в статье о модулях, тегом с type="module":
Файл в vendor скачан с jsDelivr — сервиса, который раздаёт файлы пакетов npm, а по адресу с +esm отдаёт пакет ES-модулем; первые строки файла сообщают, что для этого jsDelivr пропустил пакет через сборщик. Главный файл dayjs из node_modules на это место не встанет: он написан для require и обычного <script>, export в нём нет, и импорт в main.js упадёт с SyntaxError.
В этом и граница карты: она только даёт имени адрес, а файл по адресу браузер выполняет как есть. Карты хватает, пока в проекте нет ни JSX и TypeScript, ни пакетов на CommonJS, ни пакетов из сотен файлов, ни хеша в именах файлов ради долгого кеша. С первым же из этих пунктов нужен сборщик, и что именно он делает, разбирает статья «Свой сборщик» в части IV.
| Команда | Что делает |
|---|---|
| npm init -y | создаёт заготовку package.json |
| npm install | ставит всё, что перечислено в package.json |
| npm install dayjs | скачивает пакет и дописывает его в dependencies |
| npm install -D vitest | то же самое, но в devDependencies |
| npm uninstall dayjs | удаляет пакет и строку из package.json |
| npm ci | ставит строго по package-lock.json, начисто |
Это другие клиенты к тому же самому реестру: пакеты те же, отличаются скорость, раскладка node_modules и имя lock-файла. Важное правило — в одном проекте живёт один менеджер. Если рядом окажутся package-lock.json и pnpm-lock.yaml, у разных участников команды соберутся разные версии, и вечер перестанет быть томным.
В dependencies попадает то, без чего приложение не работает у пользователя: библиотека дат, валидатор, сам фреймворк. В devDependencies — инструменты, нужные только вам за компьютером: сборщик, линтер, тестовый фреймворк, типы для TypeScript. Флаг -D (он же --save-dev) как раз кладёт пакет во второй список.
Разница становится заметной на сервере: npm ci --omit=dev ставит только боевые пакеты, и образ приложения худеет в разы. Ошибка «положил линтер в dependencies» ничего не сломает сразу — она просто утяжелит сборку и добавит лишнего чужого кода туда, где он никому не нужен.
Версия пакета — три числа через точку: 2.4.1. Это соглашение называется семантическим версионированием, semver. Последнее число меняют, когда починили ошибку; среднее — когда добавили новое, но старое продолжает работать; первое — когда что-то сломали намеренно, и обновление потребует правок в вашем коде.
Поэтому в package.json хранится не версия, а диапазон. Значок ^, который npm ставит по умолчанию, означает «бери свежее, но не переходи через мажорную границу».
| Запись | Какие версии подойдут | Когда так пишут |
|---|---|---|
| ^1.4.2 | от 1.4.2 до 2.0.0, не включая её | по умолчанию, в большинстве случаев |
| ~1.4.2 | от 1.4.2 до 1.5.0, не включая её | нужны только исправления |
| 1.4.2 | ровно эта, и никакая другая | пакет обновляется болезненно |
| * | любая | почти никогда |
node_modules — это склад, куда npm распаковывает скачанное. Его не коммитят в git: папка целиком восстанавливается из package.json и package-lock.json, поэтому её имя одним из первых попадает в .gitignore.
Главный сюрприз новичка — размер. Вы поставили пять пакетов, а внутри тысячи папок. Дело в том, что у каждого пакета есть свои зависимости, у тех — свои, и так вглубь. Соберём такое дерево вручную и посчитаем, сколько пакетов на самом деле приедет.
Если бы каждый пакет держал свои зависимости внутри себя, одна и та же библиотека скачалась бы десятки раз, а пути стали бы длиннее, чем терпит файловая система. Поэтому npm поднимает зависимости наверх, в общий плоский список, и кладёт вложенно только те, чьи версии конфликтуют. Менеджер pnpm идёт дальше: хранит все пакеты в одном месте на диске, а в проект кладёт ссылки — оттуда его экономность.
Устанавливая пакет, вы пускаете чужой код в проект с теми же правами, что и у собственного. История помнит показательные случаи: автор удалил из реестра крошечный left-pad — и сборки посыпались по всему миру; в популярный event-stream новый сопровождающий добавил код, охотившийся на криптокошельки. Это не повод бояться npm, это повод смотреть, что ставишь.
Перед установкой полезно потратить минуту на страницу пакета: когда был последний релиз, сколько своих зависимостей он тянет, отвечают ли на issues, есть ли типы для TypeScript, понятна ли лицензия. И самый честный вопрос — точно ли задача не решается десятком своих строк. В следующей статье, «Живой ли пакет», эта минута разложена на шесть вопросов вместо привычных звёзд и загрузок.
Каждая зависимость — обещание жить с ней годами: обновлять, чинить, объяснять новичкам. Иногда десять своих строк дешевле.
- package.json — паспорт проекта: имя, версия, скрипты и списки зависимостей. npm читает его перед каждой командой.
- npm install скачивает пакет в node_modules, дописывает строку в package.json и фиксирует точную версию в package-lock.json — lock-файл коммитят.
- dependencies нужны приложению у пользователя, devDependencies — только вам во время разработки; на сервере вторые можно не ставить.
- Версия — это три числа: мажор, минор, патч. Запись ^1.4.2 разрешает обновления до 2.0.0, ~1.4.2 — только патчи.
- node_modules не коммитят: он восстанавливается из package.json и lock-файла. Каждый пакет тянет свои зависимости, поэтому ставьте осознанно.
1/3Куда попадёт пакет после команды npm install --save-dev eslint?
2/3Какие версии разрешает запись "^1.4.2"?
3/3Нужно ли коммитить node_modules в репозиторий?
Прочитать package.json
Дан уже разобранный package.json. Выведите имя проекта и общее количество зависимостей — боевых и разработческих вместе.
Списки зависимостей — обычные объекты, поэтому считаем их ключи через Object.keys:
Если поля devDependencies в файле нет, обращение к нему даст undefined и Object.keys упадёт. Подстраховка — Object.keys(pkg.devDependencies ?? {}).
Проверка диапазона ~1.2.3
Запись ~1.2.3 разрешает только патч-обновления: подойдут 1.2.3 и 1.2.9, но не 1.3.0 и тем более не 2.0.0. Напишите функцию, которая это проверяет.
Разбираем строку на три числа и требуем совпадения мажорной и минорной части, а патч — не меньше исходного:
Обратите внимание на map(Number): без него сравнение шло бы по строкам, и «10» оказалось бы меньше «9».