← fs.appendFile fsPromises.mkdtemp →

Node.js fs.readdir精讲:Dirent类型判断、递归选项的版本与目录遍历的工程决策

著
原创 2026-10-17 Node.js 已有人查阅

一、readdir返回的是“名字”,不是“路径”

fs.readdir的默认行为是读取目录下的条目名称,返回一个字符串数组。这些字符串不包含目录路径,只包含条目本身的名字。如果后续操作需要完整路径,必须用path.join拼接。这一点在初次使用时容易忽略,导致后续stat或readFile调用失败。

// 代码号学习编程:名字与路径的区别
const fs = require('node:fs');
const path = require('node:path');

fs.readdir('./logs', (err, names) => {
  if (err) {
    console.error('读取失败:', err.code);
    return;
  }
  // names 是 ['app.log', 'error.log'],不是完整路径
  names.forEach(name => {
    const fullPath = path.join('./logs', name);
    // 后续操作用 fullPath
  });
});

官方Node.js学习文档在示例中专门演示了用path.join(folderPath,fileName)获取完整路径的写法,原因正在于此。__dirname配合readdir使用时,拼接步骤同样不可省略。

二、withFileTypes:true的实质:从字符串到Dirent

默认返回字符串时,调用方无法仅凭名称判断一个条目是文件还是目录。常见的补救方式是对每个名字调用fs.stat或fs.lstat,但这意味着目录读取之后紧跟着N次额外的系统调用。withFileTypes:true将类型信息随目录读取一次性返回,每个条目是一个fs.Dirent对象,携带name字段以及isFile()、isDirectory()、isSymbolicLink()等方法。

// 代码号学习编程:Dirent 判断类型,避免额外 stat 调用
const fs = require('node:fs');

fs.readdir('./data', { withFileTypes: true }, (err, entries) => {
  if (err) throw err;

  const files = entries.filter(e => e.isFile()).map(e => e.name);
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);

  console.log('文件:', files);
  console.log('目录:', dirs);
});

Node.js中文文档确认了withFileTypes为true时数组填充的是fs.Dirent对象而非字符串。微软的Node.js培训材料也以同样的方式演示了isDirectory()的用法,并强调这是区分文件与目录的常规手段。

需要留意的是,Dirent的类型判断依赖底层readdir系统调用返回的d_type字段。在某些文件系统上(如部分网络挂载),d_type可能返回DT_UNKNOWN,此时isFile()等方法的结果可能不准确。如果类型判断的准确性对业务有实质影响,对可疑条目回退到fs.lstat是稳妥的做法。

三、排序:不是承诺,是实现细节

多个来源提到readdir的结果按字母数字顺序返回。微软的培训文档明确写了“结果按字母数字顺序返回”。但这个顺序不是Node.jsAPI的契约。POSIXreaddir本身不保证顺序,Node.js文档中也未将排序列为fs.readdir的承诺行为。

在项目中,如果业务逻辑依赖目录条目的顺序(例如按文件名序号处理分片文件),显式调用.sort()是必要的。依赖返回顺序而不排序的代码,在不同的文件系统、不同的操作系统、甚至同一系统的不同目录状态下,行为可能发生变化。

四、recursive选项的版本差异与已知

recursive选项在较新的Node.js版本中引入,允许一次调用递归读取嵌套目录。但这个选项的引入过程伴随着版本间的不一致,值得单独指出。

als-readdir这个兼容库的文档记录了版本差异的全貌:Node.jsv10到v16不支持recursive;v18到v22支持但Dirent的path字段行为有误;v23及以上中path字段被移除,保留parentPath。这意味着如果你的代码在v18写好后直接升级到v23,依赖dirent.path的部分可能静默失效。

更具体的问题出现在回调版本与Promise版本之间。GitHubissue#48640记录了这样一个现象:在同时使用recursive:true和withFileTypes:true时,fs.promises.readdir表现正常,但回调版本和同步版本可能产生Dirent{name:undefined,path:'...',[Symbol(type)]:undefined}这样的异常条目,并且会遗漏部分文件。该issue的讨论中确认了这些差异的存在。

