← Lập trình JavaScript nâng cao

Bài 5 · Vận dụng · 22 phút

Làm việc với tệp & thư mục

Biên soạn bởi Nguyễn Anh Tuấn

Làm việc với tệp & thư mục (file system) trong Node: module fs/promises và path, đọc/ghi tệp, duyệt thư mục đệ quy, theo dõi thay đổi (watch).

Backend nào rồi cũng đụng tệp: config, log, ảnh upload. Module node:fs lo việc đó từ 2009 tới nay, nên mang trên mình ba thế hệ API - cùng một việc, ba kiểu gọi:

ba-the-he.mjs - cùng một việc đọc tệp, ba đời API

import fs from "node:fs";                      // thế hệ 1 & 2: callback + sync
import { readFile } from "node:fs/promises";   // thế hệ 3: Promise - mặc định nên dùng

// 1) callback (2009) - lồng vài tầng là thành "kim tự tháp"
fs.readFile("ca.txt", "utf8", (err, data) => {
  if (err) throw err;
  // chỉ dùng được data ở TRONG này
});

// 2) sync - CHẶN cả tiến trình đến khi đọc xong
const a = fs.readFileSync("ca.txt", "utf8");

// 3) Promise - await được, lỗi bắt bằng try/catch quen thuộc
const b = await readFile("ca.txt", "utf8");

Ở bài Bản chất: event loop & libuv bạn đã biết hậu trường: đọc tệp là việc libuv giao cho threadpool làm hộ - xong việc, callback (hoặc Promise) mới được xếp lịch chạy. Vì thế bản async KHÔNG chặn luồng chính, còn readFileSync bắt cả event loop đứng đợi. Từ giờ ta viết bằng fs/promises:

doc-ghi.mjs - ghi rồi đọc lại, mỗi việc một dòng await

import { readFile, writeFile } from "node:fs/promises";

await writeFile("ca.txt", "Hôm nay: 3 con cá 🐟", "utf8");   // tạo mới (hoặc đè nếu đã có)
const noiDung = await readFile("ca.txt", "utf8");            // đọc CẢ tệp vào một chuỗi
console.log(noiDung);

Kết quả khi chạy

Hôm nay: 3 con cá 🐟

Sync không “xấu” - chỉ hay bị đặt sai chỗ

Script CLI ngắn chạy một mạch rồi thoát (tool build, script dọn dẹp) dùng readFileSync hoàn toàn ổn - code phẳng, dễ đọc, không ai phải chờ. Trong server thì khác: MỘT lần sync trên tệp lớn là mọi request đứng hình theo. Quy tắc: server → fs/promises; script chạy-rồi-thoát → sync nếu thấy gọn hơn.
  • Ba thế hệ của fs: callback (cũ) → sync (chặn) → fs/promises (mặc định nên dùng).
  • fs đi qua threadpool của libuv: bản async không chặn luồng chính, bản sync chặn cả event loop.
  • readFile/writeFile với "utf8" làm việc thẳng bằng chuỗi - đủ cho tệp văn bản cỡ vừa.

Đường dẫn trông như chuỗi, nhưng đừng nối chuỗi tay: separator mỗi hệ một kiểu - POSIX (Linux/macOS) dùng /, Windows dùng \. Module node:path ghép/tách/chuẩn hoá đúng kiểu của hệ đang chạy - và nó chỉ xử lý chuỗi, không đụng vào đĩa:

duong-dan.mjs - chạy từ thư mục /tmp/meo-fs trên máy thử; dòng cuối trên máy mèo con sẽ khác

import path from "node:path";

console.log(path.join("nhat-ky-meo", "2026", "ca.txt"));   // nối bằng separator đúng của hệ
console.log(path.basename("nhat-ky-meo/2026/ca.txt"));     // tên tệp ở cuối đường dẫn
console.log(path.extname("nhat-ky-meo/2026/ca.txt"));      // phần mở rộng
console.log(path.resolve("ca.txt"));                       // tuyệt đối hoá - tính theo CWD

