open atlas
↑ К треку
Node.js с нуля до senior NODE · 01 · 02

package.json, npm, semver и lock-файлы

package.json объявляет намерение через semver-диапазоны; lockfile пинит точное разрешённое дерево. npm install может переписать lock, npm ci ставит строго из него — поэтому коммить lockfile и используй npm ci в CI.

NODE Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Сборка зелёная на твоём ноуте и красная в CI, а кода никто не трогал. Дифф — одна транзитивная зависимость: библиотека логирования, которую ты ни разу не импортировал, выкатила патч за ночь, твой ^-диапазон молча его проглотил, и он сломался на CI-образе. В package.json написано "pino": "^9.0.0" — та же строка, что и вчера, — поэтому манифест «не менялся». Изменилось то, во что ^ разрешается сегодня. Фикс — одно слово в пайплайне: npm ci, который ставит точное дерево, уже кем-то залоканное, вместо того чтобы решать диапазоны заново.

package.json: манифест намерения

package.json — это контракт Node-проекта: идентичность, скрипты и от чего он зависит. Поля, которые отрабатывают свой хлеб на уровне middle+:

{
  "name": "billing-service",
  "version": "2.4.1",
  "type": "module",
  "engines": { "node": ">=20" },
  "exports": { ".": "./dist/index.js" },
  "scripts": { "build": "tsc", "test": "vitest run" },
  "dependencies": { "pino": "^9.0.0" },
  "devDependencies": { "vitest": "~2.1.0", "typescript": "5.6.3" },
  "peerDependencies": { "react": ">=18" },
  "optionalDependencies": { "fsevents": "^2.3.0" }
}

"type": "module" решает, как Node трактует .js-файлы (ESM vs CommonJS — прошлый урок). "exports" — современная карта входов: она задаёт ровно те пути, которые потребителям разрешено импортировать, и прячет всё остальное, поэтому глубокие импорты в твои внутренности перестают работать — она вытесняет старое одиночное поле "main". "engines" объявляет диапазон Node, который ты поддерживаешь; со строгой конфигурацией установщика это может жёстко уронить установку на неверном runtime, а не падать загадочно во время выполнения.

Четыре корзины зависимостей не взаимозаменяемы:

  • dependencies — нужны в runtime любому, кто ставит твой пакет. Отгружаются.
  • devDependencies — нужны только для сборки и тестов (компиляторы, тест-раннеры, линтеры). Ставятся локально, пропускаются при npm install --omit=dev / NODE_ENV=production.
  • peerDependencies — «я работаю вместе с этим, но host-приложение должно его предоставить». React-плагин указывает react здесь, чтобы делить единственную копию хоста, а не тащить второй, дублирующий React.
  • optionalDependencies — установка пробуется, но провал не фатален; твой код обязан подстраховаться на случай отсутствия. Используется для платформенных нативных аддонов вроде fsevents (только macOS).

Semver: диапазон — это обещание, а не пин

Версии — это MAJOR.MINOR.PATCH, и по спеке npm каждая цифра — обещание потребителям: PATCH = обратно-совместимые багфиксы (1.0.01.0.1), MINOR = обратно-совместимые новые фичи (1.0.01.1.0), MAJOR = изменения, ломающие обратную совместимость (1.0.02.0.0). Версия может нести prerelease-тег вроде 1.0.0-beta.2, который сортируется до 1.0.0 и исключается из обычных диапазонов, пока ты явно не попросишь.

То, что ты пишешь в package.json, — обычно диапазон, и оператор решает, сколько дрейфа ты принимаешь на следующей установке:

СпекаДопускает до (для 1.2.3)ПускаетКогда
^1.2.3<2.0.0minor + patchпо умолчанию; доверяешь semver либы
~1.2.3<1.3.0только patchосторожно; нужны фиксы, не фичи
1.2.31.2.3ничеготочный пин; максимум детерминизма
*любую версиювсё, включая мажорыникогда в проде

^ — это то, что npm install <pkg> пишет по умолчанию, потому что он подхватывает багфиксы и новые фичи автоматически, при этом обещая никогда не пересекать мажор. Риск прямо в хуке: это обещание зависит от того, версионирует ли мейнтейнер честно. Библиотека, выкатившая ломающее изменение как minor — или просто случайную регрессию в patch — потечёт прямо в твоё дерево на следующей не-запиненной установке. ^ решает потолок того, что может войти. Что реально вошло в прошлый раз, решает кое-что другое.

Почему это работает

^0.x — ловушка. Ниже 1.0.0 caret особым образом схлопывается до только-patch: ^0.2.3 означает >=0.2.3 <0.3.0, а не <1.0.0. Логика в том, что пакеты до 1.0 объявляют себя нестабильными, поэтому каждый bump 0.MINOR трактуется как потенциально ломающий. Многие реальные зависимости сидят на 0.x годами, так что это правило кусает чаще, чем ожидают.

Lockfile: что реально установилось

package.json отвечает на «что я готов принять?» package-lock.json отвечает на «что я получил?» Когда npm разрешает твои диапазоны, он обходит весь граф зависимостей — твои зависимости, их зависимости, до самого низа — выбирает конкретную версию для каждого узла и пишет всё уплощённое дерево в lockfile: точные версии, разрешённый URL registry и хэш integrity (SHA в духе Subresource Integrity для tarball’а). На следующей установке npm может пересобрать байт-в-байт идентичный node_modules и проверить каждую загрузку против её хэша.

Это и есть граница между двумя командами установки, и это сеньорный пойнт урока:

