我那个桌面应用里有一块功能是盯桌面和下载目录,新文件进来就记一条。最早挂上去的时候挺高兴,测试也过,因为我在测试机上只丢了一个 txt 进去,正好一条记录。真用起来才发现不对:用户改一个文件名,我这边处理函数跑了三次,同一条记录写了三遍。

这事跟代码写得糙关系不大,是监听这套东西本身就重复。搞明白之后改了两处,现在同一次保存只处理一次。这篇按我当时排查的顺序写。

现象:一次保存,三条事件

我把事件打出来看,一次 Ctrl+S 大概是这样:

add      C:\Users\me\Desktop\笔记.md.tmp
change   C:\Users\me\Desktop\笔记.md.tmp
unlink   C:\Users\me\Desktop\笔记.md.tmp
change   C:\Users\me\Desktop\笔记.md

四条。用户只做了一件事,我这边看到四次。如果用记事本这种直接原地写的编辑器,就只会有一条 change,测试时用的正好是记事本,所以当时没发现。

换成在资源管理器里改文件名,又是另一种:rename 一次旧的、rename 一次新的,有的机器上还夹一条 change。一次更名在监听端是两到三条。

为什么会重复,两个来源

第一个来源是编辑器。现在主流编辑器保存走的是「原子写入」:先写一个临时文件,写完整、刷盘,然后用重命名把临时文件盖到目标文件上。这么做的目的是防止写一半断电把原文件弄坏。代价就是文件系统上真的发生了一串操作,监听端老老实实全报给你。

这个行为是对的,没法怪编辑器。你要么在这里合并,要么在后面自己去重。

第二个来源是操作系统的通知机制本身。

fs.watch 在 Windows 上到底给你什么

Node 的 fs.watch 在 Windows 上底下调的是 ReadDirectoryChangesW。这个接口只给你两样东西:事件类型和文件名。事件类型只有两种,rename 和 change,没有更细的「创建」「删除」「修改」之分。

所以你会看到一个很别扭的对应关系:新建文件报 rename,删除文件也报 rename,改名字还是 rename。想区分只能自己去看文件还在不在、是不是同一个 inode(Windows 上得靠别的办法)。

还有三个坑是从这个接口直接来的:

1. filename 可能是 null。文档里写了不保证有值,实际遇到的情况是事件积压、缓冲区里的记录被截断的时候。你的回调第一件事就得判断它,不然 path.join 直接抛异常。

2. 同一个目录被 watch 两次,你会收到两份回调。这个坑我踩过:一个模块在启动时挂了监听,用户切换目录时又挂了一次,没把旧的关掉。表现是切换过三次目录之后,一次保存处理了四遍。所以监听器要跟着生命周期存一份引用,切换前先 close。

3. 路径大小写不统一。Windows 的文件系统不分大小写,但事件里给你的路径字符串,大小写跟你注册监听时写的可能不一样。你要拿路径当 Map 的 key,得先转小写。

第一层去抖:等写稳了再说

这一步解决的是「文件还在写」。你在收到 change 的瞬间去读文件,很可能读到一半的内容,或者干脆读失败。

思路是:收到事件后不马上处理,等这个文件安静一段时间没新事件了,再动手。chokidar 里这个选项叫 awaitWriteFinish:

const watcher = chokidar.watch(dir, {
  ignoreInitial: true,
  depth: 2,
  awaitWriteFinish: {
    stabilityThreshold: 800,   // 800ms 内没新变化才算写完
    pollInterval: 100          // 每 100ms 查一次大小
  }
});

它实现的方式很朴素:轮询文件大小,连续一段时间大小不变就当写完了。stabilityThreshold 设多少要看你的场景,本地小文件 500 到 800 毫秒够用,往网络盘写就得往大调,不然大文件还在往下写你就开始处理了。

这个选项只处理「写入过程中的重复事件」,处理不了上面那个临时文件 + 重命名的序列。chokidar 有个 atomic 选项(默认开着)专门认编辑器的这套动作,会把「新增临时文件、改它、删它、目标文件变化」合成一条 update。但它是靠文件名的模式猜的,不是所有编辑器都能认出来,所以我没指望它。

第二层去抖:按路径合并

经过第一层,同一次保存的事件通常会挤在一个很短的时间窗里。这时候再按路径做一次合并就够了:同一个文件在 300 毫秒内来多少条,都只算一次,处理的时候读一次最终状态。