Kết quả khi chạy

nhat-ky-meo/2026/ca.txt
ca.txt
.txt
/tmp/meo-fs/ca.txt

Dòng cuối lộ ra một khái niệm quan trọng: mọi đường dẫn tương đối được tính theo CWD (current working directory) - thư mục bạn đứng khi gõ lệnh node, không phải nơi đặt tệp code. Bẫy này bạn từng chạm ở bài Đa luồng & đa tiến trình Node.js: new Worker("./fib-worker.js") phải chạy từ đúng thư mục mới tìm thấy tệp. Muốn mốc tính là vị trí tệp code, ESM cho bạn:

vi-tri.mjs - tệp đặt tại /tmp/meo-fs trên máy thử; máy mèo con sẽ in đường dẫn của máy mình

import { fileURLToPath } from "node:url";
import path from "node:path";

console.log(import.meta.url);                                // vị trí tệp này, dạng URL file://
console.log(import.meta.dirname);                            // Node ≥ 20.11: có sẵn luôn
console.log(path.dirname(fileURLToPath(import.meta.url)));   // cách "cổ điển" - kết quả y hệt

Kết quả khi chạy

file:///tmp/meo-fs/vi-tri.mjs
/tmp/meo-fs
/tmp/meo-fs
  • path.join/resolve/basename/extname xử lý CHUỖI đường dẫn - không đụng đĩa, không kiểm tra tệp có thật.
  • Đường dẫn tương đối tính theo CWD (nơi gõ lệnh); vị trí tệp code lấy từ import.meta.dirname.
  • ESM không có __dirname của CommonJS - import.meta.dirname (Node ≥ 20.11) là bản thay thế chính thức.

Sang chuyện thư mục. mkdir với { recursive: true } tạo cả chuỗi thư mục cha còn thiếu (và không lỗi nếu đã có) - dựng cây nhật ký cho mèo con:

dung-cay.mjs - cây nhật ký: nhat-ky-meo/ca.txt + nhat-ky-meo/2026/(2 tệp tháng)

import { mkdir, writeFile } from "node:fs/promises";

await mkdir("nhat-ky-meo/2026", { recursive: true });   // tạo CẢ chuỗi thư mục cha nếu thiếu
await writeFile("nhat-ky-meo/ca.txt", "tổng: 38 con cá\n");
await writeFile("nhat-ky-meo/2026/thang-01.txt", "tháng 1: ăn 20 con cá\n");
await writeFile("nhat-ky-meo/2026/thang-02.txt", "tháng 2: ăn 18 con cá\n");

Liệt kê bằng readdir. Thêm { withFileTypes: true } để mỗi mục trả về là một Dirent - biết ngay đâu là tệp, đâu là thư mục con, khỏi gọi thêm stat; còn cần con số (kích thước, thời điểm sửa cuối mtime…) thì stat trả lời:

liet-ke.mjs - Dirent cho biết loại ngay khi liệt kê

import { readdir, stat } from "node:fs/promises";

const muc = await readdir("nhat-ky-meo", { withFileTypes: true });
for (const m of muc.sort((a, b) => a.name.localeCompare(b.name))) {
  console.log(m.isDirectory() ? "📁" : "📄", m.name);   // readdir không hứa thứ tự - tự sort
}

const thongTin = await stat("nhat-ky-meo/ca.txt");      // thongTin.mtime là Date lần sửa cuối
console.log("ca.txt nặng", thongTin.size, "byte");

Kết quả khi chạy

📁 2026
📄 ca.txt
ca.txt nặng 19 byte

Ghép hai mảnh là duyệt được cả cây: gặp tệp thì cộng kích thước, gặp thư mục thì đệ quy xuống - đúng kiểu hàm đệ quy mèo con đã luyện ở khoá Cơ bản:

tong-dung-luong.mjs - 69 byte là tổng đúng của 3 tệp vừa dựng (19 + 25 + 25, đếm theo byte UTF-8)

import { readdir, stat } from "node:fs/promises";
import path from "node:path";