npm installnpm ci
Источник истинызаново решает диапазоны package.jsonставит строго из lockfile
Меняет lockfile?да — может добавить/обновить записинет — никогда не пишет lock
Нужен lockfile?нет (создаёт, если нет)да — без него ошибка
Lock не совпал с манифестомобновляет lock, чтобы примиритьвыходит с ошибкой
node_modulesпатчит на местесперва удаляет, чистая установка
Поставить один пакет?да (npm i lodash)нет — только весь проект
Используй длялокальная разработка, добавление/удаление depsCI, деплои, любая воспроизводимая сборка

Сеньорное правило вытекает из таблицы. Коммить lockfile, и в CI запускай npm ci, а не npm install. npm ci детерминирован (игнорирует диапазоны и ставит ровно то, что залокано), чист (сначала стирает node_modules, так что не остаётся обрывков состояния), быстр (пропускает шаг разрешения) и — критично — падает громко, если package-lock.json и package.json разошлись, вместо того чтобы молча переписать lock, как сделал бы npm install. Это падение — фича: оно ловит вручную отредактированный манифест, который никто не перелочил, до того как он уедет.

# Локально: меняем deps, что обновляет package.json И package-lock.json
npm install lodash      # добавляет "^4.x" в deps, пишет разрешённое дерево в lock
git add package.json package-lock.json   # коммить ОБА, вместе

# CI / Dockerfile: воспроизводим залоканное дерево точно, падаем при дрейфе
npm ci                  # чисто, детерминированно, ошибка если lock рассинхронен

node_modules: уплощение, hoisting и фантомы

npm ставит в node_modules как плоское дерево там, где может: вместо вложения каждой зависимости внутрь родителя он поднимает (hoist) общие, совместимые версии наверх, чтобы много пакетов делили одну копию. Это держит дерево мелким и маленьким — но оно протекает. Пакет, который твоё приложение никогда не объявляло, может оказаться поднятым на верх node_modules, и require("that-package") сработает на твоей машине. Это фантомная зависимость: ты используешь то, чего нет в твоём package.json. В день, когда апгрейд зависимости перестанет её поднимать — или свежая установка разрешит другое дерево — твой импорт исчезнет, и сборка сломается, а в манифесте ничего не объяснит почему.

Это одна из причин, по которой существует pnpm: он строит строгий, симлинкованный node_modules, где пакет видит только то, что явно объявил, превращая фантомные зависимости в жёсткую ошибку, а не в тихий костыль. Yarn — другая крупная альтернатива, со своим lockfile (yarn.lock) и, в современных версиях, режимом Plug’n’Play, который вообще убирает node_modules. Все три решают ту же задачу, что и npm; различаются форматом lockfile и тем, насколько агрессивно изолируют дерево. Переносимый урок не зависит от инструмента: объявленная зависимость плюс закоммиченный lockfile — единственная комбинация, которая воспроизводится.

Почему это работает

Lockfile — это ещё и твой первый контроль supply-chain. Хэш integrity у каждой записи означает, что подменённый tarball в registry провалит проверку при установке, а пиннинг через lock значит, что атакующий не подсунет вредоносный патч через твой ^-диапазон между сборками. Запускай npm audit, чтобы вытащить известные CVE из залоканного дерева, и относись к lockfile как к проверяемому, закоммиченному артефакту — внезапное изменение в нём в PR заслуживает того же внимания, что и изменение кода, потому что оно может подменить ровно то, что исполняется.

Викторина

CI падает на зависимости, которую ты не менял. package.json всё ещё содержит тот же ^-диапазон. Самая вероятная причина и фикс?

Викторина

require('lodash') работает локально, но lodash нет в твоём package.json. Что происходит?

Выбери лучший вариант

Команда хочет воспроизводимые установки в CI и надёжные, отлаживаемые апгрейды. Какую политику установки принять?

Вспомните перед уходом
  1. 01
    Объясни разницу между npm install и npm ci и почему CI должен использовать npm ci.
  2. 02
    Что такое фантомная зависимость, чем она вызвана и как её предотвратить?
Итог

package.json — это манифест намерения: идентичность (name, version), как Node его грузит (type, exports/main, engines), скрипты и четыре разные корзины зависимостей — dependencies (отгружаются в runtime), devDependencies (только сборка/тесты), peerDependencies (предоставляет host-приложение) и optionalDependencies (не фатально при отсутствии). То, что ты пишешь для зависимости, — обычно semver-диапазон, и оператор задаёт потолок: ^ допускает minor и patch (по умолчанию и источник тихого дрейфа), ~ допускает только patch, точная версия не пускает ничего, а * никогда не безопасен в проде; помни, что ^0.x тихо вырождается до только-patch. Диапазон — лишь потолок, реальность пинит lockfile: package-lock.json записывает точное уплощённое дерево с версиями и хэшами integrity, чтобы установки были воспроизводимы и проверяемы. Это делает две команды установки разными по сути: npm install заново решает диапазоны и может переписать lock (верно для локальной смены зависимостей), тогда как npm ci ставит строго из lock, стирает node_modules для чистой установки, никогда не меняет lock и падает с ошибкой, если он разошёлся с package.json — поэтому сеньорная политика: коммить lockfile и использовать npm ci в CI и деплоях. Наконец, плоский, поднимающий node_modules от npm может породить фантомные зависимости — пакеты, которые ты используешь, но не объявлял, и которые ломаются на следующей перетасовке, — поэтому объявляй то, что импортируешь, относись к lockfile как к проверяемому supply-chain артефакту (хэши integrity, npm audit) и тянись к pnpm или yarn, когда хочешь более строгой изоляции, чем даёт уплощение npm.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.