这个问题的工程含义是:如果项目需要递归读取目录且必须兼容多个Node.js版本,优先使用fs.promises.readdir,或者不要依赖内建recursive选项,改用自己实现的递归遍历。自己实现递归虽然多几行代码,但行为在所有版本中一致,且便于在遇到符号链接循环时加入防护。我在一个静态资源扫描工具中曾直接使用recursive:true,在跨版本部署时遇到过条目遗漏,后来改为手写递归后问题消失。手写递归的另一个好处是可以控制在遍历过程中按需跳过某些目录(如node_modules),而内建选项没有提供过滤回调。

五、错误码:ENOTDIR才是readdir的专属信号

fs.readdir最常见的错误码有三个,它们的含义不同,处理方式也不同。

错误码 含义 触发场景
ENOENT 路径不存在 目录被删除、路径拼写错误
ENOTDIR 路径的某个组件存在但不是目录 传入了一个文件的路径,或路径中间某段是文件
EACCES 权限不足 目录没有读权限

ENOTDIR值得单独记住。Node.js的错误文档将其标注为“通常由fs.readdir引发”。当代码试图读取一个实际上是文件的路径时,ENOENT不会触发(路径存在),EACCES也不会触发(权限没问题),只有ENOTDIR会暴露问题所在。在日志中看到ENOTDIR时,排查方向应该直接指向“传入的路径是否是目录”这个判断,而不是去检查文件是否存在或权限配置。

六、readdir与opendir:大目录下的职责分工

fs.readdir将目录的所有条目读入内存后一次性返回数组。对于常规目录,这没有问题。但当目录包含数万甚至数十万个条目时,内存占用和事件循环的阻塞窗口都会变得可观。Node.js在引入recursive选项的PR讨论中,有人指出“如果只返回数组,进程可能在数小时后因数组分配耗尽内存而崩溃,且从未产生任何结果”。

fs.opendir返回一个fs.Dir对象,它以流式方式逐个产出目录条目,不需要将全部名称加载到内存。对于“遍历大目录并筛选部分文件”的场景,opendir的forawait...of循环比readdir的数组filter更节省内存。

// 代码号学习编程:大目录用 opendir 流式处理
const fs = require('node:fs');

async function scanLargeDir(dirPath) {
  const dir = await fs.promises.opendir(dirPath);
  for await (const dirent of dir) {
    if (dirent.isFile() && dirent.name.endsWith('.log')) {
      // 逐个处理,不持有全部条目
    }
  }
}

选择判断可以简化为:条目数量在可预期范围内(数百到数千),readdir的数组返回值便于后续filter/map链式操作,代码更紧凑;条目数量不可控或可能达到数万级别,opendir的流式模型避免了内存峰值和一次性I/O的延迟集中。

七、边界测试

// 代码号学习编程:readdir 的边界行为验证
const fs = require('node:fs');
const assert = require('node:assert');
const path = require('node:path');
const os = require('node:os');

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'readdir-test-'));

// 边界一:空目录返回空数组
fs.mkdirSync(path.join(tmp, 'empty'));
assert.deepStrictEqual(fs.readdirSync(path.join(tmp, 'empty')), []);

// 边界二:withFileTypes 区分文件与目录
fs.writeFileSync(path.join(tmp, 'file.txt'), '');
fs.mkdirSync(path.join(tmp, 'subdir'));
const entries = fs.readdirSync(tmp, { withFileTypes: true });
const fileEntry = entries.find(e => e.name === 'file.txt');
const dirEntry = entries.find(e => e.name === 'subdir');
assert(fileEntry.isFile());
assert(dirEntry.isDirectory());

// 边界三:对文件路径调用 readdir 抛 ENOTDIR
assert.throws(() => {
  fs.readdirSync(path.join(tmp, 'file.txt'));
}, (err) => err.code === 'ENOTDIR');

// 边界四:不存在的路径抛 ENOENT
assert.throws(() => {
  fs.readdirSync(path.join(tmp, 'no-such'));
}, (err) => err.code === 'ENOENT');

fs.rmSync(tmp, { recursive: true });