async function tongDungLuong(thuMuc) {
  let tong = 0;
  for (const m of await readdir(thuMuc, { withFileTypes: true })) {
    const duongDan = path.join(thuMuc, m.name);
    if (m.isDirectory()) {
      tong += await tongDungLuong(duongDan);   // đệ quy xuống thư mục con
    } else {
      tong += (await stat(duongDan)).size;     // tệp thì cộng kích thước
    }
  }
  return tong;
}

console.log(await tongDungLuong("nhat-ky-meo"), "byte");

Kết quả khi chạy

69 byte
  • mkdir { recursive: true }: tạo cả chuỗi thư mục, chạy lại không lỗi - nên là mặc định.
  • readdir { withFileTypes: true } trả Dirent: phân biệt tệp/thư mục ngay, đỡ một lượt stat.
  • stat trả kích thước (size), thời điểm sửa cuối (mtime)… - duyệt cây = readdir + đệ quy + stat.

Bộ thao tác hằng ngày gói trong bốn hàm - chú ý rename kiêm luôn việc di chuyển, vì với hệ tệp, “chuyển chỗ” chỉ là đổi đường dẫn:

thao-tac.mjs - sao chép, di chuyển, ghi nối, xoá

import { copyFile, rename, rm, appendFile } from "node:fs/promises";

await copyFile("nhat-ky-meo/ca.txt", "nhat-ky-meo/ca-sao-luu.txt");        // sao chép
await rename("nhat-ky-meo/ca-sao-luu.txt", "nhat-ky-meo/2026/ca-cu.txt");  // "di chuyển" = đổi đường dẫn
await appendFile("nhat-ky-meo/meo.log", "đã sao lưu xong\n");              // nối vào cuối - hợp cho log
await rm("nhat-ky-meo/2026/ca-cu.txt", { force: true });                   // force: không lỗi nếu tệp không có

// Xoá cả thư mục lẫn mọi thứ bên trong (cẩn thận!):
// await rm("nhat-ky-meo", { recursive: true, force: true });

Giờ tới mẹo đáng tiền nhất bài. writeFile không phải phép màu nguyên khối: nó mở tệp, xoá rỗng, rồi ghi dần từng khúc - sập nguồn hay crash giữa chừng là tệp config/JSON hỏng. Mẹo kinh điển: ghi trọn ra tệp tạm rồi rename đè - rename trên cùng filesystem là atomic: ai mở tệp lúc nào cũng thấy HOẶC bản cũ trọn vẹn HOẶC bản mới trọn vẹn, không bao giờ thấy bản dở:

ghi-an-toan.mjs - mẹo tệp tạm + rename đè

import { writeFile, rename, readFile } from "node:fs/promises";

async function ghiAnToan(tep, noiDung) {
  const tam = tep + ".tmp";
  await writeFile(tam, noiDung, "utf8");   // 1) ghi HẾT ra tệp tạm trước
  await rename(tam, tep);                  // 2) rename đè - atomic trên cùng filesystem
}

await ghiAnToan("diem-so.json", JSON.stringify({ meo: "Mướp", ca: 38 }));
console.log(await readFile("diem-so.json", "utf8"));

Kết quả khi chạy

{"meo":"Mướp","ca":38}

Trung thực: atomic có điều kiện

Lời hứa atomic chỉ đúng khi tệp tạm và tệp đích nằm cùng filesystem: rename vắt qua hai filesystem sẽ ném lỗi EXDEV - Node không tự đổi sang chép-rồi-xoá như lệnh mv. Vậy nên đặt tệp tạm cạnh tệp đích, đừng đặt vào /tmp của hệ thống.
  • copyFile sao chép; rename vừa đổi tên vừa di chuyển; rm { recursive, force } xoá cả cây.
  • appendFile nối vào cuối tệp - đúng việc cho ghi log.
  • Ghi an toàn: ghi trọn ra tệp tạm rồi rename đè - atomic trên cùng filesystem, không bao giờ lộ bản dở.

