资讯动态

Node.js+Electron 文件系统实战:流式读写、原子写入与 IPC安全

发布时间:2026/10/27 14:20:34 来源:尧图企业网站定制
文件系统操作这件事在 Electron 里做 demo 和做产品完全是两码事。上篇我们把 Node.js 的 fs 模块基础用法过了一遍——readFile 读配置、writeFile 存日志、readdir 列目录跑起来确实没什么问题。但真正把应用发到用户机器上之后麻烦才开始几百兆的导出文件一次性读进来把内存顶到天花板、写配置文件写到一半用户强制关机导致文件变成空壳、目录监听起来在 macOS 上好好地到了 Windows 就疯狂触发、渲染进程想读一个用户选中的文件结果被 contextIsolation 拦在门外。这篇下篇我只聊这些上生产才会遇到的部分围绕 Node.js 模块在 Electron 中的文件系统操作实战把流式读写、目录遍历、文件监听、IPC 安全边界这几块拆开讲透。适合已经能跑通基础读写、准备把文件功能做扎实的开发者也适合正在排查某个诡异 IO 问题的同学对照着看文中所有代码和参数都可以直接拿去改。1. 下篇要解决的问题从能跑到敢交付1.1 上篇留下的三个待办上篇结尾我列了三件事没展开这里先交代清楚为什么它们值得单独写一篇。第一件是大文件。readFile 的语义是把整个文件读进内存再交给你这个设计在小文件上非常舒服但用户的实际文件大小是不受控的一个从别处导出的 CSV 动辄两三百兆你没法假设它小。第二件是写入的可靠性。writeFile 不是原子操作它在底层会先截断文件再写入中间任何一个环节出问题用户手里就只剩一个半截文件配置类文件一旦坏了应用下次启动直接白屏。第三件是进程边界。Electron 从 12 版本之后默认开启 contextIsolation渲染进程拿不到 require也拿不到 Node 的 fs。这个设计是对的但它意味着所有文件操作必须经过一层设计过的 IPC 通道而不是随手在页面里 import 一下就用。这三件事的共同点是它们都不影响你本地跑通但都会在真实用户那里出问题。所以下篇的组织顺序就是按这三件事展开中间穿插目录遍历和批量处理这些日常高频但容易写出隐患的操作。1.2 下篇的设计取舍为什么把文件操作全部收归主进程在动手写代码之前有一个架构决策值得先定下来文件读写到底放在主进程还是渲染进程。早期很多项目图省事在 BrowserWindow 的 webPreferences 里打开 nodeIntegration页面里直接 require(fs)。这条路走起来最快但它等于把整个 Node 运行时暴露给了页面任何一个第三方脚本、任何一次 XSS 都能直接读写用户磁盘。我见过不止一个项目因为这个配置被安全扫描打回来。我推荐的方案是所有 fs 调用只在主进程里出现渲染进程通过 preload 暴露的一组窄接口去调用。这样做有几个实际好处。主进程是单例的文件操作的队列、锁、缓存都好做主进程崩了应用本来就完了不用额外考虑容错分层preload 里的接口是你能控制的可以针对每个方法做参数校验把路径限制在允许的范围内。代价是多写一层 IPC 胶水代码但这个成本一次性投入后面所有文件功能都受益。下面第 5 节会给出这套方案的完整代码包括 preload 怎么写、主进程怎么注册 handler、渲染进程怎么调用。顺便说一句 nodeIntegration 的取舍。有些同学会问如果我把 nodeIntegration 关掉、contextIsolation 打开是不是性能会变差实测下来几乎感知不到IPC 的序列化开销在文件 IO 面前可以忽略不计真正的瓶颈永远在磁盘和编码转换上不在通信层。2. 大文件读写把 readFile 换成流的完整落地2.1 先算一笔内存账什么规模必须上流很多人对大文件没有具体概念凭感觉判断。我给一个可以直接套用的估算方法。用 readFile 读一个文件内存里至少同时存在两份数据一份是 fs 返回的 Buffer大小等于文件字节数另一份是你把它转成字符串之后的 JS 字符串。V8 的字符串内部表示跟内容有关纯 ASCII 用单字节存储含中文的用双字节UTF-16。一个中文汉字在 UTF-8 里占 3 字节转成 UTF-16 字符串后占 2 字节所以中文场景下字符串大约是 Buffer 的三分之二。但内存峰值不能只算这两份因为转换过程中 Buffer 还没被 GC 回收两份是重叠存在的。再加上 JSON.parse 如果还要再解析一次又会产生一个结构完全不同的对象树通常是原字符串的 1.5 到 3 倍。所以一个粗略的峰值公式是峰值内存 ≈ 文件大小 × 转换系数 × 3ASCII 文本的转换系数接近 1中文内容取 0.7 到 1 之间。按这个公式一个 100MB 的中文日志文件读进来再 parse峰值大概在 200MB 到 300MB。单看数字好像还行但用户可能同时开着十几个标签页你的应用只是其中之一主进程的内存压力是叠加的。我的经验阈值是超过 10MB 的文件就开始考虑流式处理超过 50MB 一律用流超过 200MB 必须要考虑分片和进度反馈。还有一点容易被忽略readFile 读到一半失败了前面读的内容全部作废用户等了三十秒得到一个报错。而流式处理可以边读边处理失败了至少已经处理的部分可以保留或者回滚用户体验完全不同。2.2 createReadStream 配合 pipeline 的可复现写法流式读取的核心 API 是 fs.createReadStream它返回一个可读流你可以在上面监听 data、end、error。但直接监听 data 事件有个经典问题如果下游处理速度跟不上数据会在内存里堆积也就是所谓的背压失控。正确的做法是让流自己处理背压用 pipeline 把可读流、转换流、可写流串起来。下面这段是我在一个日志导出功能里实际用的代码场景是把一个可能几百兆的文本文件按行处理过滤掉不需要的行再写到新文件const fs require(node:fs); const { pipeline } require(node:stream/promises); const { Transform } require(node:stream); const readline require(node:readline); async function filterLogFile(srcPath, destPath, keyword) { const source fs.createReadStream(srcPath, { encoding: utf8, highWaterMark: 64 * 1024, }); const target fs.createWriteStream(destPath, { encoding: utf8 }); const rl readline.createInterface({ input: source, crlfDelay: Infinity, }); let kept 0; let total 0; const filter new Transform({ writableObjectMode: false, transform(line, _enc, cb) { total 1; if (line.includes(keyword)) { kept 1; cb(null, line \n); } else { cb(); } }, }); const lineSource require(node:stream).Readable.from(rl, { objectMode: false }); await pipeline(lineSource, filter, target); return { total, kept }; }这里有几个细节值得说明。highWaterMark 设成 64KB 是我实测下来比较平衡的值默认的 16KB 在机械硬盘上会产生过多的小块读取调到 1MB 又会让单次内存占用偏高64KB 到 256KB 之间都合理具体看你处理的文件是大量小行还是少量超长行。readline 的 crlfDelay 设成 Infinity 是为了让 \r\n 和 \n 都能被正确识别为同一个换行不设这个参数的话Windows 导出的文件会多出一堆空行和尾部 \r。pipeline 相比手动 pipe 的优势在于它会自动处理错误传播和资源清理。如果你用 source.pipe(target)中间任何一端报错另一端不会自动关闭文件句柄就泄漏了。pipeline 在 Promise 版本下出错时会销毁所有流这个行为在长时间运行的应用里非常关键。2.3 进度回传与背压处理用户点了一个导出按钮界面转圈转了两分钟没动静他会以为程序卡死了。所以流式处理必须回传进度。最简单的做法是统计已经读过的字节数let processed 0; source.on(data, (chunk) { processed chunk.length; const percent Math.min(100, Math.round((processed / totalSize) * 100)); if (percent ! lastPercent) { lastPercent percent; mainWindow.webContents.send(export-progress, percent); } });这里的 totalSize 要用 fs.stat 提前拿到。有一个小陷阱stat 拿到的是文件在磁盘上的大小而流读到的是解码后的字符数据两者单位不一致。如果你在流的 data 事件里拿到的是 Bufferlength 是字节数可以直接比如果流设置了 encodingchunk 就是字符串length 是字符数英文场景下和字节数相等含中文时就不等了进度会偏慢。稳妥的做法是不给流设 encoding让它吐 Buffer在需要字符串的地方单独 toString或者干脆用字节数做进度分母用 Buffer.length 累加。背压这一侧pipeline 已经帮你处理了你不需要手动调 pause 和 resume。但如果你在 transform 里做了异步的耗时操作比如每行都去查一次数据库那就得用 async 版本的 transform 并正确 await否则数据会堆积。2.4 流操作里最容易翻车的四个点第一个是忘记监听 error。流是 EventEmittererror 事件如果没有人监听Node 会直接抛出未捕获异常主进程整个挂掉。用 pipeline 的话它会替你处理用手动 pipe 的话务必两端都挂 error 回调。第二个是关闭顺序。写入流在 end() 之后还有一个 finish 事件文件描述符要等 finish 才真正释放。如果你在 end() 之后立刻去读这个文件可能读到不完整的内容。正确的写法是 await 一个 promise在 finish 或者 close 事件里 resolve。第三个是编码问题。createWriteStream 如果不指定 encoding默认是 Buffer你写字符串进去它会按 utf8 转一般没问题但如果你写了 Buffer 又写了字符串混着来结果会非常诡异。统一成一个类型是最省心的。第四个是文件描述符泄漏。每次 createReadStream 都会占用一个 fdLinux 上默认上限是 1024如果你的应用在循环里创建流又没有正确关闭跑一段时间就会报 EMFILE。这个错误信息很反直觉它看起来像是文件不存在实际是句柄用完了。pipeline 能解决大部分场景手工管理的话记得在 finally 里 destroy。3. 目录遍历、批量处理与原子写入3.1 readdir 的 withFileTypes 与递归遍历列目录最常用的写法是 fs.readdir(dir)它返回一个文件名数组。但如果你需要区分文件和目录就得对每个名字再 stat 一次N 个条目就是 N 次系统调用。Node 10 之后提供了 withFileTypes 选项一次 readdir 就能拿到 Dirent 对象自带 isDirectory()、isFile()、isSymbolicLink() 方法性能好很多const entries await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { await walk(path.join(dir, entry.name)); } else if (entry.isFile()) { results.push(path.join(dir, entry.name)); } }这里有个新手常犯的错误Dirent 的类型信息是在 readdir 那一刻拿到的如果目录内容在你遍历过程中发生变化类型可能已经不准了。所以在做先列出来再逐个处理的两阶段逻辑时处理阶段还要再 stat 一次确认不能完全信任之前的 Dirent。Node 20 之后还有一个更省事的选项fs.promises.readdir 支持 recursive: true可以一次性拿到所有层级的路径。但它返回的是相对路径字符串不是 Dirent拿不到类型信息而且大目录下一次性返回所有路径会占用不少内存。我的用法是小目录图省事用 recursive大目录或者需要边遍历边处理比如只处理前一万个文件就停的场景老老实实自己写递归。3.2 批量重命名与清理脚本的工程化写法批量操作最怕的是做到一半失败了。比如你要把一千个文件按规则重命名处理到第 400 个的时候遇到了一个权限问题前面的 399 个已经改了后面的还是原名整个目录处于一个半新半旧的状态恢复起来极其痛苦。我的做法是分成两步先做全量预检再做批量执行。预检阶段只做只读操作把所有要改的文件、目标名字、潜在冲突都算出来生成一个操作计划数组。这个计划可以先写到一个临时文件里万一后面出问题照着计划反向操作就能回滚。执行阶段逐个执行每一步的执行结果追加到日志里。async function planRename(dir, rule) { const files await fs.promises.readdir(dir); const plan []; const used new Set(); for (const name of files) { if (!rule.match(name)) continue; let target rule.apply(name); let i 1; while (used.has(target) || (await exists(path.join(dir, target)))) { target rule.apply(name, i); } used.add(target); plan.push({ from: path.join(dir, name), to: path.join(dir, target) }); } return plan; }冲突检测这一步很多人省掉结果就是两个文件被改到同一个名字上后一个把前一个覆盖了。文件系统在大多数情况下不会报错静默覆盖数据直接没了。加一个 used 集合 目标存在性检查成本很低。3.3 符号链接与死循环防护递归遍历目录时符号链接是个大坑。假设有个链接指向了它的上级目录你的递归就会无限进行下去直到栈溢出或者把整块磁盘的路径都手工加进去。防护方法是给遍历加一个深度上限同时记录已经访问过的真实路径用 fs.realpath 解析之后的路径放进一个 Set重复访问就直接跳过。const visited new Set(); async function safeWalk(dir, depth 0) { if (depth 32) return; let real; try { real await fs.promises.realpath(dir); } catch (e) { return; } if (visited.has(real)) return; visited.add(real); // ... 继续遍历 }深度上限设 32 是个经验值正常项目的目录层级很少超过十层超过这个数基本可以判定有问题。用 realpath 而不是原路径是因为同一个目录可以通过不同的路径到达比如软链接和硬链接只比较字符串是发现不了重复的。3.4 原子写入临时文件加 rename 的标准操作写配置文件最怕的就是写到一半失败。writeFile 在底层是先 open 带 truncate再 write再 close。如果在 truncate 之后、write 完成之前进程被杀了用户强制退出、系统休眠、断电磁盘上就留下一个 0 字节或者半截的文件下次启动读取就会 JSON.parse 报错。标准解法是原子写入把内容先写到一个同目录下的临时文件写完并 fsync 之后再 rename 到目标路径。rename 在同一个文件系统内是原子操作要么成功要么失败不会出现中间态。async function atomicWrite(targetPath, content) { const dir path.dirname(targetPath); const tmpPath path.join(dir, .${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp); const handle await fs.promises.open(tmpPath, w); try { await handle.writeFile(content, utf8); await handle.sync(); } finally { await handle.close(); } await fs.promises.rename(tmpPath, targetPath); }这里有两个细节必须强调。临时文件一定要放在目标文件的同一个目录下不能放系统临时目录因为跨文件系统的 rename 不是原子的Node 会退化成复制 删除原子性就没了。另外 handle.sync() 这一步很多人省略它负责把数据从操作系统的页缓存刷到磁盘上不调的话rename 虽然成功了但内容可能还在缓存里断电一样丢。代价是会慢一些配置文件这种低频写入完全值得。还有一个兜底手段写到临时文件之后如果 rename 失败至少临时文件还在可以在下次启动时扫描目录里的 .tmp 残留做恢复。我在一个配置管理模块里就这么干的启动时先检查有没有残留的 tmp 文件有的话对比一下时间戳判断能不能用来恢复上一次的写入。4. 文件监听从 fs.watch 到 chokidar4.1 原生 fs.watch 在不同平台的差异先给结论如果你的需求是用户改了一个配置文件我要重新加载fs.watch 够用如果你的需求是监控整个项目目录文件增删改都要响应不要用原生 watch直接用 chokidar。差异体现在几个地方。macOS 用的是 FSEvents事件是合并过的一次回调可能代表几个文件的变化而且文件名可能拿不到。Linux 用的是 inotify监听目录本身只能拿到目录层的变化子文件的变化拿不到需要给每个子目录单独建立监听目录多了会耗尽 inotify 的 watch 上限。Windows 用的是 ReadDirectoryChangesW行为相对可靠但它在文件被其他程序占用时会阻塞有时候回调延迟能达到几秒。跨平台行为不一致这一条光看文档是感受不到的我当初在一个导出功能上就栽过同一个监听逻辑在我的开发机上每次保存文件触发一次回调到了同事的 Windows 机器上一次保存触发了三次因为编辑器是先写临时文件再 renamewatch 捕捉到了创建、修改、重命名三个事件。4.2 chokidar 的关键参数怎么选chokidar 本质是对原生 watch 的封装加统一它解决了跨平台差异和事件合并的问题。几个参数值得逐个说const chokidar require(chokidar); const watcher chokidar.watch(watchDir, { ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100, }, depth: 4, });ignored 那个正则用来忽略隐藏文件编辑器产生的临时文件通常以点开头不忽略的话你会被大量噪音事件干扰。ignoreInitial 设成 true 表示不触发初始扫描时的 add 事件这个非常重要——如果你只是想监听后续变化设成 false 的话启动瞬间会给你几百个 add 回调如果不设防就会造成一次雪崩式的重新加载。awaitWriteFinish 是 chokidar 最有价值的特性。它的原理是发现文件变化后不立刻触发而是轮询检查文件大小是否稳定稳定超过 stabilityThreshold 毫秒才真正触发。这个参数解决的就是文件写到一半就被读到的问题。300 毫秒是个比较通用的值如果你的写入方是大文件需要把它调大到 1000 甚至 2000。depth 控制递归深度不设的话 chokidar 会无限往下监听在 node_modules 这种目录下会直接卡死。我一般会显式设成 4 到 6 之间同时把 node_modules、.git、dist 这些目录加进 ignored。4.3 防抖、去重与事件语义即使用了 chokidar你仍然需要自己做一层防抖。原因是用户的编辑器可能触发多次事件比如 VS Code 保存时会先写临时文件再 rename一次保存产生两个事件。如果你在事件回调里做重活比如重新解析整个配置文件、重启一个子进程就会做两次。防抖的实现很简单为每个被监听的文件维护一个定时器新事件到来时清掉旧的定时器重新计时const pending new Map(); function schedule(filePath, handler) { if (pending.has(filePath)) { clearTimeout(pending.get(filePath)); } pending.set(filePath, setTimeout(() { pending.delete(filePath); handler(filePath); }, 200)); }这里的关键是按文件路径分别防抖而不是全局一个防抖。全局防抖会导致用户同时改了两个文件第二个文件的操作把第一个的定时器清掉了第一个文件的变更就丢了。按文件维度隔离每个文件独立计时各管各的。事件语义上还有一个细节change 事件告诉你文件变了但不告诉你变成什么样了。有些实现会在 change 回调里重新读整个文件再 diff如果文件很大就很亏。更聪明的做法是用 stat 先比较 mtime 和 sizesize 没变、mtime 变了大概率只是 touch 了一下size 变了才需要重新读。这个小优化在大型配置文件上能省下不少 IO。4.4 监听器泄漏与资源释放watcher 是持续占用系统资源的特别是在 Linux 上每一层目录对应一个 inotify watch。如果用户反复切换项目目录每次切换都 new 一个 watcher 又不关掉旧的watch 数量会一直涨最后报 ENOSPCinotify 的 watch 数量上限。所以每次切换前必须调用 await watcher.close()而且要在 close 之后再去建新的。close 是异步的不 await 的话可能出现新旧 watcher 同时存在的一小段时间窗口虽然通常没影响但在切换频率高的场景下会造成事件重复。另外应用退出时也要记得关。主进程的 before-quit 事件里统一清理所有 watcher不要指望进程退出会帮你回收Windows 上偶尔会出现句柄没释放导致进程残留的情况。5. IPC 与安全边界渲染进程怎么合法地碰文件5.1 contextIsolation 下的 preload 暴露原则Electron 的上下文隔离开启之后preload 脚本运行在一个独立的世界里它能访问 Node 的 API但跟页面的 window 是隔离的。通过 contextBridge.exposeInMainWorld 可以把一组方法挂到页面的 window 上这是唯一正确的单向出口。暴露的时候有个原则只暴露具体功能不暴露通用能力。反例是这样写// 不要这样 contextBridge.exposeInMainWorld(fsx, { readFile: (p) fs.promises.readFile(p, utf8), writeFile: (p, c) fs.promises.writeFile(p, c), });这等于把整个 fs 送给了页面路径完全没有约束页面被注入一段脚本就能读用户主目录下的任何东西。正确的做法是每个功能一个方法参数在 preload 或者主进程里做严格校验// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(appFiles, { readProjectConfig: (projectId) ipcRenderer.invoke(config:read, projectId), saveProjectConfig: (projectId, data) ipcRenderer.invoke(config:save, projectId, data), exportLog: (projectId, options) ipcRenderer.invoke(log:export, projectId, options), onExportProgress: (cb) { const listener (_e, payload) cb(payload); ipcRenderer.on(export:progress, listener); return () ipcRenderer.removeListener(export:progress, listener); }, });注意最后那个 onExportProgress它返回一个取消订阅的函数。这个模式很重要页面组件卸载的时候如果不取消订阅事件监听器会一直挂着组件重建一次就多一个监听器跑久了内存和 CPU 都会出问题。5.2 ipcMain.handle 与 invoke 的完整链路主进程这一侧用 ipcMain.handle 注册它对应渲染进程的 invoke返回的是一个 Promise天然支持异步。这比老的 send / on 加 callback 的写法干净太多了不需要自己维护回调 id。const { ipcMain, app } require(electron); const path require(node:path); const fs require(node:fs); const PROJECTS_ROOT path.join(app.getPath(userData), projects); function resolveProjectPath(projectId) { if (typeof projectId ! string || !/^[a-zA-Z0-9_-]{1,64}$/.test(projectId)) { throw new Error(INVALID_PROJECT_ID); } const target path.resolve(PROJECTS_ROOT, projectId, config.json); const rootWithSep PROJECTS_ROOT path.sep; if (!target.startsWith(rootWithSep)) { throw new Error(PATH_ESCAPE); } return target; } ipcMain.handle(config:read, async (_event, projectId) { const filePath resolveProjectPath(projectId); try { const raw await fs.promises.readFile(filePath, utf8); return { ok: true, data: JSON.parse(raw) }; } catch (err) { if (err.code ENOENT) return { ok: true, data: null }; return { ok: false, error: err.message }; } });这段代码里有几处是刻意设计的。第一项目 id 用正则白名单校验只允许字母数字和连字符下划线从源头上排除了 ../ 这种路径穿越。第二拼出完整路径之后用 path.resolve 归一化再检查它是不是还在根目录下面这是第二道防线防止某些奇怪的符号链接或者编码问题绕过第一道。第三读文件失败的时候区分了文件不存在和其他错误前者返回 null 让前端当成默认配置处理后者才真正报错。所有 handler 都应该返回一个结构化的对象而不是直接抛异常因为 invoke 抛出的异常到了渲染进程只剩一个 message 字符串堆栈信息不同步排查起来很难受。5.3 路径校验白名单与穿越防护的具体做法路径穿越这件事比想象中普遍。假设你的应用有一个下载附件功能渲染进程传过来一个文件名你直接 path.join(下载目录, fileName)如果 fileName 是 ../../../.ssh/id_rsa你就把用户的密钥读出来了。这类漏洞在很多桌面应用里都出现过。防护的做法我总结成三层。第一层是输入格式白名单能用正则限定的就用正则比如项目 id、文件名限定字符集是最省事也最有效的。第二层是归一化之后的边界检查用 path.resolve 得到绝对路径再用 path.relative 计算相对路径如果结果以 .. 开头就说明越界了function isInside(root, target) { const rel path.relative(root, target); return rel ! !rel.startsWith(..) !path.isAbsolute(rel); }path.relative 这个写法比字符串 startsWith 更稳因为它处理了大小写、分隔符、尾部斜杠这些细节。Windows 上大小写不敏感用 startsWith 可能被 C:\Users 和 c:\users 绕过relative 就没这个问题。第三层是尽量不要接收完整路径。能让用户通过 dialog 选文件的就不要让渲染进程传路径能传相对路径的就不要传绝对路径能传 id 的就不要传文件名。参数越抽象攻击面越小。5.4 dialog 选择文件与保存的接入方式用户主动选择文件的场景必须走系统对话框不要让用户手打路径。dialog.showOpenDialog 和 showSaveDialog 都必须在主进程调用渲染进程通过 invoke 转发。ipcMain.handle(dialog:pickFile, async (event, filters) { const win BrowserWindow.fromWebContents(event.sender); const result await dialog.showOpenDialog(win, { properties: [openFile], filters: filters || [{ name: 文本文件, extensions: [txt, log, json] }], }); if (result.canceled || result.filePaths.length 0) return null; return result.filePaths[0]; });用 BrowserWindow.fromWebContents(event.sender) 拿到发起调用的窗口把它作为父窗口传进去这样对话框会以模态方式挂在正确的窗口上。不加这一步的话多窗口应用里对话框可能出现在另一个窗口上面用户会觉得莫名其妙。另外过滤器里的 extensions 不要带点写 txt 不是 .txt带了点在某些平台上过滤会失效这个坑我踩过一次排查了半天。6. 常见问题与排查实录6.1 问题速查表下面这些是我和同事在实际项目里真遇到过的按现象整理了排查方向遇到类似症状可以先对着看一眼现象最可能的原因排查与解决报 EMFILE: too many open files文件描述符泄漏循环里创建流没关闭用 pipeline 替代手动 pipefinally 里 destroyLinux 上可 ulimit 临时放宽定位报 ENOSPCLinux 上 inotify watch 用尽或磁盘满检查 watcher 是否泄漏调大 fs.inotify.max_user_watches读到的中文是乱码没有指定 encoding 或编码不对流和 writeFile 都显式指定 utf8非 UTF-8 文件用 iconv 转换配置文件变成 0 字节writeFile 非原子写入中断改成临时文件 fsync rename监听回调触发多次编辑器写临时文件再 rename或跨平台事件重复开启 awaitWriteFinish按文件路径做防抖渲染进程报 fs is not definedcontextIsolation 下没有 Node 访问权通过 preload 的 contextBridge 暴露方法不要在页面里 require打包后路径找不到开发环境是源码路径生产环境是 asar 包内路径用户数据写 app.getPath(userData)静态资源用 process.resourcesPath流处理偶发丢数据没有等 finish 就读取目标文件用 pipeline 的 Promise 或监听 finish 事件6.2 几条踩出来的经验关于 asar 那一条值得展开说。Electron 打包之后代码被塞进了一个 app.asar 归档文件它在文件系统层面是一个只读的虚拟目录。很多人在开发时把配置文件写在 __dirname 下面本地跑得好好的打包之后写入直接失败或者写成功了但每次更新应用就被覆盖。规则很简单只读的静态资源可以放 asar 里任何需要写入的东西一律放 userData。userData 的路径在三大平台上都是系统推荐的用户数据目录卸载时也不会误删。关于编码我还有一个教训。有一次用户上传了一个 GBK 编码的 CSV我用 utf8 读读出来的全是问号和乱码而且没有报错——因为 GBK 字节序列在 UTF-8 解码器里大部分会被替换成 UFFFD解码器不认为这是错误。所以文件编码不能靠猜要么让用户明确选要么用文件头 BOM 判断要么引入编码检测的库。这个坑的隐蔽之处在于它不报错只是数据悄悄地错了。还有一个经验是关于错误信息的。文件操作报错的时候Node 给的 message 往往只有一句 ENOENT: no such file or directory, open /some/path用户看到这个完全不知道该怎么办。我现在的做法是在主进程统一包一层把 err.code 映射成人话ENOENT 对应文件不存在或已被移动EACCES 对应没有权限访问该文件EBUSY 对应文件正被其他程序占用。同时在日志里保留原始的 code 和路径方便排查。这一层包装看似琐碎但能挡掉大量用不了的用户反馈。最后说一个我个人的习惯所有涉及用户数据的写操作我都会在开发阶段故意在写入过程中杀掉进程看看下次启动能不能正常恢复。这个测试比写多少单元测试都管用因为中断恢复这件事你在正常流程里永远测不出来。用临时文件加 rename 之后我做过大概二十几次随机的强杀测试没有出现过一次数据损坏这套方案算是被验证过了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