Склонировал чужой проект, набрал 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.