Современная работа с файлами в Node.js
Современная работа с файлами в Node.js: fs/promises, потоки, file handles, коды ошибок, параллельность и защита от path traversal.
В современном Node.js основным инструментом для файлового ввода-вывода является модуль node:fs/promises на основе промисов совместно с async/await. Для работы с большими файлами или файлами неизвестного размера используйте потоки (pipeline из node:stream/promises), а для побайтового позиционного доступа — файловые дескрипторы (open/read/close). Модуль fs по-прежнему предоставляет три параллельных API — синхронный, на основе колбэков и на основе промисов, — и большинство устаревших руководств начинают именно с API колбэков и CommonJS require(). Такой подход устарел: для нового кода на актуальной версии среды выполнения правильной отправной точкой является node:fs/promises с ES-модульным import.
В этой статье показано, как читать, записывать и обрабатывать файлы с помощью этого современного API: поведение кодировок, семантика перезаписи, обработка ошибок по code, параллелизм с Promise.all и Promise.allSettled, ограничения памяти, при которых readFile даёт сбой, потоки и файловые дескрипторы для работы с большими данными, операции с директориями и защита от обхода путей. Примеры ориентированы на Node 24 — ветку Active LTS по состоянию на 2026 год (расписание релизов Node.js). Все показанные возможности доступны значительно ниже этой версии, поэтому код без изменений работает на Node 24; все примеры используют ESM import с префиксом node: и await на верхнем уровне модуля.
Ключевые выводы
- Используйте
node:fs/promisesсasync/awaitкак основной инструмент для файлового ввода-вывода; синхронные методы, такие какreadFileSync, блокируют цикл событий и допустимы только в одноразовых CLI-скриптах — никогда в серверном коде. readFileбуферизует весь файл в памяти и выбрасываетERR_FS_FILE_TOO_LARGEдля любого файла размером более 2 ГиБ — это фиксированное ограничение ввода-вывода libuv, не связанное с ограничениями Buffer и длины строк, — поэтому уже при нескольких сотнях МиБ следует переходить на потоки.pipeline()изnode:stream/promises(стабилен с Node 15) соединяет потоки и автоматически управляет распространением ошибок и освобождением ресурсов.- Используйте
Promise.all, когда все файлы должны быть обработаны успешно; используйтеPromise.allSettled, когда частичный сбой допустим и вы хотите обработать то, что удалось прочитать. - Никогда не передавайте непроверенные пользовательские данные в функции
fs: разрешайте путь с помощьюpath.resolveи проверяйте, что он остаётся внутри предназначенной базовой директории, чтобы предотвратить обход путей.
Три API модуля fs и почему fs/promises является стандартным выбором
Node.js предоставляет одни и те же файловые операции через три API: синхронный (readFileSync), на основе колбэков (readFile(path, cb)) и на основе промисов (node:fs/promises). API промисов стал стабильным в Node 14.0.0 (примечания к релизу Node.js 14) и является современным стандартом, поскольку органично интегрируется с async/await и никогда не блокирует цикл событий. API колбэков является устаревшим — он появился до промисов и приводит к глубоко вложенному коду, — а синхронный API блокирует цикл событий на всё время выполнения операции ввода-вывода. Для нового кода используйте API промисов.
import { readFile } from 'node:fs/promises'
const data = await readFile('config.json', 'utf8')
console.log(data.length)
Префикс node: указывает, что импорт является встроенным модулем Node.js и не может быть перекрыт npm-пакетом с таким же именем; он явно идентифицирует модуль как часть ядра Node.js. В этом примере также используется await на верхнем уровне модуля, доступный в ES-модулях без флагов с Node 14.8.0 (примечания к релизу Node.js 14.8.0). Больше не нужно оборачивать чтение файлов в async IIFE на уровне модуля.
Какой API выбрать?
Выбор зависит от размера файла, типа необходимого доступа и того, обрабатывает ли процесс параллельные задачи. Используйте эту таблицу как руководство для принятия решений:
| Сценарий | Рекомендуемый API | Причина |
|---|---|---|
| Небольшие файлы, примерно до 100 МБ | node:fs/promises readFile/writeFile | Простейший API; весь файл без труда помещается в память, и одного await достаточно |
| Большие файлы или файлы неизвестного размера | Потоки: createReadStream/createWriteStream с pipeline | Постоянный расход памяти независимо от размера; полностью исключает ограничение readFile в 2 ГиБ |
| Побайтовый или позиционный доступ (чтение/запись по конкретным смещениям) | Файловые дескрипторы: open/read/close | Только API дескрипторов позволяет читать или записывать конкретные диапазоны байт по заданным позициям |
| Одноразовые CLI-скрипты и инструменты сборки (без параллелизма) | Синхронные методы допустимы: readFileSync/writeFileSync | Блокировка цикла событий безвредна, когда ничего другого не выполняется |
| HTTP-серверы или любой параллельный код | Только асинхронный API промисов — никогда синхронный | Заблокированный цикл событий одновременно останавливает все ожидающие запросы |
Значение ~100 МБ — практическое эмпирическое правило для момента перехода на потоки, а не жёсткое ограничение; жёстким пределом отказа для readFile является ограничение libuv в 2 ГиБ. При сомнениях между fs/promises и потоками потоковая обработка является более безопасным выбором по умолчанию для всего, размер чего вы не контролируете.
Чтение файлов с помощью fs/promises
Discover how at OpenReplay.com.
readFile() загружает весь файл в память и возвращает либо строку, либо Buffer. Передайте кодировку, например 'utf8', чтобы получить декодированную строку; опустите кодировку, чтобы получить сырые байты в виде Buffer. Для конфигурационных и файлов с данными читайте как строку и разбирайте:
import { readFile } from 'node:fs/promises'
const raw = await readFile('config.json', 'utf8')
const config = JSON.parse(raw)
console.log(config.port)
Без кодировки возвращаемое значение является Buffer — правильный выбор для изображений, аудио или любых нетекстовых данных:
import { readFile } from 'node:fs/promises'
const bytes = await readFile('logo.png') // Buffer
console.log(bytes.length, 'bytes')
JSON.parse выбрасывает SyntaxError при некорректном вводе, поэтому блок try/catch вокруг разбора обрабатывает как сбои ввода-вывода, так и некорректный JSON. Полный перечень параметров см. в документации fs.readFile.
Запись файлов с помощью fs/promises
writeFile() создаёт файл, если он не существует, или полностью перезаписывает его, если существует — с точки зрения вызывающего кода, один await заменяет всё содержимое файла. Чтобы добавить данные в файл вместо его замены, используйте appendFile(), который также создаёт файл при его отсутствии. Чтобы усечь файл до фиксированной длины, используйте truncate().
import { writeFile, appendFile } from 'node:fs/promises'
const user = { name: 'Ada', email: 'ada@example.com' }
// Создаёт user.json или полностью заменяет его содержимое
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')
// Добавляет строку без перезаписи файла; создаёт его при отсутствии
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')
truncate(path, n) сохраняет первые n байт файла и отбрасывает остальное — аргумент означает количество байт, которые нужно оставить, а не удалить, что противоположно тому, что подсказывает название. Усечение 1234567890 до 5 оставляет 12345:
import { writeFile, truncate, readFile } from 'node:fs/promises'
await writeFile('data.txt', '1234567890')
await truncate('data.txt', 5)
console.log(await readFile('data.txt', 'utf8')) // '12345'
Обработка ошибок: перехват по error.code
Файловые операции завершаются с системными ошибками, содержащими свойство code; разветвляйте логику по error.code, а не разбирайте сообщения об ошибках. Наиболее часто встречающиеся коды задокументированы в справочнике распространённых системных ошибок Node.js:
error.code | Значение |
|---|---|
ENOENT | Файл или директория не существует |
EACCES | Доступ запрещён |
EISDIR | Попытка прочитать директорию как файл |
ENOSPC | На устройстве нет места (диск заполнен) |
EMFILE | Слишком много открытых файловых дескрипторов |
Один блок try/catch покрывает как сбой ввода-вывода, так и сбой разбора JSON:
import { readFile } from 'node:fs/promises'
try {
const config = JSON.parse(await readFile('config.json', 'utf8'))
console.log('loaded', config)
} catch (error) {
if (error.code === 'ENOENT') {
console.error('config.json is missing; using defaults')
} else if (error.code === 'EACCES') {
console.error('no permission to read config.json')
} else {
throw error // включает SyntaxError от JSON.parse и неожиданные коды
}
}
Перебрасывайте коды, которые вы специально не обрабатываете, вместо того чтобы их подавлять — молча перехваченные ошибки ENOSPC или EMFILE являются распространённым режимом сбоя в продакшне, скрывающим реальную причину.
Синхронный vs асинхронный: когда допустимы синхронные методы
Синхронные методы fs (readFileSync, writeFileSync) блокируют цикл событий Node.js на всё время выполнения операции ввода-вывода — никакой другой JavaScript в это время не выполняется. Они допустимы в одноразовых CLI-скриптах и инструментах сборки, где параллелизм отсутствует, но никогда — в HTTP-серверах или любом коде, обрабатывающем параллельные запросы, поскольку заблокированный цикл событий одновременно останавливает все ожидающие запросы (руководство по циклу событий Node.js).
// Допустимо: одноразовый скрипт, который запускается и завершается
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
console.log(pkg.version)
Правило выбора: если процесс обслуживает более одной задачи одновременно, используйте асинхронный API промисов.
Параллелизм: Promise.all vs Promise.allSettled
Используйте Promise.all, когда все операции чтения файлов должны завершиться успешно, а единственный сбой должен прервать весь пакет; используйте Promise.allSettled, когда частичный сбой допустим и вы хотите обработать то, что удалось прочитать. Promise.allSettled всегда разрешается, возвращая массив, где каждый элемент является либо { status: 'fulfilled', value }, либо { status: 'rejected', reason }.
Преобразование имён файлов в промисы без await внутри цикла запускает операции чтения параллельно:
import { readFile } from 'node:fs/promises'
const files = ['a.json', 'b.json', 'c.json']
// Всё или ничего: один отсутствующий файл отклоняет весь пакет
const all = await Promise.all(
files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)
Если вы предпочитаете загрузить всё доступное и сообщить об остальном, проверьте завершённые результаты:
import { readFile } from 'node:fs/promises'
const files = ['a.json', 'b.json', 'missing.json']
const results = await Promise.allSettled(
files.map((f) => readFile(f, 'utf8')),
)
const loaded = results.filter((r) => r.status === 'fulfilled').map((r) => r.value)
const failed = results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason.code) // например, 'ENOENT'
console.log(`loaded ${loaded.length}, failed:`, failed)
Промисы fs и большие файлы: ограничения памяти, при которых readFile даёт сбой
readFile буферизует весь файл в памяти и выбрасывает ERR_FS_FILE_TOO_LARGE для любого файла размером более 2 ГиБ — это фиксированное ограничение ввода-вывода libuv, а не ограничение размера Buffer (node#55864, ERR_FS_FILE_TOO_LARGE). Задолго до этого жёсткого предела загрузка сотен мегабайт в один буфер создаёт нагрузку на память и замедляет ответы, поэтому уже при нескольких сотнях МиБ следует переходить на потоки.
Три различных ограничения легко перепутать, и большинство руководств смешивают их:
| Ограничение | Значение (64-бит) | Применяется к |
|---|---|---|
| Предел чтения файлов libuv | 2 ГиБ | readFile для любого файла; выбрасывает ERR_FS_FILE_TOO_LARGE |
buffer.constants.MAX_STRING_LENGTH | 536 870 888 байт (~512 МиБ) | строки, то есть readFile с кодировкой |
buffer.constants.MAX_LENGTH | 2⁵³−1 байт (~8 ПиБ) | максимальный размер выделяемого Buffer |
При передаче кодировки в readFile (возвращающей строку) действующим ограничением является buffer.constants.MAX_STRING_LENGTH — 536 870 888 байт на 64-битных платформах, уменьшенное примерно с 1 ГБ в Node 14.4.0 (node#33960). При превышении выбрасывается ошибка «Cannot create a string longer than…», а не ERR_FS_FILE_TOO_LARGE. Значение buffer.constants.MAX_LENGTH представляет собой максимальный размер выделяемого Buffer и является отдельным ограничением; оно не ограничивает readFile, который даёт сбой при 2 ГиБ независимо от него. Практический вывод: не рассуждайте об ограничениях readFile исходя из предела Buffer — ограничение libuv в 2 ГиБ срабатывает первым.
Потоки: createReadStream, createWriteStream и pipeline
Потоки обрабатывают данные файла по частям вместо буферизации всего файла в памяти, что делает их правильным инструментом для больших файлов или файлов неизвестного размера. createReadStream и createWriteStream создают читаемые и записываемые потоки; соединяйте их с помощью pipeline из node:stream/promises, стабильного с Node 15. pipeline() соединяет читаемый поток с записываемым и автоматически управляет распространением ошибок и освобождением ресурсов. Хотя pipe() уже управляет обратным давлением, pipeline() обеспечивает более безопасную обработку ошибок и освобождение ресурсов.
import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
// Копирует файл любого размера с постоянным расходом памяти
await pipeline(
createReadStream('huge-input.log'),
createWriteStream('huge-output.log'),
)
Поскольку pipeline() возвращает промис, который отклоняется при любой ошибке потока и уничтожает потоки при сбое, одного блока try/catch достаточно — не нужно вручную подключать обработчики событий 'error'.
Обратное давление (backpressure) — это механизм, предотвращающий переполнение медленного записывающего потока быстрым читающим: когда внутренний буфер записываемого потока заполняется, читаемый поток приостанавливается до его освобождения. Пороговое значение буфера — это highWaterMark. Потоки чтения файлов по умолчанию имеют highWaterMark в 64 КиБ, в отличие от значения по умолчанию в 16 КиБ у обычного stream.Readable; документация fs явно указывает значение 64 КиБ как стандартное для файловых потоков. Настраивайте его, когда профилирование показывает, что это помогает:
import { createReadStream } from 'node:fs'
const stream = createReadStream('data.bin', { highWaterMark: 128 * 1024 })
Для форматов с разделителями строк, таких как CSV, пропускайте поток чтения через парсер, например csv-parser, вместо ручного разбора.
Файловые дескрипторы: побайтовый и позиционный доступ
Используйте файловый дескриптор из open(), когда вам нужно читать или записывать конкретные диапазоны байт по конкретным смещениям — то, что readFile и потоки не предоставляют. FileHandle является низкоуровневым ресурсом: вы самостоятельно управляете буфером и позицией, и всегда должны освобождать его с помощью close() в блоке finally, иначе дескриптор утечёт (а достаточное количество утечек приведёт к EMFILE).
import { open } from 'node:fs/promises'
async function readInChunks(path) {
let handle
try {
handle = await open(path, 'r')
const chunkSize = 64 * 1024
const buffer = Buffer.alloc(chunkSize)
let position = 0
while (true) {
const { bytesRead } = await handle.read(buffer, 0, chunkSize, position)
if (bytesRead === 0) break
// обработка buffer.subarray(0, bytesRead)
position += bytesRead
}
} finally {
await handle?.close()
}
}
handle.read(buffer, offset, length, position) заполняет buffer начиная с заданной position в файле и сообщает bytesRead; ручное отслеживание position и делает позиционный доступ возможным. Для большинства задач копирования и преобразования потоки являются лучшим инструментом — файловые дескрипторы оправдывают свою многословность только тогда, когда вам действительно нужен побайтовый контроль.
AbortSignal поддерживается readFile и fs.watch, поэтому медленное чтение можно отменить — например, при истечении времени ожидания запроса. Отменённое чтение отклоняется с ошибкой, у которой name равно 'AbortError':
import { readFile } from 'node:fs/promises'
const controller = new AbortController()
setTimeout(() => controller.abort(), 1000)
try {
await readFile('slow-source.bin', { signal: controller.signal })
} catch (error) {
if (error.name === 'AbortError') console.error('read cancelled')
else throw error
}
Директории и пути
Создавайте вложенные директории с помощью mkdir({ recursive: true }) (не выдаёт ошибку, если они уже существуют, аналогично mkdir -p), перечисляйте содержимое с помощью readdir({ withFileTypes: true }) для получения объектов Dirent, и удаляйте дерево директорий с помощью rm({ recursive: true }) — всё из того же модуля node:fs/promises. Составляйте пути с помощью path.join, чтобы разделители были корректными в разных операционных системах.
import { mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
await mkdir('output/reports', { recursive: true })
const entries = await readdir('output', { withFileTypes: true })
for (const entry of entries) {
const full = join('output', entry.name)
if (entry.isFile()) console.log('file:', full)
else if (entry.isDirectory()) console.log('dir: ', full)
}
await rm('output/reports', { recursive: true, force: true })
Перебирайте массив с помощью for...of, а не for...in — for...in по массиву возвращает строковые индексы, а не значения, что является распространённой ошибкой в устаревших руководствах. rm (с recursive: true) — это современный способ удаления деревьев директорий; rmdir с опцией recursive является устаревшим.
Для обращения к файлам относительно текущего модуля, а не рабочей директории процесса, используйте import.meta.dirname. Доступный с Node 20.11.0 (import.meta.dirname), он предоставляет ES-модулям ту же ссылку на текущую директорию, что __dirname давал в CommonJS:
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const config = await readFile(join(import.meta.dirname, 'config.json'), 'utf8')
Функции управления правами и владельцем (chmod, chown) и функции для работы со ссылками (symlink, link) также присутствуют в API промисов, но они применимы к Unix-подобным системам и ведут себя непредсказуемо или выдают ошибки в Windows; рассматривайте их как платформозависимые.
Безопасность: предотвращение обхода путей при пользовательском вводе
Никогда не передавайте пользовательские строки напрямую в readFile, writeFile или любую другую функцию fs. Нормализуйте путь с помощью path.resolve и убедитесь, что он начинается с вашей предназначенной базовой директории, прежде чем продолжать — необработанный ввод вида ../../../etc/passwd иначе позволит выйти за пределы любого относительного пути, который вы намеревались ограничить. Наивный join(baseDir, userInput) не защищает вас, поскольку сегменты .. разрешаются вверх по дереву.
import { resolve, sep } from 'node:path'
import { readFile } from 'node:fs/promises'
const baseDir = resolve('uploads')
async function readUserFile(userPath) {
const target = resolve(baseDir, userPath)
if (target !== baseDir && !target.startsWith(baseDir + sep)) {
throw new Error('path traversal blocked')
}
return readFile(target, 'utf8')
}
Разрешение пути с последующей проверкой префикса относительно baseDir + sep ограничивает запрос внутри разрешённой директории независимо от количества сегментов .. во входных данных. Защита + sep предотвращает прохождение проверки для соседних директорий вида uploads-private при простом startsWith('uploads').
Заключение
Для нового кода на Node.js начинайте с node:fs/promises и async/await, переходите на потоки с pipeline(), когда файлы превышают несколько сотен мегабайт или имеют неизвестный размер, и опускайтесь до файловых дескрипторов только тогда, когда вам нужен побайтовый позиционный доступ. Подбирайте инструмент под конкретное ограничение — ограничение памяти, параллелизм и ненадёжный ввод являются тремя наиболее болезненными факторами — и обращайтесь к официальной документации fs, когда вам нужен полный перечень параметров. Следующий шаг — проверить существующий код с колбэками или синхронными вызовами fs в ваших серверах и перенести его на API промисов.
Часто задаваемые вопросы
В чём разница между fs и fs/promises в Node.js?
Оба обращаются к одним и тем же файловым операциям, но предоставляют разные интерфейсы. Базовый модуль node:fs предоставляет синхронные методы (readFileSync) и асинхронные методы на основе колбэков (readFile с аргументом-колбэком), тогда как node:fs/promises предоставляет версии тех же операций, возвращающие промисы и работающие с async/await. API промисов стал стабильным в Node 14.0.0 и является рекомендуемым стандартом для нового кода, поскольку избегает вложенности колбэков и никогда не блокирует цикл событий.
Можно ли использовать require с fs/promises или нужны ES-модули?
Можно использовать оба варианта. В ES-модулях пишите import { readFile } from 'node:fs/promises'. В CommonJS пишите const { readFile } = require('node:fs/promises'). Сам API промисов не требует ESM; только await на верхнем уровне модуля и import.meta.dirname нуждаются в контексте ES-модуля. Префикс node: работает в обоих форматах и указывает, что импорт является встроенным модулем Node.js, который не может быть перекрыт никаким npm-пакетом.
Почему readFile выбрасывает ERR_FS_FILE_TOO_LARGE раньше, чем достигает ограничения размера Buffer?
ERR_FS_FILE_TOO_LARGE — это фиксированное ограничение ввода-вывода libuv в 2 ГиБ на одну операцию чтения, не зависящее от ограничения выделения Buffer. Файл чуть больше 2 ГиБ даёт сбой, даже если buffer.constants.MAX_LENGTH значительно больше, потому что базовый путь системного вызова чтения принудительно применяет ограничение в 2 ГиБ независимо от того, сколько памяти мог бы занять Buffer. Для обработки больших файлов используйте потоки с pipeline вместо буферизации всего файла.
Как прочитать часть файла по конкретному байтовому смещению?
Откройте файл с помощью open() из node:fs/promises, чтобы получить FileHandle, затем вызовите handle.read(buffer, offset, length, position), который заполняет buffer начиная с заданной позиции в файле и сообщает bytesRead. Отслеживайте position вручную между операциями чтения для перемещения по файлу. Всегда освобождайте дескриптор с помощью handle.close() в блоке finally, иначе дескриптор утечёт, а достаточное количество утечек приведёт к EMFILE. Обычный readFile и потоки не предоставляют позиционного доступа.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k