Модуль fs в Node.js: работа с файлами и каталогами
✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026
Содержание
Модуль fs — интерфейс Node к файловой системе: чтение, запись, каталоги, права, наблюдение за изменениями. Он встроен в платформу, но у него есть особенность, которая сбивает с толку новичков: одна и та же операция доступна в трёх разных вариантах, и выбор между ними влияет на то, будет ли ваш сервер отвечать другим клиентам.
Три API одного модуля
import { readFile } from 'node:fs/promises'; // 1. промисы — так пишут сегодня
import { readFileSync } from 'node:fs'; // 2. синхронно — блокирует всё
import { readFile as readFileCb } from 'node:fs'; // 3. колбэк — legacy
console.log('promises :', await readFile(f, 'utf8'));
console.log('sync :', readFileSync(f, 'utf8'));
readFileCb(f, 'utf8', (err, data) => console.log('callback :', data));
promises : привет
sync : привет
callback : привет
Результат одинаковый, цена разная:
| API | Импорт | Блокирует цикл | Когда брать |
|---|---|---|---|
| промисы | node:fs/promises |
нет | по умолчанию |
| синхронный | node:fs, суффикс Sync |
да | только до старта сервера и в CLI |
| колбэчный | node:fs |
нет | legacy-код |
Про цену синхронного варианта — не абстрактно: в отдельном разборе мы замерили, что во время readFileSync на 117 МБ таймер с интервалом 10 мс не сработал ни разу.
Префикс node: в импорте — не украшение. Он явно говорит, что это встроенный модуль, а не пакет из node_modules с таким же именем, и защищает от подмены зависимостью.
node:fs/promises
Выбор по умолчанию
await readFile(p, 'utf8')- Цикл не блокирует
- Обычный
try/catch - Диск ждёт пул libuv, не ваш поток
node:fs + Sync
Только до старта сервера
readFileSync(p, 'utf8')- Блокирует весь процесс
- Замер: 269 мс, 0 тиков таймера
- Законно: конфиг, CLI, миграции
node:fs + колбэк
Legacy
readFile(p, 'utf8', (err, d) => …)- Цикл не блокирует
- Вложенность и
errв каждом - Нужен для старого кода
Базовые операции
import {
readFile, writeFile, appendFile,
rename, unlink, stat,
mkdir, readdir, rm,
} from 'node:fs/promises';
await writeFile('a.txt', 'данные'); // создать/перезаписать
await appendFile('a.txt', '\nещё строка'); // дописать в конец
await rename('a.txt', 'b.txt'); // переименовать/переместить
await unlink('b.txt'); // удалить файл
| Задача | Метод |
|---|---|
| прочитать файл | readFile(path, 'utf8') |
| записать (затереть) | writeFile(path, data) |
| дописать в конец | appendFile(path, data) |
| переименовать / переместить | rename(from, to) |
| удалить файл | unlink(path) |
| создать каталог | mkdir(dir, { recursive: true }) |
| список файлов | readdir(dir) |
| информация о файле | stat(path) |
| удалить каталог с содержимым | rm(dir, { recursive: true, force: true }) |
Флаг recursive: true у mkdir избавляет от ручного создания вложенных папок и не падает, если каталог уже есть. У rm пара recursive + force — это современная замена устаревшему rmdir и внешним пакетам вроде rimraf.
Каталоги: readdir и stat
readdir возвращает просто имена, и по ним не понять, где файл, а где папка. За типом идут либо в stat, либо — правильнее — просят withFileTypes:
console.log(await readdir(d));
console.log((await readdir(d, { withFileTypes: true }))
.map((e) => `${e.name}:${e.isDirectory() ? 'dir' : 'file'}`));
console.log(await readdir(d, { recursive: true }));
[ 'b.txt', 'sub' ]
[ 'b.txt:file', 'sub:dir' ]
[ 'b.txt', 'sub' ]
withFileTypes экономит по вызову stat на каждый элемент — на больших каталогах разница заметная.
stat отдаёт метаданные:
const s = await stat('b.txt');
console.log('isFile:', s.isFile(), '| size:', s.size, '| isDirectory:', s.isDirectory());
isFile: true | size: 1 | isDirectory: false
Кроме size там есть mtime (время изменения), birthtime (создание) и права доступа.
Ошибки: смотрите на код, а не на текст
Все ошибки fs несут поле code — короткую константу ОС. Именно её и нужно проверять:
try {
return await readFile(path, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return null; // файла нет — штатная ситуация
throw err; // всё остальное — настоящая ошибка
}
| Код | Значение |
|---|---|
ENOENT |
файла или каталога нет |
EACCES |
нет прав доступа |
EISDIR |
ожидался файл, а это каталог |
ENOTDIR |
ожидался каталог, а это файл |
EEXIST |
файл уже есть (при флаге 'wx') |
EMFILE |
кончились дескрипторы — где-то забыли close() |
Сравнивать err.message с текстом нельзя: формулировки меняются между версиями Node, коды — нет.
Не проверяйте существование заранее
existsSync перед чтением — лишний вызов и гонка: между проверкой и чтением файл может исчезнуть.
console.log('existsSync говорит:', existsSync(race));
await unlink(race); // файл удалили прямо сейчас
await readFile(race, 'utf8'); // ...и чтение всё равно упало
existsSync говорит: true
чтение упало : ENOENT
Проверка ничего не гарантировала. Единственный надёжный способ — попытаться выполнить операцию и обработать ENOENT.
Пути: забудьте про __dirname
В ESM-модулях __dirname и __filename не существуют. Раньше их воссоздавали через fileURLToPath(import.meta.url), теперь есть штатные свойства:
console.log(import.meta.dirname); // каталог текущего модуля
console.log(import.meta.filename); // полный путь к файлу
import.meta.dirname : string -> доступен
import.meta.filename: string
Доступны с Node 21.2, то есть на всех поддерживаемых LTS. И главное правило про пути: склеивайте их через path.join, а не конкатенацией строк — иначе код сломается на Windows.
Относительные пути в fs считаются от рабочего каталога процесса (process.cwd()), а не от файла модуля. Это частый источник «работает у меня, падает в проде»: запустили из другой папки — путь не нашёлся.
Что было на этой странице в 2016 году
Первая версия этого справочника вышла 22 мая 2016 года, и сравнение с ней показывает, что в модуле fs поменялось, а что нет.
Пример тех лет:
'use strict';
const fs = require('fs');
fs.unlink('/tmp/test.txt', (err) => {
if (err) throw err;
console.log('Файл /tmp/test.txt успешно удален');
});
Три вещи здесь — приметы времени: 'use strict' (в ESM он подразумевается), require вместо import и колбэк с if (err) throw err. Промис-версии fs тогда не существовало: fs.promises появился только в Node 10, в 2018-м. Выбор был между колбэком и Sync — третьего не было.
А вот главный совет той статьи не устарел ни на слово:
«В нагруженных проектах рекомендуется всегда использовать асинхронные методы. При выполнении синхронных методов весь процесс блокируется, пока не будет выполнен синхронный метод.»
Это ровно то, что мы замерили девять лет спустя: 269 мс полной остановки событийного цикла и ноль тиков таймера. Автор был прав, просто тогда это утверждали без цифр.
Изменился не принцип, а инструмент: там, где в 2016-м спасением был колбэк, сегодня есть node:fs/promises — и он же читается как синхронный код.
Когда fs не подходит
Все функции выше читают и пишут файл целиком. Если файл может оказаться большим, нужен поток: он обрабатывает данные кусками и не зависит от размера. Мы замеряли: на файле в 183 МБ readFile дал +169 МБ к памяти процесса, а поток читал порциями максимум по 64 КБ.
Причём дело не только в памяти: у readFile с кодировкой есть жёсткий потолок примерно в 512 МБ — больше он не прочитает никогда, независимо от объёма оперативной памяти.
Второй случай, когда fs не нужен, — работа с дочерними процессами: их вывод читают потоком, а не через временные файлы.
Остальные модули платформы разобраны в справочнике по Node.js.
Частые вопросы
Чем `node:fs/promises` отличается от `fs.promises`?
Как следить за изменениями файла?
fs.watch(). Но он опирается на механизмы ОС и ведёт себя по-разному на разных платформах: на сетевых дисках может не работать вовсе.Почему `EMFILE`?
open() без close(), либо слишком много параллельных операций.Как скопировать файл?
copyFile(src, dest) для одного файла, cp(src, dest, { recursive: true }) — для каталога целиком.