
最近在做 umi v2 迁移到 rsbuild + React Router v7 data mode (你没听错就是 7 年前的 umijs v2),需要写了一个 Node.js 脚本监听 config/routes.ts
自动生成 react-router
的 createHistoryRouter
。
Node.js 提供了两个 API 可以监听文件变化 fs.watchFile
和 fs.watch
,第一眼看上去貌似前者更合适,但是 Node.js 官方文档特意指出:
Using
fs.watch()
is more efficient thanfs.watchFile
andfs.unwatchFile
.fs.watch
should be used instead offs.watchFile
andfs.unwatchFile
when possible.
翻译一下就是:使用 fs.watch()
比 fs.watchFile
和 fs.unwatchFile
更高效。在可能的情况下,应尽量使用 fs.watch
替代 fs.watchFile
和 fs.unwatchFile
。
这是一个非常重要的结论,其核心原因在于两种方法的工作原理有本质区别:事件驱动(fs.watch
) vs. 轮询(fs.watchFile
)。
下面详细解释为什么 fs.watch()
更高效。
如果生产级别应该用 chokidar,我这里为了避免引入三方库而且不会有。
1. 核心机制:工作原理完全不同
fs.watchFile()
/ fs.unwatchFile()
(轮询模式)
- 工作原理: 这个函数会主动轮询 文件系统。这意味着它会在固定的时间间隔内,反复检查文件的元数据(如修改时间
mtime
、文件大小size
),即使文件没有任何变化。 - 轮询间隔: 你可以通过选项配置轮询频率(例如
{ interval: 2000 }
表示每2秒检查一次)。如果不配置,它会使用一个默认间隔(通常是5秒或更长)。 - 开销: 这种不间断的检查会持续消耗 CPU 计算周期、内存和磁盘 I/O 资源。这就像你不停地问文件系统:"文件变了吗?现在呢?现在呢?",非常低效。
fs.watch()
(事件驱动模式)
- 工作原理: 这个函数请求操作系统内核在文件变化时主动通知 Node.js 进程。它设置一个监听器后便进入等待状态。
- 底层机制: 它利用了操作系统提供的原生文件事件接口:
- Windows: 使用
ReadDirectoryChangesW
API。 - macOS: 使用
FSEvents
API。 - Linux: 使用
inotify
子系统。
- Windows: 使用
- 开销: 它只在设置监听器时消耗少量资源,之后仅在文件确实发生变化、收到操作系统通知后才消耗资源。这就像告诉操作系统:"帮我盯着这个文件,有变化时叫我一声就行。"
2. 效率对比一览表
对比维度 | fs.watch() (事件驱动) |
fs.watchFile() (轮询) |
为什么 fs.watch() 更高效 |
---|---|---|---|
CPU 使用率 | 极低。CPU 仅在事件触发和处理时被使用。 | 可能很高。CPU 在每个轮询间隔都会被使用,产生大量不必要的计算。 | 消除了空转(idle)检查的浪费。 |
磁盘 I/O | 极低。不直接读取磁盘,依赖操作系统信号。 | 高 。需要不断调用 stat() 来读取文件元数据,产生大量磁盘访问。 |
大幅减少了磁盘操作。 |
响应速度 | 近乎实时。操作系统在变化发生的那一刻就发出通知。 | 有延迟 。变化只能在下一个轮询间隔被检测到,可能有几秒延迟。 | 提供(近乎)实时的响应。 |
资源与扩展性 | 极佳。监视1000个文件的开销也相对较低,因为大部分是被动监听。 | 极差。监视1000个文件意味着1000个独立的轮询操作,会严重拖慢系统。 | 可以高效地监视大量文件。 |
对笔记本电脑电池的影响 | 低。 | 高。持续的活动会阻止CPU进入低功耗状态。 | 对移动设备更友好。 |
3. 具体例子
假设我们需要监听配置文件(config.ts
)的更改。
-
使用
fs.watchFile({ interval: 2000 })
:- 你的 Node.js 程序每 2 秒醒来一次。
- 向文件系统询问
config.ts
的当前状态。 - 将新状态与旧状态进行比较。
- 即使这个配置文件几周都没人动过,这个操作也会每分钟执行 30 次,每小时执行 1800 次。
-
使用
fs.watch()
:- 你的程序设置一个监听器,然后就去休眠了。
- 你修改并保存了
config.json
。 - 操作系统内核立刻向你的 Node.js 进程发送一个
'change'
事件。 - 你的事件处理函数被调用。
- 处理完后,程序再次进入休眠。整个过程中,只进行了 1 次操作。
两者在资源消耗上的差距是天壤之别!
注意事项:什么时候(极少情况下)仍可能需要 fs.watchFile
虽然 fs.watch()
更优,但它并非完美无缺。官方文档也指出它在不同平台上"一致性较差"。它的缺点和 fs.watchFile
的适用场景如下:
- 平台差异性: 事件的类型和可靠性因操作系统而异。例如,重命名操作在 Linux 和 Windows 上触发的事件可能不同。
- 并非100%可靠: 在极少数情况下,尤其是在网络驱动器(NFS, SMB)或虚拟文件系统上,可能会丢失某些事件。
- 可能不提供文件名: 在监听整个目录时,回调函数有时可能不提供具体是哪个文件发生了变更。
- 递归监听: 虽然现在支持(
{ recursive: true }
),但其行为在不同平台上可能很复杂。
在以下极端情况下,你才应该考虑 fs.watchFile
:
- 你需要极致的跨平台一致性,并且愿意为此承受性能损失和高延迟。
- 你正在使用的文件系统(如某些网络驱动器)已知对原生事件API支持很差。
- 你的应用逻辑本身就需要一个定期检查,而不是即时通知(这种情况非常罕见)。
chokidar
最后看看为什么推荐最稳的生产级别方案 chokidar。

