跳到主要内容

文件系统监听实现说明

文档范围​

本文档说明 Keep Notes 中文件系统监听的实现方式,包括技术方案、相关库及作用、关键技术难点,以及当前实现中已经遇到并处理的问题。

技术方案概览​

Keep Notes 是 Electron + Vite + React + TypeScript 桌面应用。文件系统监听放在 Electron 主进程中实现,渲染进程只能通过 preload 暴露的安全 IPC API 发起监听、取消监听和接收变更通知。

当前监听分为两条独立链路:

  1. 工作区目录结构监听

    打开文件夹后,主进程递归监听工作区根目录。只要检测到有效的文件系统事件,就通知渲染进程刷新当前文件树。该链路只更新侧边栏目录结构,不直接改动编辑器内容。

  2. 已打开文件内容监听

    当某个 Markdown 文件被编辑器打开后,主进程监听该文件所在的父目录,并按文件名过滤事件。目标文件发生变化时,主进程读取最新内容,再通过 IPC 推送给渲染进程。渲染进程只把内容分发给订阅了该文件路径的编辑器实例。

核心实现文件包括:

  • src/main/file-watch.ts
  • src/main/ipc/file.ipc.ts
  • src/preload/api/file.api.ts
  • src/renderer/src/hooks/use-electron.ts
  • src/renderer/src/features/editor/lib/editor-runtime.ts
  • src/renderer/src/features/editor/lib/file-watch-registry.ts
  • src/renderer/src/features/editor/components/editor-workspace.tsx

主进程实现​

WorkspaceWatchRegistry​

WorkspaceWatchRegistry 位于 src/main/file-watch.ts,负责工作区级别的目录监听。

它通过 Node.js 原生 fs.watch(rootPath, { recursive: true }, callback) 监听当前工作区根目录。每个工作区路径对应一个 watcher 记录,记录中保存:

  • 原生 watcher 句柄
  • 变更回调
  • 防抖定时器

当文件系统事件到达时,主进程会先解析事件路径,再通过统一的忽略规则过滤无关路径。有效事件不会立即通知渲染进程,而是进入一个短防抖窗口。防抖结束后,主进程调用 onChange(rootPath),再通过 IPC 通知渲染进程刷新目录树。

工作区监听默认防抖时间是 120ms,用于合并一次保存、重命名或批量操作产生的多个原生事件,减少侧边栏反复刷新和抖动。

FileContentWatchRegistry​

FileContentWatchRegistry 同样位于 src/main/file-watch.ts,负责已打开文件的内容监听。

它没有直接监听目标文件本身,而是监听目标文件的父目录。例如打开 notes/daily.md 时,实际监听的是 notes 目录,然后按 basename daily.md 过滤事件。

这样设计是为了处理编辑器常见的 rename/replace 保存模式。很多编辑器保存文件时不会直接覆盖原文件,而是先写临时文件,再把临时文件重命名为目标文件。在 macOS 等平台上,如果直接监听文件本身,文件 inode 被替换后可能导致监听丢失。监听父目录可以让 watcher 在文件被替换后仍然有效。

当目标文件发生变化时,主进程会等待 80ms 防抖窗口,然后使用 fs.promises.readFile(filePath, "utf-8") 读取最新内容,并将 (filePath, content) 发送给渲染进程。

如果文件替换过程中目标文件短暂不存在,读取可能抛出 ENOENT。当前实现会忽略这类瞬时缺失错误,等待下一次 rename 或 change 事件再次同步。

IPC 链路​

IPC 通道定义在 src/shared/constants/ipc-channels.ts:

  • file:watch:监听单个文件内容变化
  • file:unwatch:取消单个文件监听
  • file:on-changed:文件内容变化通知
  • file:watch-workspace:监听工作区目录变化
  • file:unwatch-workspace:取消工作区监听
  • file:on-workspace-changed:工作区变化通知

src/main/ipc/file.ipc.ts 注册对应处理函数:

  • WATCH:启动文件内容监听,变化后发送 ON_FILE_CHANGED
  • UNWATCH:停止文件内容监听
  • WATCH_WORKSPACE:启动工作区目录监听,变化后发送 ON_WORKSPACE_CHANGED
  • UNWATCH_WORKSPACE:停止工作区目录监听

src/preload/api/file.api.ts 将 IPC 能力包装成渲染进程可用的安全 API:

  • watchFile(filePath)
  • unwatchFile(filePath)
  • watchWorkspace(rootPath)
  • unwatchWorkspace(rootPath)
  • onFileChanged(callback)
  • onWorkspaceChanged(callback)

