Модуль 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 с таким же именем, и защищает от подмены зависимостью.

Три API одной операции: чем платите

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 }) — для каталога целиком.