Electron在鸿蒙PC上监听文件变化,chokidar静默失效,我被迫写了一个轮询器
上周产品经理提了个需求:用户在系统里导出报表之后,我们的 Electron 应用要能自动感知到,然后在界面上弹个提示,问用户要不要直接打开。听起来挺简单对吧?监听一下下载目录不就完了。
我一开始也是这么想的。电脑上装了 chokidar,之前项目在 Windows 和 macOS 上跑得好好的,代码几乎不用改。结果搬到鸿蒙 PC 上,怪事来了。
先上代码,后面解释我为什么这么写
javascript
// 这是最终能跑的版本,先别急着抄,看完故事
const fs = require('fs');
const path = require('path');
const { ipcMain } = require('electron');
class FallbackWatcher {
constructor(targetPath, options = {}) {
this.targetPath = targetPath;
this.interval = options.interval || 800;
this.filter = options.filter || (() => true);
this.onChange = options.onChange || (() => {});
this.onError = options.onError || (() => {});
this.snapshot = new Map();
this.timer = null;
this.running = false;
}
async start() {
if (this.running) return;
this.running = true;
// 先拍一张"全家福"
await this._scan();
this.timer = setInterval(() => {
this._scan().catch(this.onError);
}, this.interval);
}
async _scan() {
const current = new Map();
const entries = await fs.promises.readdir(this.targetPath, { withFileTypes: true })
.catch(() => []);
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = path.join(this.targetPath, entry.name);
if (!this.filter(fullPath)) continue;
try {
const stat = await fs.promises.stat(fullPath);
const key = entry.name;
current.set(key, {
mtime: stat.mtimeMs,
size: stat.size
});
} catch (e) {
// 文件可能在扫描过程中被删了,无视
}
}
// 找新增和修改的
for (const [name, meta] of current) {
const old = this.snapshot.get(name);
if (!old) {
this.onChange({ type: 'add', file: name, path: path.join(this.targetPath, name) });
} else if (old.mtime !== meta.mtime || old.size !== meta.size) {
this.onChange({ type: 'change', file: name, path: path.join(this.targetPath, name) });
}
}
// 找删除的
for (const name of this.snapshot.keys()) {
if (!current.has(name)) {
this.onChange({ type: 'unlink', file: name });
}
}
this.snapshot = current;
}
stop() {
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
// 主进程里这么用
const downloadsPath = path.join(app.getPath('home'), 'Downloads', 'Reports');
const watcher = new FallbackWatcher(downloadsPath, {
interval: 600,
filter: (p) => p.endsWith('.xlsx') || p.endsWith('.pdf'),
onChange: (event) => {
console.log('[Watcher]', event.type, event.file);
// 通知渲染进程
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('file-detected', event);
});
}
});
watcher.start();
好,如果你只想要能跑的代码,上面就是了。但如果你想听我吐槽这中间发生了什么,接着看。
第一版:chokidar,我的老伙计怎么不干活了
我的第一版代码长这样,我相信很多人也是这么写的:
javascript
const chokidar = require('chokidar');
const watcher = chokidar.watch('/home/user/Downloads/Reports', {
persistent: true,
ignoreInitial: true,
depth: 0
});
watcher.on('add', path => console.log('新增:', path));
watcher.on('change', path => console.log('修改:', path));
在 Windows 上测试,没问题。在 macOS 上测试,也没问题。放到鸿蒙 PC 上------静默。完全没有事件抛出来。不是报错,就是单纯的......没反应。
你猜怎么着?我检查了十分钟,以为是我路径写错了。打印出来一看,/home/user/Downloads/Reports,存在啊,权限也对了,里面还有几个测试文件。chokidar 的 ready 事件也触发了,说明初始化是成功的。但之后不管我怎么往里面丢文件、改文件、删文件,它就是一声不吭。
我当时心里只有一个念头:这玩意儿在鸿蒙上不会是假 ready 吧?
第二版:直接上 fs.watch,结果更离谱
我怀疑 chokidar 的底层在鸿蒙上有兼容问题,那就绕过它,直接用 Node.js 原生的 fs.watch 试试:
javascript
fs.watch('/home/user/Downloads/Reports', { recursive: false }, (eventType, filename) => {
console.log('fs.watch 触发:', eventType, filename);
});
这次有反应了,但反应大得我想骂人。往目录里复制一个文件,触发三四次 rename。修改一个文件,先触发 rename 再触发 change,然后再来一个 rename。更离谱的是,有时候什么都不做,它也会冷不丁给你弹一个事件出来。
我查了一下 Node.js 文档,发现人家早就说了:"fs.watch 的底层依赖于操作系统,行为可能不一致。" 但这也太不一致了吧?
顺便说一句,鸿蒙的文档排版真是......算了,不说了。
第三版:试试 fs.watchFile?性能直接爆炸
fs.watch 不靠谱,那 fs.watchFile 呢?这个用的是轮询,理论上最稳定:
javascript
fs.watchFile('/home/user/Downloads/Reports/target.xlsx', { interval: 500 }, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('文件变了');
}
});
确实稳定了,但问题是它只能监视单个文件。我的需求是监视整个目录,里面随时可能有新文件进来。如果我为每个文件都起一个 watchFile,那内存和 CPU 直接爆炸。而且新文件进来的时候我还得动态添加监视,这逻辑越写越复杂。
我一度想放弃,跟产品说"要不咱让用户手动点一下刷新按钮?"
等等,这里我漏说一个前提
其实我在踩这些坑之前,应该先查一下鸿蒙 PC 的 inotify 限制。后来发现问题的时候,我跑去看了 /proc/sys/fs/inotify/max_user_watches,数值是 8192。对于单目录监听来说,这个值完全够用了,所以不是系统限制的问题。纯粹就是 chokidar 和 fs.watch 在鸿蒙 PC 上的底层实现不够稳定。
我还特地去鸿蒙开发者论坛搜了一下,找到两三个帖子说文件监听有问题的,但都没什么正经回复。Stack Overflow 上更是一点相关的都没有------毕竟鸿蒙 PC 的开发者生态还处在早期,很多边缘场景根本没人讨论。
最终方案:自己写一个轮询器
折腾了一圈之后,我决定不跟这些底层 API 较劲了。轮询就轮询吧,现代电脑的 CPU 不差这一点开销。关键是轮询的逻辑要写得足够轻量,不要每次都做大量 IO。
我写的 FallbackWatcher 核心思路很简单:
- 每次轮询只读目录列表(
readdir),不读文件内容 - 用
stat拿到 mtime 和 size,两个值组合起来判断文件是否变化 - 维护一个快照(snapshot),新旧对比找出增删改
- 过滤掉不需要的文件类型,减少无效统计
轮询间隔我设的是 600ms。你可能觉得这也太频繁了吧?但其实 readdir + stat 在本地 SSD 上的开销极低。我实测了一下,监视一个里面有 200 个文件的目录,单次扫描平均 3-5ms。CPU 占用几乎看不出来。
等一下,这里我需要说明一下为什么不用 fs.Stats 的 ctime。因为有些编辑器保存文件的时候,ctime 会变但 mtime 不变(比如只做权限修改的情况)。我的场景只关心内容变化,所以 mtime + size 的组合更靠谱。如果你需要监听权限变化,那把 ctime 也加进去就行。
渲染进程里怎么接
主进程把事件发过去之后,渲染进程里这么收:
typescript
// preload.ts
import { ipcRenderer } from 'electron';
export const onFileDetected = (callback: (event: any) => void) => {
ipcRenderer.on('file-detected', (_, data) => callback(data));
};
// 组件里
import { onFileDetected } from '../preload';
onFileDetected((event) => {
if (event.type === 'add') {
// 弹个 toast 问用户要不要打开
showOpenPrompt(event.path);
}
});
数据对比:几种方案在鸿蒙 PC 上的表现
我简单测了一下,数据如下(目录里 50 个文件,持续监听 5 分钟):
| 方案 | 事件准确率 | CPU占用 | 内存占用 | 我的评价 |
|---|---|---|---|---|
| chokidar | 0%(完全静默) | 低 | 低 | 鸿蒙PC上不可用 |
| fs.watch | 约60%(大量重复/误报) | 极低 | 极低 | 需要大量去重逻辑 |
| fs.watchFile(单文件) | 100% | 中 | 中(每个文件一个watcher) | 不适合目录监听 |
| 自研轮询(600ms) | 100% | 低(<1%) | 低(单对象) | 目前最稳 |
从表格能看出来,轮询方案在准确率和资源占用之间取得了最好的平衡。如果你不是在监听成千上万个文件,轮询完全够用了。而且代码可控,出了问题自己能修,不用去翻 chokidar 的源码。
如果让我重来,我会直接放弃 chokidar
说实话,我在这上面浪费了差不多三个小时。如果一开始就知道鸿蒙 PC 的文件监听生态这么不成熟,我会直接写轮询,根本不会去试 chokidar 和 fs.watch。
个人建议是:如果你的 Electron 应用需要同时支持 Windows、macOS 和鸿蒙 PC,文件监听这块最好封装一个统一的接口。内部判断平台,鸿蒙走轮询,其他平台走 chokidar。不要试图让一套代码在所有平台上都跑得完美,不现实。
另外,轮询间隔不要设得太短。600ms 对用户来说已经几乎是"实时"的了,再短意义不大,反而增加 IO 压力。如果你的目录里文件特别多(上千个),可以考虑把间隔拉到 1-2 秒,或者加个文件数量阈值,超过一定数量就降级为更长间隔。
你遇到过类似的平台兼容性问题吗?欢迎留言。
本文遵循 MIT 协议,转载请注明出处。