边界三和边界四的区分是实践中容易混淆的地方。两者都在“路径有问题”时触发,但指向的排查方向不同:ENOENT看“路径是否存在”,ENOTDIR看“路径是否指向了错误类型的目标”。

八、常见误区速查

误区一:readdir返回的是完整路径。返回的是条目名称,完整路径需要path.join拼接。

误区二:结果一定按字母顺序排列。多个来源描述了排序现象,但这不是API契约。需要顺序时显式.sort()。

误区三:recursive:true在所有版本、所有调用方式下行为一致。回调/同步版本在同时使用recursive和withFileTypes时存在已知的条目遗漏和undefined字段问题,Promise版本相对稳定。跨版本项目应优先使用Promise版本或手写递归。

误区四:withFileTypes的类型判断在任何文件系统上都准确。d_type为DT_UNKNOWN时类型方法可能返回不可靠结果,对关键判断应回退到lstat。

误区五:readdir适合任何规模的目录。数万级条目时数组的内存占用和一次性I/O的延迟集中需要纳入考量,opendir是流式替代方案。

九、练习与思考

练习一:实现一个listFilesByExt(dir,ext)函数,读取目录下所有指定扩展名的文件,返回完整路径数组。要求使用withFileTypes:true区分文件与目录,忽略子目录。用边界测试覆盖:空目录、包含子目录、包含符号链接、目录不存在。

练习二:分别用fs.readdir(配合withFileTypes)和fs.opendir读取一个包含5000个空文件的临时目录,记录两种方式的耗时和进程RSS变化。观察readdir的数组构造和opendir的逐项产出在内存上的差异。

思考题:在一个文件同步工具中,需要递归遍历源目录并对比目标目录。如果直接使用fs.readdir的recursive:true,在遇到包含符号链接循环的目录时会发生什么?如果改为手写递归,需要在代码中加入什么防护条件?这两种方案在“跳过指定目录”的需求下,实现成本有何不同?

十、延伸阅读与参考文献

Node.js 官方文档·Working withfolders
https://nodejs.org/learn/manipulating-files/working-with-folders-in-nodejs
官方学习材料中关于readdir的用法演示,包括完整路径拼接和文件/目录过滤的示例。作为入门参考,它清晰地展示了最基本的调用方式。

Node.js 中文文档·类:fs.Dirent
https://url.nodejs.cn/api/v23/fs/fs_dirent.html
withFileTypes:true时返回Dirent对象的官方说明。isFile()、isDirectory()等方法的定义可在此查阅。

Node.js 中文文档·常见系统错误
https://url.nodejs.cn/api/v22/errors/2.html
ENOTDIR被标注为“通常由fs.readdir引发”的权威来源。区分ENOENT与ENOTDIR时以此为准。

GitHu bIssue#48640·fs.readdir的recursive选项文档不完整
https://github.com/nodejs/node/issues/48640
记录了回调/同步版本在recursive与withFileTypes同时使用时产生异常Dirent和遗漏条目的已知问题。在决定是否依赖内建recursive前值得阅读。

NPM·als-readdir
https://www.npmjs.com/package/als-readdir
系统整理了fs.readdir的recursive选项和Dirent字段在Node.jsv10到v23之间的版本差异。维护跨版本兼容代码时,这份对照表有实际参考价值。

Microsoft Learn·使用文件系统(Node.js)
https://learn.microsoft.com/es-es/training/modules/nodejs-files/2-file-system
以具体场景演示了readdir配合withFileTypes的用法,并展示了递归遍历的手写实现。示例结构清晰,适合作为练习的对照参考。

关于版本差异的说明:fs.readdir的基础签名在长期版本中保持稳定,但recursive选项的可用性和行为随版本变化显著。Node.jsv18起引入该选项,v18–v22的Dirent.path行为与v23+不同,回调版本与Promise版本在特定组合下存在已知缺陷。在需要兼容多个LTS版本的项目中,对recursive的使用应做版本检测或封装,而不是直接依赖运行时行为一致。withFileTypes在v10.10起可用,跨该版本边界时需要准备回退方案。

← fs.appendFile fsPromises.mkdtemp →
分享笔记 (共有 篇笔记)
验证码:
微信公众号