这样可以保证真实文件系统能力只存在于主进程,渲染进程不会直接获得 Node.js 文件系统权限,符合 Electron 应用的安全边界设计。

渲染进程集成​

工作区目录刷新​

src/renderer/src/hooks/use-electron.ts 在打开或加载文件夹后调用 startWorkspaceWatch(rootPath)。

渲染进程维护一个当前正在监听的 watchedWorkspacePath。当用户切换到另一个工作区时,会先取消旧工作区监听,再监听新工作区。

收到 onWorkspaceChanged 通知后,渲染进程调用 generateTree(rootPath) 重新生成目录树,并且只有在当前根目录仍然匹配 rootPath 时才更新 store。

该刷新流程有两个并发保护:

  • workspaceRefreshInFlight:避免同时发起多个目录树刷新
  • pendingWorkspaceRefreshPath:如果刷新过程中又来了新事件,记录一次待处理刷新

工作区事件只刷新树结构,不触碰编辑器内容。这样可以避免外部保存文件时编辑区域闪烁或被重复 reload。

文件内容同步​

src/renderer/src/features/editor/lib/editor-runtime.ts 创建了渲染端的 FileWatchRegistry。

这个 registry 用于复用同一路径的监听:

  • 第一个订阅者出现时才调用主进程 watchFile
  • 同一路径的多个编辑器 tab 共用一个主进程 watcher
  • 最后一个订阅者取消订阅时才调用 unwatchFile
  • 所有文件路径共享一个全局 onFileChanged IPC 订阅

src/renderer/src/features/editor/components/editor-workspace.tsx 会让每个打开的编辑器 tab 订阅自己的文件路径。外部内容变更到达后,它会:

  • 更新编辑器缓存
  • 比较当前内容和传入内容
  • 内容确实变化时重新加载当前 tab
  • 同步其他打开了同一文件的 tab

自身写入过滤​

编辑器自动保存逻辑由 src/renderer/src/features/editor/lib/editor-save-coordinator.ts 中的 EditorSaveCoordinator 管理。

自动保存本身也会触发 fs.watch 事件。如果不区分事件来源,应用会把自己的保存动作误判为外部修改,导致编辑器重复 reload 或状态抖动。

当前方案是在真正写盘前先记录“自身写入”:

  • 每条自身写入记录保存文件路径、内容和过期时间
  • 记录有效期为 2,000ms
  • 每个路径最多保留 8 条近期写入记录
  • 同一次写入触发多个文件系统事件时,在有效期内都能被识别为自身写入

渲染端 FileWatchRegistry 在分发文件变更前会调用 editorSaveCoordinator.isOwnWrite(path, content)。如果判断为自身写入,则不会通知编辑器订阅者。

写入记录会在真正写盘前登记,而不是写盘成功后登记。原因是文件系统事件可能先于 IPC 写入结果返回,提前登记可以避免竞态。

忽略路径规则​

忽略逻辑集中在 src/main/file-watch.ts 的 shouldIgnoreFsWatchPath。

精确忽略的路径片段包括:

  • .git
  • node_modules
  • .DS_Store
  • .tolaria-rename-txn

临时文件、编辑器交换文件和云占位文件规则包括:

  • 文件名以 .# 开头
  • 文件名以 ~ 结尾
  • 文件名以 .tmp 结尾
  • 文件名以 .swp 结尾
  • 文件名以 .swx 结尾
  • 文件名以 .icloud 结尾

同一个忽略函数同时用于 watcher 事件过滤和 src/main/file.ts 中的目录树生成,因此“哪些文件会显示在侧边栏”和“哪些事件会触发刷新”使用同一套规则。

相关库和作用​

Node.js fs​

文件监听的核心能力来自 Node.js 标准库:

  • fs.watch:监听文件系统原生事件
  • fs.promises.readFile:读取变化后的文件内容
  • fs.promises.readdir:读取目录内容
  • fs.promises.stat:判断路径是目录还是文件

项目没有在业务代码中直接使用第三方 watcher 库,因此运行时依赖更轻。

Electron IPC​

Electron IPC 用于隔离主进程和渲染进程:

  • ipcMain.handle:接收渲染进程发起的监听、取消监听请求
  • webContents.send:主动推送文件变化和工作区变化
  • ipcRenderer.invoke / ipcRenderer.on:由 preload 包装后暴露给渲染进程

React​

React effect 用于根据编辑器 tab 生命周期订阅和取消订阅文件监听。当 tab 切换文件或组件卸载时,会自动释放对应订阅。

Zustand​