Dev server tự reload khi bạn lưu tệp, build tool tự dịch lại - đều là watch. Node có sẵn fs.watch: callback nhận loại sự kiện ("change" - nội dung đổi; "rename" - tạo/xoá/đổi tên) kèm tên tệp; muốn dừng, đưa vào signal của một AbortController - quen mặt từ fetch:

theo-doi.mjs - MỘT lần writeFile, nhận HAI sự kiện change (máy Linux chạy thử - cả 5 lần chạy đều vậy)

import { watch } from "node:fs";
import { writeFile } from "node:fs/promises";
import { setTimeout as cho } from "node:timers/promises";

const ac = new AbortController();
watch("nhat-ky-meo", { signal: ac.signal }, (loaiSuKien, tenTep) => {
  console.log("sự kiện:", loaiSuKien, "→", tenTep);
});

await cho(100);                                              // chờ watcher sẵn sàng
await writeFile("nhat-ky-meo/ca.txt", "thêm 1 con cá 🐟");   // sửa tệp trong lúc đang theo dõi
await cho(300);
ac.abort();                                                  // dừng theo dõi - không abort, tiến trình không thoát

Kết quả khi chạy

sự kiện: change → ca.txt
sự kiện: change → ca.txt

Một lần ghi mà hai sự kiện - không phải bug của máy này. fs.watch chỉ là lớp mỏng phủ lên cơ chế riêng của từng hệ điều hành (inotify trên Linux, FSEvents trên macOS, ReadDirectoryChangesW trên Windows): một cú ghi thực chất là mở + xoá rỗng + ghi, hệ báo thành mấy biến cố là tuỳ hệ - số lượng, loại, thậm chí tên tệp có được báo hay không, tài liệu Node dành hẳn mục “Caveats” thú nhận chuyện này. Code thật vì thế luôn debounce (gom sự kiện trong 50-100ms thành một lần xử lý); cần đáng tin hơn nữa thì dùng thư viện chokidar - bộ lọc quanh fs.watch mà các dev server lớn đều xài.

  • fs.watch theo dõi tệp/thư mục, dừng gọn bằng AbortController - không abort thì tiến trình treo mãi.
  • Sự kiện có thể TRÙNG LẶP và khác nhau giữa các hệ điều hành - khuyết tật nổi tiếng, phải debounce.
  • Sản phẩm thật cần theo dõi đáng tin: dùng chokidar thay vì tự vá fs.watch.

Tiếp theo: khi tệp KHÔNG vừa một cú readFile

Cả bài nay ta đọc tệp kiểu “cả cục” - ổn với config, nhật ký bé. Nhưng tệp 2GB thì RAM nào chịu nổi: phải đọc từng miếng, xử lý xong miếng nào bỏ miếng đó. Để hiểu cách Node đưa từng miếng cho bạn, cần nắm trước một mảnh nền của chính JavaScript: Generator & async iterator - bài kế của khoá. Hẹn mèo con ở đó.

Câu hỏi thường gặp

Hiếm. API Promise đã phủ gần hết nhu cầu; bản callback còn đó vì lịch sử (Node ra đời 2009, trước khi có Promise) và vì vài chỗ hiệu năng cực hạn - mỗi Promise tốn thêm một chút chi phí. Code mới cứ fs/promises mà viết: dễ đọc, lỗi bắt bằng try/catch quen thuộc, phối hợp tự nhiên với async/await.

Chỉ tiến trình Node đang chạy: trong lúc đọc, luồng chính không chạy được dòng JS nào khác - server không nhận thêm request, timer không nổ, callback không được gọi. Hệ điều hành và các chương trình khác vẫn chạy bình thường. Vì vậy script chạy-một-mạch-rồi-thoát dùng sync vô tư, còn server thì tránh.

Theo NƠI GÕ LỆNH - gọi là CWD (current working directory). Đứng ở thư mục khác chạy cùng một script là đọc trượt tệp ngay. Muốn “tính theo vị trí tệp code”, tự ghép: path.join(import.meta.dirname, "du-lieu.txt"). Quy tắc nhớ nhanh: CWD là chuyện của NGƯỜI CHẠY, import.meta.dirname là chuyện của TỆP CODE.

