前端学习笔记 · 第 122 天:目录操作与递归遍历——mkdir、readdir 与批量处理脚本
- 2026-11-07 13:14:21
前端学习笔记 · 第 122 天:目录操作与递归遍历——mkdir、readdir 与批量处理脚本
Day 122 · 第五阶段 · Node.js 与后端开发
目录操作与文件操作的差别在于范围:rename 作用在整棵子树,rm 需要显式开启递归,遍历时还要处理符号链接与读不到的目录。把这几处边界定清楚,批量处理脚本才不会在中途停下。
一、问题现场
四个现象:
想建 logs/2026/11,父目录还不存在,mkdir直接报ENOENT。用 readdir拿到一串名字,为了判断类型又对每个名字调用一次stat,目录稍大就变慢。删除一个包含文件的目录, rmdir报ENOTEMPTY,换成递归删除又怕路径写错。递归遍历时跟着符号链接走进同一个目录,文件被统计两次,甚至停不下来。
前两个属于创建与读取的粒度,后两个属于移动、删除与遍历的边界。
二、创建目录
mkdir 的默认语义是「建一层」,父目录必须已经存在。要在缺失的父目录下建多级目录,需要开启 recursive:
import { mkdir } from "node:fs/promises";await mkdir("logs/2026/11", { recursive: true }); // 缺失的三层一次补齐实测一条命令补齐三层,返回值为最先创建的那一层目录;同一路径再次调用时返回 undefined,因为没有任何一层是新建的。不带 recursive 时,父目录缺失报 ENOENT,目标已存在报 EEXIST。
存在性检查有两个入口:access 成功即表示可访问,缺失路径抛出 ENOENT;existsSync 直接返回布尔值,不抛错。
import { access } from "node:fs/promises";try { await access("logs/2026/11"); console.log("目录可访问");} catch (error) { console.log(error.code); // ENOENT}access 检查的是「能否访问」,不只是「是否存在」。权限不足时报 EACCES,与「不存在」是两种不同的情况。需要区分时看错误码,不要一律当成缺失。
三、读取目录
readdir 默认返回名称数组,不含路径:
import { readdir } from "node:fs/promises";const names = await readdir("workspace");console.log(names); // ["app.js", "assets", "notes.md", "src"]要判断类型,需要再调用 stat 或 lstat。目录条目多时,这一步就是成倍的往返。加上 withFileTypes 后,返回值变成 Dirent 对象数组,类型信息随目录一起取回:
const entries = await readdir("workspace", { withFileTypes: true });for (const entry of entries) { if (entry.isDirectory()) console.log("目录", entry.name); else if (entry.isFile()) console.log("文件", entry.name); else if (entry.isSymbolicLink()) console.log("链接", entry.name);}Dirent 的常用字段有 name、parentPath,以及 isFile()、isDirectory()、isSymbolicLink() 三个判断方法。类型来自目录项本身,不需要额外查询;也正因为如此,它不会跟随符号链接去判断目标类型。实测一个指向目录的 junction,isSymbolicLink() 为 true,isDirectory() 为 false。
返回顺序由文件系统决定,代码不应依赖。实测同一目录返回 app.js、assets、notes.md、src,换一个文件系统可能不同。需要稳定顺序时,先对 entries 排序。
目录很大、或者只需要前几条时,用 opendir 逐条读取:
import { opendir } from "node:fs/promises";const dir = await opendir("workspace");for await (const entry of dir) { console.log(entry.name, entry.isDirectory());}opendir 返回一个异步可迭代对象,每次只取一个目录项,内存占用不随条目数量增长。提前 break 时,需要调用 dir.close() 释放句柄。
四、重命名与移动
rename 的第一个参数是原路径,第二个是新路径。作用在目录上时,移动的是整棵子树,原路径随即消失:
import { rename } from "node:fs/promises";await rename("workspace/docs/draft", "workspace/docs/final");两条边界需要记住:
目标已存在且是非空目录时,Windows 报 EPERM,不会合并两个目录。跨磁盘移动报 EXDEV,因为rename只改目录项,不搬运数据。
EXDEV 的处理方式是先复制再删除源,cp 与 rm 各做一半。同一磁盘内的移动不涉及数据拷贝,目录多大都很快。
五、删除目录
rmdir 只删除空目录。目录里有文件时报告 ENOTEMPTY,缺失目录报 ENOENT。要删整棵子树,用 rm 并开启递归:
import { rm } from "node:fs/promises";await rm("workspace/old", { recursive: true, force: true });recursive 决定是否连同内容一起删除,force 决定路径缺失时是否忽略错误。实测缺失路径在无 force 时报 ENOENT,带 force 时正常返回。
删除是不可逆操作,路径必须先归一化再检查:
import path from "node:path";const target = path.resolve("workspace/old");if (target === path.parse(target).root) throw new Error("拒绝删除磁盘根目录");path.parse(target).root 是当前磁盘的根,例如 D:\。把目标解析成绝对路径后再比较,可以挡住 .. 拼出来的意外位置。rmdir 的 recursive 选项已废弃,递归删除统一用 rm。
六、递归遍历
目录树是一棵树,遍历就是「读当前目录、逐个分流、遇到子目录再进一层」。用 withFileTypes 可以在分流时省掉 stat:
import { readdir } from "node:fs/promises";import path from "node:path";async function walk(dir) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isSymbolicLink()) continue; // 跳过链接,避免回路 if (entry.isDirectory()) await walk(full); // 子目录再进一层 else console.log(full); // 文件交给调用方处理 }}两个边界决定遍历能否跑完:
符号链接可能指向祖先目录,跟随会让同一棵子树被反复进入。目录项的 isSymbolicLink()为true时直接跳过。某个子目录可能因为权限或删除而读不到。捕获错误、记录路径与错误码,然后继续处理兄弟目录,比让整次遍历中断更合适。
需要限制范围时,在进入子目录前按名字排除,例如 node_modules 与 .git。排除发生在递归之前,整棵子树都不会被打开。
七、实战示例:目录扫描脚本
把前面的规则收进一个脚本:接收目标目录与报告路径,递归遍历,按扩展名累计文件数与字节数,最后写出报告。符号链接跳过,node_modules 与 .git 不进入,读不到的目录记入失败列表。
// scan.mjs —— 递归遍历目录,按扩展名统计文件数与字节数import { readdir, stat, writeFile } from "node:fs/promises";import path from "node:path";const [target = ".", outFile = "scan-report.txt"] = process.argv.slice(2);const root = path.resolve(target);const out = path.resolve(outFile);const skipDirs = new Set(["node_modules", ".git"]);const stats = new Map();const failures = [];async function walk(dir) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { failures.push(`${dir}(${error.code})`); return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (full === out || entry.isSymbolicLink()) continue; if (entry.isDirectory()) { if (!skipDirs.has(entry.name)) await walk(full); continue; } try { const { size } = await stat(full); const ext = path.extname(entry.name).toLowerCase() || "无扩展名"; const row = stats.get(ext) ?? { files: 0, bytes: 0 }; row.files += 1; row.bytes += size; stats.set(ext, row); } catch (error) { failures.push(`${full}(${error.code})`); } }}await walk(root);const rows = [...stats].sort((a, b) => b[1].bytes - a[1].bytes || a[0].localeCompare(b[0]));for (const [ext, row] of rows) { console.log(`${ext} ${row.files} 个 ${row.bytes} 字节`);}for (const line of failures) console.log(`跳过 ${line}`);await writeFile(out, rows.map(([ext, row]) => `${ext}\t${row.files}\t${row.bytes}`).join("\n") + "\n", "utf8");console.log(`报告写入 ${path.basename(out)}`);在一棵包含 src、assets、node_modules 与一个 junction 的目录上运行:
$ node scan.mjs workspace report.txt.js 3 个 51 字节.vue 1 个 29 字节.md 1 个 15 字节无扩展名 1 个 13 字节.svg 1 个 11 字节.png 1 个 7 字节报告写入 report.txtnode_modules 下的文件没有出现在结果里,junction 也没有被进入。三个 .js 文件分别来自根目录、src 与 src/pages,字节数相加为 51。报告文件与屏幕输出内容一致,每行是扩展名、文件数、字节数三列。
脚本先把报告路径解析成绝对路径,遍历时用 full === out 排除自身。即使报告写在目标目录内部,第二次运行也不会把自己统计进去,两次输出完全相同。
目标目录不存在时,脚本不中断:读目录失败记入失败列表,打印一行 跳过 …(ENOENT),报告仍照常写出。这类「局部失败」比「整次退出」更适合批量任务。
常见问题
Q1:mkdir 的 recursive 返回值有什么用?
返回本次最先创建的那一层目录,目录已存在时返回 undefined。需要知道新建了哪一层、或据此打印日志时使用;只关心最终目录是否存在时,忽略返回值即可。
Q2:readdir 与 opendir 如何选择?
一次要拿到全部条目、并做排序或统计时用 readdir;目录条目很多、只想处理前几条或边读边处理时用 opendir,它逐条返回,内存占用稳定。
Q3:Dirent 的类型判断与 stat 有什么区别?
Dirent 描述目录项本身,不跟随符号链接,一次读取即可得到类型;stat 会跟随链接到目标,拿到的是目标的完整信息。判断链接本身用 isSymbolicLink()。
Q4:rename 可以跨磁盘移动目录吗?
不可以。跨磁盘时目录项无法直接改指,报 EXDEV;处理方式是先 cp 复制整棵子树,再 rm 删除源目录。
Q5:rm 与 rmdir 的分工是什么?
rmdir 只删空目录,非空报 ENOTEMPTY;删除整棵子树用 rm 的 recursive 选项。rmdir 的 recursive 选项已废弃。
Q6:递归遍历如何避免重复与死循环?
进入子目录前检查 isSymbolicLink(),跳过链接目录;确需跟随链接时,记录已访问的真实路径(realpath)再去重,同时限制深度。
知识点总结
创建:
多级目录用
mkdir的recursive选项,父目录缺失时一次补齐重复创建:
目录已存在时报
EEXIST,开启recursive后返回undefined存在性检查:
access缺失路径报ENOENT,existsSync返回布尔值读取:
readdir默认只给名字,withFileTypes一并给出Dirent类型判断:
Dirent的isFile、isDirectory、isSymbolicLink不用再查stat顺序:
readdir的返回顺序由文件系统决定,需要稳定顺序时先排序移动:
rename在同一磁盘内移动整棵子树,跨磁盘报EXDEV删除:
空目录用
rmdir,非空目录用rm的recursive选项遍历:
递归进入前跳过符号链接,读不到的目录记录后继续
(本文为学习笔记整理)