Zustand 存储目录树、编辑器 tab、文件内容、保存状态和 reload key。监听事件到达后,渲染进程通过 store 更新 UI,而不是直接操作组件内部状态。

Vitest​

Vitest 用于覆盖监听逻辑的关键行为。相关测试通过 fake timers 和注入式 watcher/readFile 函数验证防抖、过滤、父目录监听、订阅复用和自身写入识别。

chokidar​

pnpm-lock.yaml 中存在 chokidar@3.6.0,但当前应用业务代码没有直接 import 或使用 chokidar 作为文件监听方案。它更像是工具链或打包相关依赖的间接依赖。

技术难点​

原生事件重复触发​

一次保存可能触发多个 rename 和 change 事件。如果每个事件都刷新目录树或读取文件内容,会造成重复计算、UI 抖动和无意义的渲染。

当前通过两套防抖解决:

  • 工作区目录刷新:120ms
  • 文件内容读取:80ms

rename/replace 保存模式​

很多编辑器保存文件时会用临时文件替换原文件。直接监听文件路径可能在文件被替换后丢失监听。

当前方案是监听父目录,再按目标文件名过滤事件,从而在文件 inode 替换后仍能继续收到事件。

文件短暂不存在​

文件替换过程中,目标文件可能短暂不存在。此时立即读取会抛出 ENOENT。

当前方案是忽略瞬时 ENOENT,等待后续事件再次读取。

区分自身保存和外部修改​

自动保存也会触发文件系统事件。如果误判为外部修改,编辑器可能重复 reload。

当前通过 EditorSaveCoordinator 记录近期自身写入,并在分发事件前做内容匹配过滤。

同一文件多 tab 打开​

同一文件可能被多个编辑器 tab 打开。如果每个 tab 都启动一个原生 watcher,会浪费资源,也容易造成重复通知。

当前通过渲染端 FileWatchRegistry 对同一路径做订阅复用。一个路径只对应一个主进程 watcher,再由渲染端分发给多个订阅者。

异步刷新竞态​

目录树刷新和文件读取都是异步过程。刷新期间可能发生工作区切换、取消监听或新的文件系统事件。

当前方案是在主进程和渲染进程应用异步结果前再次检查 watcher/store 状态,避免过期结果覆盖当前状态。

噪声文件过滤​

Git 内部文件、依赖目录、系统文件、编辑器临时文件、iCloud 占位文件都可能触发无意义事件。

当前通过 shouldIgnoreFsWatchPath 统一过滤,减少无效刷新和侧边栏污染。

遇到的问题和处理方式​

侧边栏因重复事件抖动​

问题:一次保存可能触发多次文件系统事件,导致目录树反复生成。

处理:WorkspaceWatchRegistry 对工作区事件做防抖合并,再通知渲染进程刷新。

保存替换后文件监听丢失​

问题:部分编辑器保存文件时会替换原文件,直接监听目标文件可能失效。

处理:FileContentWatchRegistry 改为监听父目录,并按目标文件名过滤事件。

自动保存被误判为外部修改​

问题:应用自身写盘也会触发 fs.watch,可能被当成外部修改。

处理:EditorSaveCoordinator 在写盘前记录近期自身写入,文件变更分发前用 isOwnWrite 过滤。

临时文件进入目录树或触发刷新​

问题:.DS_Store、.swp、.tmp、.icloud 等文件会污染目录树或产生无效刷新。

处理:目录读取和 watcher 事件共用 shouldIgnoreFsWatchPath 过滤规则。

异步结果过期​

问题:文件读取或目录刷新完成时,用户可能已经切换工作区或取消监听。

处理:应用结果前重新检查当前 watcher 和 store 状态,避免过期结果覆盖最新界面。

当前限制和风险​

  • fs.watch 在不同操作系统上的行为不完全一致,递归监听尤其依赖平台实现。
  • 当前工作区监听采用“收到有效事件后重建目录树”的方式,没有做增量树更新。
  • 文件内容监听每次事件后读取完整文件。对 Markdown 笔记足够简单可靠,但超大文件会有性能成本。
  • watcher 启动失败目前主要记录在主进程日志中,没有在渲染进程 UI 中明确提示。

测试覆盖​

相关测试文件包括:

  • src/main/file-watch.test.ts
  • src/renderer/src/features/editor/lib/file-watch-registry.test.ts
  • src/renderer/src/features/editor/lib/editor-save-coordinator.test.ts
  • src/renderer/src/features/editor/lib/editor-external-change.test.ts

这些测试覆盖了忽略规则、事件防抖、父目录监听、无关事件过滤、重复事件合并、渲染端订阅复用,以及自身写入识别。