Як працює модуль File System (fs) у Node.js?
Модуль fs (File System) - це вбудований API Node.js для читання, запису, зміни та відстеження файлів і каталогів на диску. Підтримує три стилі роботи: синхронний, на основі callback та Promise.
Теорія
TL;DR
- Уяви
fsяк ресторан: ти робиш замовлення (файлова операція), кухня (потоки libuv) готує, а стійка реєстрації (event loop) лишається вільною для інших гостей - Три стилі API: callback (застарілий), sync (блокуючий),
fs/promises(поточний стандарт) - Асинхронні операції
fsнеблокуючі, бо Node делегує дисковий I/O в пул потоків libuv, не займаючи головний потік fs/promisesдля серверного коду,readFileSyncтільки в скриптах, потоки для файлів більше ~50MB- Розмір пулу потоків libuv за замовчуванням - 4 (змінюється через
UV_THREADPOOL_SIZE)
Швидкий приклад
const fs = require('fs/promises');
async function demo() {
await fs.writeFile('test.txt', 'Hello Node'); // неблокуючий запис
const data = await fs.readFile('test.txt', 'utf8'); // читання як рядок
console.log(data); // Hello Node
await fs.unlink('test.txt'); // прибираємо за собою
}
demo().catch(console.error);
// event loop обробляє інші запити, поки libuv працює з дискомДва моменти варті уваги. Параметр 'utf8' важливий: без нього отримаєш Buffer, а не рядок. І весь ланцюг неблокуючий.
Як це працює всередині
Модуль fs звертається до системних викликів ОС (read(2) на Linux, ReadFile на Windows) через libuv. Коли викликаєш fs.readFile, Node не читає файл сам. Він передає завдання в пул потоків libuv (4 потоки за замовчуванням) і одразу повертає контроль event loop. Коли libuv завершує роботу, він додає подію завершення в чергу. V8 підхоплює її і запускає твій callback або резолвить Promise.
Синхронні версії пропускають пул потоків і звертаються до ОС напряму з головного потоку. Саме тому readFileSync блокує все інше до завершення. Збільшити розмір пулу можна так: UV_THREADPOOL_SIZE=16 node app.js. Це питання регулярно зустрічається на технічних інтерв'ю.
Три стилі API
На основі callback (оригінальний стиль, досі зустрічається в legacy-коді):
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});Синхронний (блокує event loop; підходить тільки для скриптів):
const data = fs.readFileSync('data.txt', 'utf8');
console.log(data);На основі Promise (поточний стандарт; використовуй fs/promises):
const fs = require('fs/promises');
const data = await fs.readFile('data.txt', 'utf8');Різниця у продуктивності між callback і Promise на практиці незначна. Promise додають мікрозавдання, але обробка помилок і ланцюжки набагато чистіші. Я переписав один сервіс логування з вкладених callback на fs/promises - код скоротився з 80 рядків до 25 з async/await.
Коли який стиль обирати
- CLI-скрипти та завантаження конфігурації при запуску:
readFileSyncнормально. Конкурентності немає, блокування допустиме. - Сервери Express або Fastify:
fs/promisesзasync/await. Неблокуючий код тримає пропускну здатність під навантаженням. - Файли більше ~50MB:
createReadStream. Завантаження 10GB файлу черезreadFileпризведе до падіння процесу. - Відстеження змін файлів:
fs.watchдля подій на рівні ОС (швидко, мало ресурсів),fs.watchFileдля polling (надійніше на мережевих файлових системах). - Legacy-кодова база: callback залишається нормальним. Рефакторити немає сенсу, якщо нема проблеми з вкладеністю.
Основні операції коротко
Читання та запис:
const fs = require('fs/promises');
const text = await fs.readFile('file.txt', 'utf8'); // читання як рядок
await fs.writeFile('output.txt', 'Hello'); // створити або перезаписати
await fs.appendFile('server.log', `${new Date()} - запит\n`); // додати без перезаписуКаталоги:
await fs.mkdir('logs/prod', { recursive: true }); // вкладені каталоги атомарно (Node 10.12+)
const entries = await fs.readdir('src', { withFileTypes: true });
entries.forEach(e => console.log(e.name, e.isDirectory() ? 'каталог' : 'файл'));Статистика, видалення, перейменування:
const stats = await fs.stat('file.txt');
console.log(stats.size, stats.mtime); // розмір у байтах, час зміни
await fs.unlink('old.txt'); // видалити файл
await fs.rm('old-folder', { recursive: true }); // видалити каталог
await fs.rename('old.txt', 'new.txt'); // перейменувати або перемістити
await fs.copyFile('source.txt', 'dest.txt'); // скопіюватиТипові помилки
Синхронні методи у веб-сервері:
// Неправильно - блокує ВСІ одночасні запити
app.get('/', (req, res) => {
const data = fs.readFileSync('large.json');
res.send(data);
});
// Правильно
app.get('/', async (req, res) => {
const data = await fs.readFile('large.json', 'utf8');
res.send(data);
});Один заблокований виклик readFileSync може опустити сервер з 1000 RPS до 10 RPS під навантаженням. Event loop ставить в чергу всі інші запити за ним.
Забутий параметр кодування в readFile:
const data = await fs.readFile('file.txt');
console.log(data); // <Buffer 48 65 6c 6c 6f> - не те, що очікувалось
// Виправлення: завжди передавай кодування для текстових файлів
const data = await fs.readFile('file.txt', 'utf8'); // повертає рядокЗа замовчуванням повертається Buffer. Це одне з найпопулярніших питань про fs на Stack Overflow.
Promise без await у циклі:
// Неправильно - forEach не чекає; записи виконуються в непередбаченому порядку
files.forEach(async (file) => {
await fs.writeFile(file, 'data');
});
// Правильно
for (const file of files) {
await fs.writeFile(file, 'data');
}Піраміда callback (типовий запах legacy-коду):
// Неправильно
fs.readFile('a.txt', (err, data) => {
fs.writeFile('b.txt', data, (err) => {
fs.unlink('a.txt', (err) => { /* і далі глибше */ });
});
});
// Виправлення: перейди на fs/promises
const data = await fs.readFile('a.txt', 'utf8');
await fs.writeFile('b.txt', data);
await fs.unlink('a.txt');Ігнорування race condition при mkdir:
// Без { recursive: true } два конкурентних процеси перевіряють "каталог існує?"
// і обидва намагаються створити його → другий кидає EEXIST і падає
await fs.mkdir('logs', { recursive: true }); // атомарно з Node 10.12, безпечноДе зустрічається в реальних проектах
- Express/Fastify:
fs.appendFileу request middleware для логування (так само, як Morgan всередині) - Next.js:
fs.readdirSyncпри білді вgetStaticPathsдля формування списку сторінок із файлової системи - Webpack:
fs.readFileSyncдля читання маніфестів ресурсів і додавання хешів у назви файлів - PM2:
fs.writeFileSyncдля запису PID-файлів при управлінні процесами в кластері - NestJS:
fs.readFileдля завантаження.envабо конфігурації перед запуском застосунку
Для відстеження файлів у продакшені більшість команд використовує chokidar - обгортку над fs.watch, яка усуває крайові випадки на macOS і мережевих дисках.
Follow-up питання
Q: Який розмір пулу потоків libuv за замовчуванням і як його змінити?
A: 4 потоки. Задай UV_THREADPOOL_SIZE=16 node app.js (максимум 1024) для I/O-інтенсивних застосунків. Актуально для серверів завантаження файлів або пакетної обробки.
Q: В чому різниця між fs.watch та fs.watchFile?
A: fs.watch використовує події на рівні ОС (inotify на Linux, FSEvents на macOS): швидко і без зайвого навантаження. fs.watchFile працює через polling з інтервалами: повільніше, але надійніше на мережевих файлових системах.
Q: Як передати 10GB файл через відповідь Express без падіння процесу?
A: fs.createReadStream через pipe до res. Чанки йдуть клієнту по мірі читання, споживання пам'яті залишається стабільним. readFile завантажить весь файл одразу і процес впаде.
Q: Як у PM2-кластері писати в спільний лог-файл без пошкодження даних?
A: fs.appendFile є атомарним для невеликих записів на більшості ОС. Для критичних великих записів використовуй proper-lockfile або виділений процес-логер. Конкурентний writeFile в один файл призводить до перезапису даних.
Q: Чому fs.stat іноді повертає дані симлінку замість цільового файлу?
A: Не повертає. fs.stat завжди слідує за симлінком і повертає дані цілі. Це fs.lstat повертає метадані самого симлінку. Часто плутають у тестах, що перевіряють розмір файлів на симлінках.
Приклади
Неблокуюче логування запитів в Express-сервері
Цей патерн показує, як fs.appendFile тримає сервер відповідним під час запису логів на кожному запиті:
const express = require('express');
const fs = require('fs/promises');
const app = express();
app.post('/upload', async (req, res) => {
const logLine = `${new Date().toISOString()} - User uploaded file\n`;
await fs.appendFile('server.log', logLine, 'utf8');
// запис іде в потік libuv, event loop вільний для інших запитів
res.send('Logged');
});
app.listen(3000);
// server.log: "2026-04-14T22:00:00.000Z - User uploaded file"Event loop не блокується. Інші вхідні запити обробляються паралельно, поки libuv пише на диск.
ENOENT та конкурентне створення каталогів
Типова пастка у налаштуваннях з кількома процесами, наприклад у PM2. Два воркери намагаються створити один каталог при запуску:
const fs = require('fs/promises');
const path = require('path');
async function safeMkdirWrite(dir, filename) {
try {
await fs.mkdir(dir, { recursive: true }); // атомарно з Node 10.12
await fs.writeFile(path.join(dir, filename), 'data');
console.log('Записано');
} catch (err) {
if (err.code === 'EEXIST') {
console.log('Каталог вже існував, продовжуємо');
} else {
throw err; // непередбачена помилка, прокидаємо далі
}
}
}
safeMkdirWrite('logs/prod', 'app.log');
// Виведе: Записано
// Без { recursive: true }: конкурентні виклики кидають ENOTDIRОпція { recursive: true } робить операцію безпечною. Без неї другий конкурентний виклик кидає EEXIST і воркер падає.
Потокова передача великого файлу для економії пам'яті
const fs = require('fs');
function streamFile(filePath, destination) {
const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const writeStream = fs.createWriteStream(destination);
readStream.pipe(writeStream);
readStream.on('error', (err) => console.error('Помилка читання:', err.message));
writeStream.on('finish', () => console.log('Передачу завершено'));
}
streamFile('large-data.csv', 'output.csv');
// Споживання пам'яті стабільне незалежно від розміру файлу
// fs.readFile для 10GB файлу = падіння процесу через перевищення пам'ятіКлючова різниця: readFile виділяє пам'ять для всього файлу одразу. createReadStream тримає в пам'яті лише поточний чанк і відкидає його після відправки.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.