Vì separator mỗi hệ một kiểu: POSIX (Linux/macOS) dùng /, Windows dùng \. Chuỗi nối tay chạy được trên máy mèo con nhưng có thể hỏng trên máy bạn cùng lớp dùng hệ khác. path.join chọn separator đúng cho hệ đang chạy, lại còn dọn giúp các đoạn //, .. thừa.

Cách phổ biến là debounce: gom mọi sự kiện trong một khoảng ngắn (50-100ms) thành MỘT lần xử lý - dev server của các framework đều làm vậy. Cần đáng tin hơn nữa (theo dõi đệ quy ổn định trên mọi hệ, so sánh cả nội dung) thì dùng thư viện chokidar thay vì tự vá fs.watch.

Kỹ thuật là được nhưng đừng: readFile kéo CẢ tệp vào RAM một lúc, và Node còn chặn trần kích thước một Buffer. Tệp lớn phải đọc từng khúc rồi xử lý dần - đó là Stream, học ở bài Buffer & Stream, ngay sau khi nắm generator & async iterator ở bài kế.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Server Node đang phục vụ nhiều request - vì sao nên tránh readFileSync với tệp lớn?

  1. 1

    Nhật ký cá đầu tiên

    Mèo con viết script tạo thư mục nhat-ky-meo/, ghi tệp ca.txt với một dòng tuỳ thích, đọc lại và in ra - tất cả bằng fs/promises và await.

    Hoàn thành khi: Chạy 2 lần liên tiếp không lỗi (mkdirrecursive, writeFile đè bản cũ) và in đúng nội dung vừa ghi.

  2. 2

    Trượt chân vì CWD

    Đặt doc.mjs trong nhat-ky-meo/ đọc ./ca.txt theo đường dẫn tương đối. Chạy 1 lần từ trong thư mục đó, 1 lần từ thư mục cha (node nhat-ky-meo/doc.mjs). Sau đó sửa bằng import.meta.dirname cho chạy đúng từ mọi nơi.

    Hoàn thành khi: Bản đầu lỗi ENOENT khi đứng ở thư mục cha; bản sửa chạy đúng từ cả hai nơi.

  3. 3

    Đếm tệp theo đuôi

    Sửa hàm duyệt cây ở Bước 3 thành hàm đếm số tệp theo phần mở rộng (dùng path.extname), trả về object dạng {".txt": 5, ".mjs": 2}.

    Hoàn thành khi: Chạy trên một thư mục dự án thật; cộng các đuôi lại đúng bằng tổng số tệp.

  4. 4

    Top 3 tệp nặng nhất

    Viết hàm duyệt cây thu thập từng tệp kèm size (qua stat), sắp xếp giảm dần rồi in 3 tệp nặng nhất của một thư mục bất kỳ.

    Hoàn thành khi: Tổng size mọi tệp thu được khớp kết quả tongDungLuong chạy trên cùng thư mục.

  5. 5

    Ghi an toàn, log đầy đủ

    Dùng ghiAnToan ở Bước 4 để lưu một object điểm danh cá (JSON) mỗi lần chạy, đồng thời appendFile một dòng vào meo.log. Chạy 5 lần.

    Hoàn thành khi: Không còn tệp .tmp sót lại; diem-so.json luôn JSON.parse được; meo.log có đúng 5 dòng.

  6. 6

    Đo độ “ồn” của watch

    Watch thư mục nhat-ky-meo/ trong 10 giây (AbortController + setTimeout), trong lúc đó mèo con tự sửa tệp đúng 3 lần. Đếm số sự kiện nhận được; lặp lại thí nghiệm 3 lần.

    Hoàn thành khi: Số sự kiện thường ≥ số lần sửa và có thể khác nhau giữa các lần chạy - mèo con giải thích được vì sao cần debounce.