Модули и npm: как код собирается из частей
Реальный проект — это не один файл, а десятки и сотни. Чтобы код одного файла мог пользоваться функциями другого, существуют модули. А чтобы подключать чужой код (библиотеки) и не копировать его вручную, существует npm — пакетный менеджер из мира Node.js. Разберём оба по порядку.
Зачем нужны модули
Если бы весь код жил в одной глобальной области, любые две переменные с одинаковым именем конфликтовали бы, а понять, откуда взялась функция, было бы невозможно. Модуль решает это просто: каждый файл — отдельное пространство имён. Внутри файла всё своё и приватное; наружу видно только то, что вы явно экспортировали, а взять чужое можно только через явный импорт.
Короткая формула: один файл — один модуль, и связи между файлами видны в строках import.
ES-модули: import и export
Современный стандарт — ES modules (ESM). Это синтаксис import/export, единый для браузера и для Node. Именно его вы пишете в TypeScript по умолчанию.
// file: math.ts
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
// "export default" — одно главное значение на модуль
export default class Calculator {
// ...
}
// file: app.ts
import Calculator, { add, PI } from "./math.js"; // default + именованные
import { add as plus } from "./math.js"; // переименование при импорте
import * as math from "./math.js"; // всё пространство модуля разом
console.log(add(2, 3)); // 5
console.log(math.PI); // 3.14159
Что важно запомнить:
- Именованный экспорт (
export function add) импортируется в фигурных скобках по тому же имени. - Экспорт по умолчанию (
export default) — один на файл, имя при импорте выбираете сами. - Путь к своему файлу начинается с
./или../. Путь без точки (import { z } from "zod") — это пакет изnode_modules. - В ES-модулях под Node относительный путь пишут с расширением
.js, даже если импортируете из.ts-файла: так будет называться скомпилированный файл, а Node подставлять расширения не умеет. С настройкойmodule: NodeNextкомпилятор наfrom "./math"ответит ошибкой TS2835 и сам подскажет./math.js;tsxдля разработки такое прощает, но собранныйtscкод без расширения упадёт наCannot find module.
CommonJS: require и module.exports
До появления ESM в Node была своя система модулей — CommonJS (CJS). Её вы будете встречать в унаследованном коде и многих библиотеках:
// экспорт
function add(a, b) {
return a + b;
}
module.exports = { add };
// импорт
const { add } = require("./math");
Чем CommonJS отличается от ES-модулей на практике:
require()— обычный вызов функции, его можно поставить в середине файла или внутри условия.import— это объявление, которое поднимается наверх и выполняется до остального кода.- CommonJS грузит модули синхронно; ESM спроектирован под асинхронную загрузку и статический анализ (бандлеры умеют выкидывать неиспользуемый экспорт).
- ESM — это будущее и стандарт; CommonJS — наследие, которое ещё долго будет рядом.
В Node то, какую систему использует ваш .js-файл, определяет поле "type" в package.json: "type": "module" — это ESM, "type": "commonjs" (или отсутствие поля) — CommonJS. В TypeScript вы почти всегда пишете import/export, а уже компилятор и настройки решают, во что это превратится.
package.json — паспорт проекта
Любой Node-проект описывается файлом package.json в корне. Это обычный JSON с метаданными проекта и, главное, списком зависимостей. Создать его можно командой npm init (а npm init -y — со значениями по умолчанию).
{
"name": "my-service",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"dev": "tsx watch src/app.ts",
"test": "vitest"
},
"dependencies": {
"express": "^4.19.2",
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.4.5",
"tsx": "^4.10.0",
"vitest": "^1.6.0"
}
}
dependencies против devDependencies
Зависимости делятся на две группы, и разница не косметическая:
- dependencies — пакеты, без которых приложение не работает в продакшене. Например, веб-фреймворк
expressили валидаторzod. Они уезжают на сервер вместе с приложением. - devDependencies — инструменты, нужные только во время разработки и сборки: сам компилятор
typescript, тестовый раннер, линтеры. На рабочем сервере собранного приложения они не нужны.
Команды кладут пакет в нужную группу: npm install express — в dependencies, npm install --save-dev typescript (короче npm i -D typescript) — в devDependencies. Правильное разделение уменьшает размер продакшен-сборки и снижает поверхность для уязвимостей.
semver — версии вида 4.19.2
Номер версии пакета — это semver (semantic versioning), три числа MAJOR.MINOR.PATCH:
- MAJOR (4.x.x) — несовместимые изменения, старый код может сломаться.
- MINOR (x.19.x) — новые возможности без поломок.
- PATCH (x.x.2) — исправления, ничего не ломающие.
В package.json перед версией обычно стоит значок, который задаёт допустимый диапазон обновлений:
^4.19.2— «карет»: разрешено всё до следующего MAJOR (4.x.x, но не5.0.0). Самый частый вариант.~4.19.2— «тильда»: разрешены только PATCH-обновления (4.19.x).4.19.2— строго эта версия, без обновлений.
Точные версии, которые реально установились, фиксируются в файле package-lock.json (у npm). Его коммитят в репозиторий — он гарантирует, что у вас и у коллеги соберётся ровно одно и то же.
node_modules и установка
Команда npm install (без аргументов) читает package.json, скачивает все зависимости и складывает их в папку node_modules в корне проекта. Туда же подтягиваются зависимости ваших зависимостей — поэтому папка получается огромной.
Два правила про node_modules:
- Не коммитьте её в git — она восстанавливается из
package.json+ lock-файла одной командой. Добавьтеnode_modules/в.gitignore. - Если что-то «сломалось непонятно почему», частое лечение — удалить
node_modulesи lock-файл и поставить заново.
npm, pnpm и другие
npm — менеджер по умолчанию, идёт вместе с Node. Есть альтернативы с тем же package.json: pnpm (хранит пакеты в одном общем месте и делает ссылки — экономит диск и ставит быстрее) и yarn. Для начала достаточно npm; pnpm стоит знать, потому что его часто выбирают в новых проектах.
Скрипты npm
Блок scripts в package.json — это именованные команды проекта. Запускаются они через npm run <имя>:
npm run build # выполнит "tsc" — скомпилирует TypeScript
npm run dev # запустит dev-режим с автоперезагрузкой
npm test # для test/start/stop слово run можно опустить
Скрипты избавляют от запоминания длинных команд и документируют, как с проектом работать: открыв package.json, любой человек сразу видит, как его собрать, запустить и протестировать.
Глубже: обёртка модуля, exports против module.exports и циклические зависимостирасширенное
Три вопроса про CommonJS задают на собеседованиях по Node чаще остальных, и все три объясняются одним механизмом. Перед выполнением Node заворачивает содержимое файла в функцию: (function (exports, require, module, __filename, __dirname) { ... }). Поэтому у каждого файла свои require и module, переменные верхнего уровня не утекают в глобальную область, а __dirname всегда указывает на каталог именно этого файла.
Второй вопрос вытекает из первого. exports это всего лишь локальная ссылка на объект module.exports, которую обёртка передала параметром. Дописывать в него свойства можно, exports.add = add работает, а вот присвоить exports = { add } бесполезно: переменная теперь указывает на новый объект, а require вернёт прежний module.exports, пустой. Заменить экспорт целиком можно только через module.exports = ....
Третий: require кеширует модуль по абсолютному пути и выполняет файл один раз, повторные вызовы отдают тот же объект. Отсюда поведение циклических зависимостей: если a.js требует b.js, а тот в свою очередь требует a.js, то b получит недостроенный module.exports модуля a, тот, что успел заполниться до строки с require. Ошибки не будет, будет undefined в неожиданном месте. Лечат циклы выносом общей части в третий модуль или отложенным require внутри функции; в ES-модулях цикл тоже возможен, но там связывание живое, и импорт получит значение, когда оно появится.
Коротко
- Модуль — это файл; наружу видно только
export, взять чужое можно только черезimport. - ES modules (
import/export) — современный стандарт; CommonJS (require) — наследие в Node-коде и старых библиотеках. - package.json — паспорт проекта: метаданные, зависимости и скрипты.
- dependencies нужны в продакшене, devDependencies — только для разработки и сборки.
- semver
MAJOR.MINOR.PATCH;^разрешает обновления до следующего MAJOR, lock-файл фиксирует точные версии. - node_modules восстанавливается из
package.json, в git не коммитится. - npm scripts запускаются через
npm run <имя>и документируют работу с проектом. - CommonJS заворачивает файл в функцию с
exports,requireиmodule; заменять экспорт можно только черезmodule.exports;requireкеширует модуль, поэтому в цикле зависимостей второй модуль видит недостроенный экспорт первого.
Что почитать дальше
- Основы JavaScript для TypeScript — язык, поверх которого всё это работает.
- Инструменты: компилятор и tsconfig — как
tscи настройки превращают ваши модули в исполняемый код. - Асинхронность и event loop — почему ESM грузится асинхронно и как Node это исполняет.