文件系统监听实现说明
文档范围
本文档说明 Keep Notes 中文件系统监听的实现方式,包括技术方案、相关库及作用、关键技术难点,以及当前实现中已经遇到并处理的问题。
技术方案概览
Keep Notes 是 Electron + Vite + React + TypeScript 桌面应用。文件系统监听放在 Electron 主进程中实现,渲染进程只能通过 preload 暴露的安全 IPC API 发起监听、取消监听和接收变更通知。
当前监听分为两条独立链路:
-
工作区目录结构监听
打开文件夹后,主进程递归监听工作区根目录。只要检测到有效的文件系统事件,就通知渲染进程刷新当前文件树。该链路只更新侧边栏目录结构,不直接改动编辑器内容。
-
已打开文件内容监听
当某个 Markdown 文件被编辑器打开后,主进程监听该文件所在的父目录,并按文件名过滤事件。目标文件发生变化时,主进程读取最新内容,再通过 IPC 推送给渲染进程。渲染进程只把内容分发给订阅了该文件路径的编辑器实例。
核心实现文件包括:
src/main/file-watch.tssrc/main/ipc/file.ipc.tssrc/preload/api/file.api.tssrc/renderer/src/hooks/use-electron.tssrc/renderer/src/features/editor/lib/editor-runtime.tssrc/renderer/src/features/editor/lib/file-watch-registry.tssrc/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_CHANGEDUNWATCH:停止文件内容监听WATCH_WORKSPACE:启动工作区目录监听,变化后发送ON_WORKSPACE_CHANGEDUNWATCH_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 - 所有文件路径共享一个全局
onFileChangedIPC 订阅
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。
精确忽略的路径片段包括:
.gitnode_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.tssrc/renderer/src/features/editor/lib/file-watch-registry.test.tssrc/renderer/src/features/editor/lib/editor-save-coordinator.test.tssrc/renderer/src/features/editor/lib/editor-external-change.test.ts
这些测试覆盖了忽略规则、事件防抖、父目录监听、无关事件过滤、重复事件合并、渲染端订阅复用,以及自身写入识别。