以下是原文翻译
在2024年,选择 Chokidar 而非原生fs.watch
/fs.watchFile
具有诸多优势:
- 事件能被准确上报
- macOS 系统会准确上报文件名
- 避免事件重复触发
- 变更以添加/修改/删除形式呈现,而非无意义的重命名提示
- 支持原子写入(通过
atomic
选项)- 某些文件编辑器会采用这种写入方式
- 支持分块写入(通过
awaitWriteFinish
选项)- 大文件通常采用分块写入方式
- 支持文件/目录过滤
- 支持符号链接
- 始终支持递归监控,而非原生事件的部分支持
- 提供限制递归深度的方式
Chokidar 基于 Node.js 核心 fs 模块构建,但在使用 fs.watch
和 fs.watchFile
进行监控时,会对接收的事件进行标准化处理,通常通过获取文件状态和/或目录内容来验证真实性。默认采用基于 fs.watch
的实现方案,避免轮询机制,有效降低CPU占用率。需要注意的是,Chokidar 会递归初始化所有指定路径范围内的监控器,因此应注意监控范围,避免过度消耗系统资源。在特定情况下,则会采用基于轮询机制且资源占用更高的fs.watchFile
。
这里有几个关键字:
- 标准化处理
- 获取文件状态和/或目录内容来验证真实性
- 避免轮询、注意监控范围,避免过度消耗系统资源
- 特定情况下,则会采用基于轮询机制且资源占用更高的
fs.watchFile
『事件重复触发』这个我确实遇到过,不过我采用 debounce 解决。我不想引入三方库,且只是监听一个文件,没有 Chokidar 提到的其他缺陷,故仍然使用 fs.watch
。这里也透露出一个专业前端开发如何做技术选择的,因地制宜,不是某个库下载量极大甚至能避免诸多问题就一定要使用。
总结
fs.watch
性能优于 fs.watchFile
,但可靠性却反过来!
fs.watch()
是被动的、事件驱动的,只在需要时消耗资源;而 fs.watchFile()
是主动的、轮询驱动的,无时无刻不在消耗资源。
通用法则就是:始终优先使用 fs.watch()
以获得更好的性能和效率。 只有当你遇到特定的、已证实的平台兼容性问题,并且清楚意识到自己正在为此牺牲性能时,才应该 fallback 到 fs.watchFile()
。