const pending = new Map();

watcher.on('all', (event, file) => {
  const key = file.toLowerCase();          // Windows 路径不分大小写
  const old = pending.get(key);
  if (old) clearTimeout(old);
  pending.set(key, setTimeout(() => {
    pending.delete(key);
    handleFile(key);                       // 只在这里读文件、写库
  }, 300));
});

关键点是后一条事件把前一条的定时器清掉,只留最后一个。这样一次保存的 add、change、unlink 连起来,最后只剩一次处理。删掉的文件在 handleFile 里发现不存在,直接跳过。

为什么可以这么简单:因为我不需要知道「发生了哪些变化」,我只需要知道「这个文件现在是什么样」。处理函数是无状态的,读当前状态覆盖数据库里的记录,来几次都一样。如果你的逻辑是「每来一次变化就追加一条历史」,那就不能用合并,得先老老实实把事件序列归类。

这个区别挺重要。事件监听天然不可靠,凡是能用「当前状态」表达的,就别用「变化序列」表达。

文件正在写,读它是会失败的

就算等了 800 毫秒,也还是会碰上读失败。Windows 上文件被别的进程打开着独占写入时,你去 fs.stat 或 fs.readFile 会报 EBUSY 或者 EPERM。

典型场景是下载目录:浏览器写 .crdownload 的时候,那个文件是锁着的。还有 Office 打开文档时会生成一个隐藏的 ~$xxx.docx。

所以处理函数里要有重试,而且不能无限重试:

async function statRetry(p, times = 3, gap = 200) {
  for (let i = 0; i < times; i++) {
    try {
      return await fs.promises.stat(p);
    } catch (e) {
      if (e.code !== 'EBUSY' && e.code !== 'EPERM') throw e;
      if (i === times - 1) return null;      // 放弃,这条不记了
      await new Promise((r) => setTimeout(r, gap));
    }
  }
}

读不到就丢掉这条,别重试十次。用户看见的是「这个文件没进列表」,而不是「应用卡住」。而且这种文件往往还在下载,过一会儿会有新的事件进来。

顺手把临时文件过滤掉,能少一半噪音:扩展名是 .tmp、.crdownload、.part 的,文件名以 ~$ 开头的,直接忽略。

事件还会丢,这个没法彻底解决

这个我一开始不信,后来接受它了。ReadDirectoryChangesW 在内核里用的是固定大小的缓冲区,一次给你一批记录。短时间内目录里发生的变化太多、你这边处理太慢,缓冲区就会溢出,超出的那部分事件就没了。

触发条件很现实:解压一个几千个文件的压缩包,或者 git checkout 切分支。这种情况下你不可能收到每一个事件。

所以策略得是「最终一致」:监听负责发现变化,另外再有一个低频率的全量扫描(比如启动时跑一次、每半小时跑一次)负责纠偏。只靠监听,漏掉的就永远是漏的。

全量扫描别做太重,比对文件大小和修改时间就够了,不用读内容、不用算哈希。几千个文件的目录扫一遍也就几百毫秒,扔进 utilityProcess 跑,别占主进程。

怎么选:裸 watch、chokidar、还是轮询

三种方案的差别在准确度和成本,按需求选:

1. 裸 fs.watch。零依赖,但两个事件类型、filename 可能为空、没有写入完成的判断、跨平台行为不一致。只适合「我只想知道有没有动静」这种需求,比如热重载看一眼就重启。

2. chokidar。把 add、change、unlink 分清了,做了 awaitWriteFinish、atomic、ignoreInitial,跨平台统一。代价是多一个依赖、多一层内存结构,监听几万个文件的目录会明显吃内存。我用的这个。

3. 轮询 fs.watchFile 或自己 setInterval 扫。最土但最可控,绝对不会漏,也绝对不会重复。缺点是延迟等于轮询间隔,而且文件多了 CPU 一直有占用。适合网络盘、或者需要监控的目录很小的场景。

判断标准我给一条:如果你的处理函数读的是文件的最终状态,就选事件监听加去抖;如果必须知道中间发生过什么,就选轮询。前者便宜,后者可靠,中间那层很容易两头不占。

最后补一句,监听的目录不要开得太宽。depth 设成 1 或 2、把 node_modules、.git、临时目录排除掉,比什么优化都管用。我最早在根目录上挂了一个递归监听,切一次分支直接把进程拖到假死。