Склонировал чужой проект, набрал npm install — и через минуту рядом лежит папка node_modules на сотни мегабайт. Откуда npm узнал, что именно скачать и каких версий? Из одного небольшого файла в корне — package.json. В нём же записано, как проект запускать, что из него можно импортировать и на какой Node.js он рассчитан.
Ниже — разбор этого файла по группам полей и подробно про загадочные значки перед номерами версий: чем ^ отличается от ~, почему * лучше не писать и где прячется ловушка с версиями 0.x. Если npm в новинку, сначала стоит заглянуть в статью о том, как пользоваться Node, npm и yarn.
Что такое package.json и зачем он нужен
package.json — это паспорт и инструкция JavaScript-проекта в формате JSON. Он отвечает на три вопроса:
- что это за проект — имя, версия, лицензия, ссылки на репозиторий;
- как с ним работать — команды для запуска, сборки и тестов, точки входа для импорта;
- от чего он зависит — список пакетов и допустимых версий.
Благодаря третьему пункту node_modules не нужно хранить в репозитории и пересылать коллегам. Достаточно файла-списка: по нему любой разработчик командой npm install соберёт ту же папку у себя.
Создать файл можно руками, но быстрее командой npm init. Она задаст несколько вопросов, а с флагом -y молча возьмёт ответы по умолчанию. Вот что получается в свежем npm:
mkdir weather-widget && cd weather-widget
npm init -y
{
"name": "weather-widget",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"type": "commonjs"
}
Имя взято из названия папки, версия стартует с 1.0.0. Поля license здесь нет: начиная с npm 12 команда больше не подставляет лицензию ISC сама и пропускает поле, если лицензия не задана в настройках (npm config set init-license MIT). В старых версиях npm в таком файле будут ещё "author": "" и "license": "ISC".
Файл должен быть валидным JSON: ключи и строки только в двойных кавычках, без комментариев и без запятой после последнего элемента. Одна лишняя запятая — и npm install упадёт с ошибкой разбора.
Паспортные поля
Эта группа описывает проект для людей и для реестра npm. Типичный набор у библиотеки выглядит так:
{
"name": "@acme/date-kit",
"version": "2.4.1",
"description": "Маленькие хелперы для форматирования дат",
"keywords": ["date", "format", "i18n"],
"license": "MIT",
"author": "Мария Коваль <maria@example.com>",
"homepage": "https://github.com/acme/date-kit#readme",
"bugs": "https://github.com/acme/date-kit/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/acme/date-kit.git"
}
}
- name — до 214 символов, без заглавных букв и символов, недопустимых в URL: имя станет частью адреса в реестре и названием папки в node_modules. Префикс вида @acme/ — это scope, пространство имён организации или автора.
- version — версия в формате MAJOR.MINOR.PATCH, о нём подробно ниже. Для публикации обязательны оба поля, name и version. В проекте, который никуда не публикуется, они необязательны.
- description и keywords помогают найти пакет поиском на npmjs.com.
- license — идентификатор из списка SPDX: MIT, Apache-2.0, ISC. Для закрытого кода, которым нельзя пользоваться без разрешения, пишут "UNLICENSED".
- author и contributors — автор и соавторы, строкой или объектом с полями name, email, url.
- homepage, bugs, repository — ссылки на сайт, трекер задач и исходники. Именно их npmjs.com показывает в карточке пакета.
Отдельно стоит поле private. Если в нём true, npm откажется публиковать пакет. Для сайтов и приложений, которые никогда не попадут в реестр, это дешёвая страховка от случайного npm publish:
{
"name": "acme-landing",
"private": true
}
Поля, которые управляют запуском и подключением
scripts — свои команды
В scripts длинные команды получают короткие имена. Запускаются они через npm run <имя>, а для start и test слово run можно пропустить. Утилиты из node_modules/.bin внутри скриптов доступны по имени, без путей и npx.
{
"scripts": {
"dev": "vite",
"prebuild": "npm run lint",
"build": "vite build",
"lint": "eslint src",
"test": "vitest run"
}
}
Отдельно про prebuild. Если у скрипта есть сосед с приставкой pre или post, npm выполнит его автоматически до или после основного. Команда npm run build здесь сначала прогонит линтер и только потом соберёт проект.
type, main и exports — как пакет импортируют
type говорит Node.js, как читать файлы с расширением .js: "commonjs" — через require, "module" — как ES-модули с import. Сам npm это поле не использует, оно для Node.js и инструментов сборки.
main — классическая единственная точка входа: файл, который отдаётся при импорте пакета по имени. Современная замена — exports. Она умеет описать несколько точек входа и отдать разные файлы для import и require:
{
"name": "@acme/date-kit",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./relative": "./dist/relative.js"
}
}
import { formatDate } from "@acme/date-kit"; // ./dist/index.js
import { timeAgo } from "@acme/date-kit/relative"; // ./dist/relative.js
import { parse } from "@acme/date-kit/dist/parse.js"; // ошибка: путь не объявлен
Последняя строка — главное, что нужно знать про exports. Как только поле появилось, импортировать можно только перечисленные пути, всё остальное закрыто. Автор библиотеки получает право спокойно переименовывать внутренние файлы, а пользователи — ошибку ERR_PACKAGE_PATH_NOT_EXPORTED, если лезли во внутренности. Поэтому добавление exports в существующий пакет обычно выпускают мажорной версией. Поле main рядом оставляют для старых инструментов, которые exports не понимают. Как формат модулей влияет на размер бандла, разобрано в статье про tree shaking — там же про ещё одно поле, sideEffects.
bin и files — что пакет приносит с собой
bin превращает файл пакета в консольную команду, files перечисляет, что попадёт в архив при публикации. Без files в реестр уедет почти вся папка, включая исходники и тесты.
{
"bin": {
"date-kit": "./bin/cli.js"
},
"files": ["dist", "bin"]
}
package.json, README, LICENSE и файлы из main и bin попадают в архив всегда, перечислять их не нужно. Проверить итоговый состав без публикации можно командой npm pack --dry-run.
engines и devEngines — на чём проект запускается
engines указывает, с какими версиями Node.js и npm работает пакет. По умолчанию это лишь подсказка: при несовпадении npm выдаст предупреждение EBADENGINE, но установку продолжит. Жёсткую проверку включает настройка engine-strict.
{
"engines": {
"node": ">=22"
},
"devEngines": {
"runtime": { "name": "node", "version": ">=22", "onFail": "error" },
"packageManager": { "name": "npm", "version": ">=11", "onFail": "warn" }
}
}
devEngines — поле поновее, для тех, кто работает с исходниками проекта, а не устанавливает его как зависимость. npm проверяет его перед install, ci и run, а по onFail решает, что делать при несовпадении: остановиться (error, по умолчанию), предупредить (warn) или промолчать (ignore). Так вся команда работает на одной версии рантайма. Как держать на машине несколько версий Node.js и переключаться между ними, рассказано в статье про смену версии Node в Windows.
Пять видов зависимостей
Зависимости живут не в одном списке, а в нескольких — в зависимости от того, кому и когда они нужны.
| Поле | Что туда кладут | Как добавить |
| dependencies | Всё, что нужно коду во время работы: фреймворк, роутер, библиотека дат | npm install react |
| devDependencies | Инструменты разработки: сборщик, линтер, тесты, TypeScript | npm install -D vite |
| peerDependencies | Пакет, который должен установить сам проект-хозяин. Типично для плагинов: React-компонент не тащит свою копию React | Вручную в package.json библиотеки |
| optionalDependencies | Желательные, но необязательные пакеты: если не установились, установка не падает | npm install -O fsevents |
| overrides | Не список зависимостей, а правило: принудительно заменить версию пакета где угодно в дереве, даже у зависимостей зависимостей | Вручную, только в корневом package.json |
Так это выглядит в библиотеке React-компонентов:
{
"dependencies": {
"clsx": "^2.1.1"
},
"devDependencies": {
"vite": "^7.0.0",
"vitest": "^3.2.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"overrides": {
"nanoid": "^3.3.8"
}
}
Несколько нюансов, на которых часто спотыкаются:
- Начиная с npm 7 peerDependencies устанавливаются автоматически. Если версии конфликтуют, установка падает с ERESOLVE, а не молча ставит вторую копию.
- На сервере зависимости для разработки можно не ставить: npm install --omit=dev. Поэтому сборщик, попавший в dependencies, раздувает продакшен, а нужная в рантайме библиотека в devDependencies ломает его.
- overrides работает только в корне проекта. Если указать его в публикуемой библиотеке, npm у пользователя это поле проигнорирует.
Как с теми же зависимостями обращается альтернативный менеджер пакетов, разобрано в статье о том, почему стоит попробовать pnpm.
Диапазоны версий: ^, ~, * и компания
Версии пакетов в npm следуют семантическому версионированию (SemVer): три числа MAJOR.MINOR.PATCH.
- PATCH растёт при исправлении ошибок: 2.4.1 → 2.4.2.
- MINOR — при новых возможностях без поломок: 2.4.2 → 2.5.0.
- MAJOR — когда старый код может перестать работать: 2.5.0 → 3.0.0.
В package.json обычно пишут не точную версию, а диапазон — правило, какие версии подходят. Значок перед номером и задаёт это правило. Возьмём выдуманный пакет date-kit, у которого в реестре есть версии 2.4.1, 2.4.7, 2.9.0 и 3.0.0:
| Запись | Что означает | Что поставит npm |
| 2.4.1 | Ровно эта версия | 2.4.1 |
| ~2.4.1 | Только патчи: от 2.4.1 и ниже 2.5.0 | 2.4.7 |
| ^2.4.1 | Патчи и минорные: от 2.4.1 и ниже 3.0.0 | 2.9.0 |
| 2.x или 2 | Любая 2-я мажорная: от 2.0.0 и ниже 3.0.0 | 2.9.0 |
| * или "" | Вообще любая версия | 3.0.0 |
Именно ^ npm ставит по умолчанию, когда выполняется npm install date-kit. Логика такая: минорные версии по правилам SemVer ничего не ломают, значит их можно брать автоматически. ~ осторожнее и пропускает только исправления. А * доверяет автору пакета полностью — при очередной установке может приехать мажорная версия с несовместимым API, и сборка упадёт на ровном месте.
Кроме значков, диапазон можно собрать из сравнений:
{
"dependencies": {
"pkg-a": ">=2.1.0 <3.0.0",
"pkg-b": "1.2.0 - 1.4.5",
"pkg-c": "^2.0.0 || ^3.0.0",
"pkg-d": "latest"
}
}
Пробел между условиями работает как «и», двойная черта || — как «или», дефис задаёт диапазон включительно с обоих концов. latest — это не диапазон, а тег: npm возьмёт версию, которую автор пометил как актуальную. С * у него тот же недостаток.
Проверить любой диапазон на реальном пакете можно командой npm view — она выведет версии из реестра, которые под него подходят:
npm view react@"^18.2.0" version
Ловушка: ^ у версий 0.x
Пока пакет не дошёл до 1.0.0, по SemVer он считается нестабильным: ломать API разрешено в любой момент. Поэтому ^ у таких версий ведёт себя строже и фиксирует первую ненулевую цифру:
^1.4.2 → >=1.4.2 <2.0.0 (как обычно)
^0.4.2 → >=0.4.2 <0.5.0 (только патчи, почти как ~)
^0.0.7 → >=0.0.7 <0.0.8 (ровно одна версия)
Отсюда частая путаница: у пакета вышла 0.5.0, в package.json стоит ^0.4.2, а npm update обновление не ставит. Это не баг: для нулевой мажорной версии переход 0.4 → 0.5 считается ломающим, и обновляться придётся вручную.
Пре-релизы не подтягиваются сами
Версии с суффиксом вроде 3.0.0-beta.1 или 2.5.0-rc.1 — это пре-релизы. Обычный диапазон их не захватывает: ^2.0.0 не поставит 2.5.0-rc.1, хотя по числам она внутри диапазона. Чтобы получить бету, её указывают явно — npm install date-kit@3.0.0-beta.1 или по тегу npm install date-kit@next, если автор публикует пре-релизы под ним. Так тестовые версии не попадают в проект случайно.
Что происходит при npm install
Диапазон в package.json — это пожелание, а не факт. Сегодня ^2.4.1 даёт 2.9.0, через месяц — 2.11.0. Чтобы у всех в команде и на сервере стояло одно и то же, npm записывает результат в package-lock.json: точную версию каждого пакета, включая вложенные, адрес загрузки и контрольную сумму.
- npm install берёт версии из lock-файла, а если package.json поменялся — досчитывает дерево и обновляет lock-файл.
- npm ci ставит строго по lock-файлу, предварительно удалив node_modules. Если lock и package.json расходятся, команда падает, а не исправляет. Это вариант для CI и деплоя.
- npm outdated показывает, что можно обновить, а npm update обновляет в пределах диапазонов. Чтобы выйти за диапазон, например на новую мажорную версию, нужен явный npm install date-kit@3.
Lock-файл коммитят в репозиторий вместе с package.json, а node_modules — нет.
Скрипты зависимостей и allowScripts
Некоторые пакеты при установке запускают свой код: preinstall, install, postinstall. Так библиотеки обработки картинок скачивают бинарники под нужную платформу. Тот же механизм годами использовали заражённые пакеты, поэтому в npm 12 такие скрипты заблокированы по умолчанию. Разрешения хранятся прямо в package.json, в поле allowScripts:
{
"allowScripts": {
"sharp@0.34.3": true,
"esbuild": true,
"telemetry-helper": false
}
}
Запись с версией разрешает скрипт только этой версии, запись без версии — любой. false блокирует пакет явно. Вручную поле обычно не правят: после установки npm перечисляет пакеты с пропущенными скриптами, а команда npm approve-scripts <пакет> записывает одобрение. Затем npm rebuild запускает то, что разрешили.
Ещё одна защита — не ставить версии, которые вышли только что. Взломанный релиз обычно замечают и снимают с реестра довольно быстро, и небольшая задержка перед обновлением отсекает его. С npm 11.10 для этого есть настройка min-release-age: она живёт не в package.json, а в .npmrc рядом с ним.
# .npmrc — брать только версии старше 7 дней
min-release-age=7
Частые ошибки
- * или latest вместо версии. Проект собирается сегодня и ломается через полгода без единого изменения в коде. Лучше обычный ^ плюс lock-файл.
- Удалённый или непокоммиченный lock-файл. У каждого разработчика оказывается своё дерево зависимостей, и баг «у меня работает» гарантирован. Чинить конфликт в lock-файле надо через npm install, а не руками.
- Нет "private": true в приложении. Одна случайная команда — и код компании лежит в публичном реестре.
- Комментарий или висячая запятая в JSON. npm не прочитает файл. Пояснения к скриптам лучше держать в README.
Если проекту нужна полная воспроизводимость без всяких диапазонов, npm можно попросить сохранять точные версии: npm config set save-exact true. Тогда npm install date-kit запишет "2.9.0" вместо "^2.9.0", а обновления придётся делать осознанно.
Итог
- package.json описывает проект, его команды, точки входа и зависимости — по нему npm воссоздаёт node_modules на любой машине.
- exports задаёт точки входа пакета и закрывает всё остальное, private: true защищает от случайной публикации.
- ~ пускает только патчи, ^ — патчи и минорные, * — всё подряд. У версий 0.x значок ^ работает строже.
- Точные версии фиксирует package-lock.json, а скрипты установки зависимостей в npm 12 нужно явно разрешать в allowScripts.
Комментарии (0)