资讯动态

Electron文件系统操作实战:进程分层、IPC白名单与原子写入

发布时间:2026/10/27 7:18:06 来源:尧图企业网站定制
1. Electron 里做文件操作先搞清楚“谁在干活”写过 Node.js 脚本的人第一次进 Electron最容易犯的错就是把require(fs)直接写进渲染进程的页面脚本里然后在控制台看到一片红色报错回头怀疑是自己版本装错了。我当年也这么干过折腾了小半天最后才发现问题根本不在依赖而在于 Electron 的进程模型和权限边界跟纯 Node 环境完全是两套规则。这篇《利用 Node.js 模块在 Electron 中进行文件系统操作实战上》就聊这件事在 Electron 桌面应用里怎么用 Node.js 的fs、fs/promises和path做稳定、扛得住实际使用的文件读写包括模块该怎么选、进程该怎么分层、接口该怎么封、错误该怎么兜。先把结论摆出来方便你判断这篇值不值得往下读Electron 的文件系统操作本质上不是“会不会用 fs”的问题而是“你在哪个进程里用 fs、暴露了什么能力给谁、出了问题怎么兜”的问题。同样的readFile写在主进程里是一次普通的系统调用写在渲染进程里就是一条安全漏洞加上一堆兼容性麻烦。这篇内容适合三类人刚把网页项目塞进 Electron 壳子、发现本地文件读写完全跑不通的新手已经能用nodeIntegration: true跑起来、但被安全审计或者打包后的路径问题折腾过的中级开发者以及想把这套能力沉淀成项目内部规范、让团队里其他人照着抄的负责人。上篇聚焦四件事进程分层与权限模型、模块选型与版本约定、一套可复用的读写链路从零搭起来、以及真实踩过的错误码排查。至于文件监听、目录递归遍历、大文件分块传输、打包资源解包这些留到下篇再展开因为那些东西一展开就是另一整套逻辑混在一起讲只会让读者抓不住主线。1.1 三个运行位置三套权限模型Electron 应用里代码实际运行在三个位置主进程、预加载脚本、渲染进程。主进程是 Node.js 环境fs、path、os随便用想读哪里读哪里权限就是当前系统用户的权限。渲染进程则是一个 Chromium 页面它天生只认识 DOM 和 Web API默认拿不到 Node 的任何能力。预加载脚本夹在中间运行在页面加载之前能拿到一部分 Node 能力但它能拿到多少取决于窗口的sandbox配置。这个分层不是 Electron 故意刁难人。桌面应用的用户数据、配置文件、日志、导出目录这些东西属于系统级资源让一个可能加载了第三方脚本的页面直接持有删除文件的权力风险太高。所以官方给的路线是主进程负责真正的磁盘操作预加载脚本负责把“能力”以白名单的形式暴露给页面页面只调用被允许的那几个方法。这条链路多绕了一层但换来的是权限可控、行为可审计、出错可定位。我见过不少项目图省事直接在窗口配置里写nodeIntegration: true、contextIsolation: false代码确实能跑require(fs)也确实能读到文件。但只要项目里引入任何一个第三方前端库或者页面里渲染了用户可控的 HTML 内容这个配置就等于把整个文件系统交给了页面。后面想改回来会发现代码里到处都是直接调fs的地方重构成本高得吓人。所以我的建议很明确从第一天就按正规链路写别给自己挖坑。1.2 一个待办清单工具的读写需求拆解为了让后面的代码有落脚点这里设定一个具体场景做一个本地待办清单工具数据存在本地 JSON 文件里同时支持把清单导出成文本。这个场景看着简单实际把文件操作的绝大部分基础问题都覆盖了。它需要的文件能力大概是这几样读取某个 JSON 文件的文本内容把一段文本写回指定文件列出某个目录下的文件名和类型查一个路径是否存在、是不是文件、多大删除某个文件。这五件事对应readFile、writeFile、readdir、stat、unlink正好是日常用得最多的几个 API。数据文件的落点用app.getPath(userData)拿到的目录这个路径在 Windows 上是用户目录下的应用数据文件夹在 macOS 上是~/Library/Application Support/应用名跨平台不需要自己判断。这个场景还有两个隐含需求。第一个是写入要“稳”用户点了保存程序崩了或者断电了原文件不能被写坏成半截内容所以写入得走临时文件加替换的原子写法。第二个是路径要“收口”用户能操作的目录范围必须限定在一个工作区里不能让页面传一个../../../../etc/passwd就跑到系统目录去。这两点在后面第 4 章会具体实现。2. 项目骨架与依赖版本别在第一步就埋雷很多“Electron 文件操作跑不通”的问题根子不在代码逻辑而在骨架搭错了。版本组合不对、窗口参数配错、目录层级混乱这三样会让后面所有调试都变成猜谜。所以这一章先把地基打平把版本和目录说清楚后面的代码才有稳定的运行环境。2.1 初始化与目录约定从零开始的话最省事的做法是直接用官方的脚手架别自己手动拼package.json。命令大概是npm init electron-applatest my-todo-app它会帮你把主进程入口、预加载脚本、渲染页面的基本结构和构建配置都生成好。生成之后把package.json里的main字段指向主进程入口文件通常是src/main.js或者项目模板给的那个路径。目录我习惯这么分这套结构用了好几个项目改起来也方便my-todo-app/ ├─ src/ │ ├─ main/ │ │ ├─ index.js # 主进程入口创建窗口 │ │ └─ fs-service.js # 文件操作的全部实现 │ ├─ preload/ │ │ └─ index.js # 预加载脚本暴露白名单 API │ └─ renderer/ │ ├─ index.html │ └─ renderer.js # 页面逻辑只调 window.fileAPI ├─ package.json └─ electron-builder.yml这么分的好处很直接文件操作的所有代码集中在fs-service.js一个文件里主进程入口只负责注册 IPC预加载脚本内容极少只做转发渲染层完全不知道fs的存在。等到后面要加权限校验、加日志、加单元测试改动都集中在服务层不会牵连一大片。依赖方面electron装到当前稳定大版本即可node运行时的版本跟着 Electron 走不需要单独装。如果你在项目里用到了 Node 的新 API比如fs/promises的cp得确认 Electron 内置的 Node 版本够新——Electron 20 之后的版本内置 Node 16 以上fs/promises的常用方法都齐了再往上的新版本才有cp、glob这些。查版本很简单主进程里console.log(process.versions.node)打一下就知道了。2.2 主进程窗口配置里那三行关键参数创建窗口的时候有三个参数决定了整个文件操作链路能不能走通写错一个就白忙活。const win new BrowserWindow({ width: 1080, height: 720, webPreferences: { preload: path.join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false, sandbox: true } })contextIsolation: true让预加载脚本和页面脚本运行在互相隔离的上下文里页面没法篡改预加载脚本暴露出去的对象。nodeIntegration: false关掉页面的 Node 能力页面里require直接不可用。sandbox: true是 Electron 20 之后渲染进程的默认值它会把渲染进程放进系统沙箱。这里有个非常容易踩的坑值得单独拎出来说在sandbox: true的情况下预加载脚本里能require的东西是受限的只有electron和少数几个模块能用fs、path在预加载里直接require会报错。所以在沙箱模式下预加载脚本的正确写法是“只管转发不碰磁盘”所有fs调用都留在主进程。如果你确实需要在预加载里做路径拼接要么把sandbox关掉要么就老老实实把路径逻辑挪到主进程。注意网上大量教程给的还是nodeIntegration: true那套老写法能跑但不符合现在的安全模型。照抄之前先看看自己的sandbox和contextIsolation是什么值别混着用。3. fs、fs/promises、path模块选型的取舍逻辑Node 的fs模块给了三套 API同步版、回调版、Promise 版。三套都能完成同一件事但用在不同位置代码的可读性和出错概率差别很大。这一章把选型逻辑说清楚顺便把path模块里几个高频写错的地方点出来。3.1 同步 API、回调 API、Promise API 怎么选同步 API 的问题是阻塞事件循环。在主进程里事件循环一卡窗口的响应、IPC 的回包全都跟着停用户看到的就是“点了没反应”。读一个几 KB 的配置文件同步读大概几毫秒感觉不出来但要是碰上一个几 MB 的文件或者要连续读上百个小文件界面卡顿就非常明显了。我做过一次测试串行同步读 300 个 200KB 左右的小文件主进程阻塞接近 800 毫秒那段时间窗口拖都拖不动。回调 API 不阻塞但错误处理全靠err参数嵌套几层之后代码就没法看了。现在新写的代码基本不用它除非你在维护老项目。Promise 版是现在的默认选择。require(node:fs/promises)拿到的就是一套 Promise API配合async/await错误可以用try/catch统一处理链式调用也清爽。我现在的项目里除了少数几个场景基本全用这一套。那几个“少数场景”是什么第一应用启动阶段读配置这时候窗口还没显示阻塞一下无所谓同步读代码更短。第二写日志、写锁文件这种必须立刻落盘、且逻辑极短的场景同步写反而更可靠不会因为进程退出把异步任务丢掉。第三某些需要在模块顶层就完成的初始化await用不了只能同步。除了这三套 API还有一个东西值得单独提fs.constants。里面定义了一堆标志位比如打开文件的模式O_RDONLY、O_CREAT、O_EXCL复制文件的COPYFILE_EXCL、COPYFILE_FICLONE。后者挺有意思它会让系统尝试用写时复制的方式完成复制在支持的文件系统上几乎是瞬间完成不实际搬运数据。当然不是所有平台都支持失败了会自动降级成普通复制所以用起来没什么心理负担。3.2 path 模块里最容易写错的四个地方path模块看着简单实际是 bug 高发区尤其在跨平台的时候。我整理过项目里出现过的路径问题九成集中在下面四个地方。第一个是拼接方式。用字符串加号拼路径dir / name在 Windows 上会拼出混合分隔符的怪东西虽然大部分 API 能容错但显示给用户、写进日志、拿去比较的时候就会出问题。统一用path.join()它按当前平台的分隔符拼接还会顺手处理多余的分隔符。第二个是__dirname在打包后的含义。开发阶段它指向源码目录打包进 asar 之后它指向 asar 包内部那是个只读的虚拟文件系统。你在里面读写文件写操作必然失败。所以凡是涉及“要写入的位置”一律用app.getPath(userData)、app.getPath(temp)这些系统路径别用__dirname往下拼。第三个是路径穿越。用户传进来的相对路径必须用path.resolve转成绝对路径再用path.relative算出它相对于工作区根目录的位置。如果算出来的结果以..开头或者是绝对路径说明这个请求跑到工作区外面去了直接拒绝。这段校验逻辑后面会给出完整代码。第四个是大小写和符号链接。macOS 的文件系统默认不区分大小写Linux 区分Windows 也不区分但保留大小写。如果业务里需要判断“两个路径是不是同一个文件”别自己写字符串比较用fs.realpath把符号链接解开、拿到真实路径再比。另外path.normalize不会处理符号链接它只是整理字符串。3.3 大文件与流式读写的基本盘readFile一次性把整个文件读进内存返回一个 Buffer再转成字符串。对配置文件、几 KB 到几 MB 的 JSON这么干完全没问题。但文件一大问题就来了。假设有个 200MB 的文本文件。readFile会先分配一个 200MB 的 Buffer转成 UTF-8 字符串的时候V8 又要分配一块差不多大小的内存如果内容里有中文JavaScript 字符串是 UTF-16 编码占用还会翻倍。也就是说峰值内存可能到 500MB 以上。桌面应用本来就不该吃这么多内存加上 GC 压力很容易出现页面卡顿甚至进程崩溃。流式读写的思路是分块处理内存里只保留一小段。Node 的流默认的highWaterMark是 64KB读的时候每次往内存里放 64KB处理完再放下一块。还是那个 200MB 文件常驻内存从 200MB 降到 128KB 左右差别是数量级的。pipeline是流式处理里最省心的工具来自node:stream/promises。两个流接起来中间出错会自动清理用await等它跑完就行const { pipeline } require(node:stream/promises) const fs require(node:fs) async function copyByStream(src, dest) { await pipeline( fs.createReadStream(src, { highWaterMark: 64 * 1024 }), fs.createWriteStream(dest, { flags: w }) ) }highWaterMark这个参数我一般不动64KB 是默认值也是大多数场景下的平衡点。如果你的文件都是几百 MB 的大块头调到 256KB 或者 512KB 能少一些系统调用但收益有限还得考虑磁盘本身的吞吐。反过来如果文件都是几十 KB 的小文件用流反而是负担直接readFile更简单别为了“看起来专业”硬上流。4. 从零搭一套可复用的文件操作链路前面把原理和选型讲完了这一章动手。目标是把第 1.2 节说的那五件事做成一套能直接抄进项目的实现主进程里一个服务文件、预加载里一个白名单、页面里一个调用封装。每一步我会把“为什么这么写”说清楚尤其是那些看起来多余、实际能救命的部分。4.1 第一步主进程封装文件服务与路径白名单先建src/main/fs-service.js。这个文件对外只暴露几个函数内部处理路径校验、错误包装、编码统一。const { app } require(electron) const fsp require(node:fs/promises) const path require(node:path) const WORKSPACE_ROOT path.join(app.getPath(documents), my-todo-workspace) function resolveInside(relPath) { if (typeof relPath ! string || relPath.length 0) { throw Object.assign(new Error(路径不能为空), { code: E_BAD_PATH }) } const abs path.resolve(WORKSPACE_ROOT, relPath) const rel path.relative(WORKSPACE_ROOT, abs) if (rel.startsWith(..) || path.isAbsolute(rel)) { throw Object.assign(new Error(路径超出了工作区范围), { code: E_OUT_OF_SCOPE }) } return abs } async function readText(relPath) { const abs resolveInside(relPath) return await fsp.readFile(abs, utf8) } async function listDir(relPath .) { const abs resolveInside(relPath) const entries await fsp.readdir(abs, { withFileTypes: true }) return entries.map((e) ({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() })) } module.exports { readText, listDir, resolveInside, WORKSPACE_ROOT }这段代码里有三个设计点值得展开。WORKSPACE_ROOT用app.getPath(documents)往下拼好处是不同系统都能拿到一个用户可见、可备份的目录而且首次使用时目录可能不存在后面写文件的时候用mkdir递归创建就行。resolveInside是整个安全模型的核心。它先把用户给的相对路径转成绝对路径再算回相对于根目录的位置。如果结果是..开头说明跳出去了如果是绝对路径说明用户传了个盘符或者根路径进来也挡掉。这个校验必须放在每一次磁盘操作之前不能只在入口处做一次因为中间可能会被其他逻辑改写。返回值这里用了readdir的withFileTypes: true选项。它返回的每个条目是个Dirent对象自带isFile()、isDirectory()方法。相比先readdir拿名字、再对每个名字stat一次这种写法少了一轮系统调用。目录里文件多的时候差别很明显一千个文件省掉一千次stat在机械硬盘上能差出好几百毫秒。这是很多教程里没提的细节。4.2 第二步预加载脚本用 contextBridge 开白名单接口src/preload/index.js的内容要尽量少少到一眼能看完这样安全审计的时候不用翻逻辑。const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(fileAPI, { readText: (relPath) ipcRenderer.invoke(fs:readText, relPath), writeText: (relPath, content) ipcRenderer.invoke(fs:writeText, relPath, content), listDir: (relPath) ipcRenderer.invoke(fs:listDir, relPath), stat: (relPath) ipcRenderer.invoke(fs:stat, relPath), remove: (relPath) ipcRenderer.invoke(fs:remove, relPath) })contextBridge.exposeInMainWorld的作用是在隔离的上下文之间架一座桥把指定的对象挂到页面的window上。页面能拿到的只有这五个方法拿不到ipcRenderer本身也没法伪造其他通道。这就是白名单的含义只有写在这里的能力才存在没写的等于不存在。这里有个细节要注意暴露出去的函数参数必须是可结构化克隆的类型。字符串、数字、布尔、普通对象、数组、Uint8Array都行函数、类实例、流对象不行。所以传文件内容一律用字符串或者Uint8Array不要试图把读流对象从主进程传到页面那个东西克隆不过去。传大内容也别走这条路IPC 每次传输都要序列化和反序列化几百 KB 以上就该考虑分块或者写到临时文件再传路径。另一个细节是返回值。ipcRenderer.invoke返回的是 Promise主进程那边抛出的错误会被包装后 reject 到页面但错误信息会被加上一段前缀原始的code属性也会丢。所以我在主进程里统一把错误转成普通对象返回页面根据ok字段判断这样既能保留错误码也不会被 Electron 的错误包装干扰。下一小节给具体写法。4.3 第三步ipcMain.handle 注册与统一错误封装回到主进程入口把通道和处理函数接起来。这里我加了一层包装负责捕获异常、记录日志、把错误转成可传输的结构。const { ipcMain } require(electron) const service require(./fs-service) function wrap(handler) { return async (event, ...args) { try { const data await handler(...args) return { ok: true, data } } catch (err) { console.error([fs], err.code || , err.message) return { ok: false, code: err.code || E_UNKNOWN, message: err.message || 未知错误 } } } } ipcMain.handle(fs:readText, wrap((rel) service.readText(rel))) ipcMain.handle(fs:writeText, wrap((rel, content) service.writeText(rel, content))) ipcMain.handle(fs:listDir, wrap((rel) service.listDir(rel))) ipcMain.handle(fs:stat, wrap((rel) service.stat(rel))) ipcMain.handle(fs:remove, wrap((rel) service.remove(rel)))这层包装看着只是加了几行try/catch实际价值在于把错误处理的策略从“散落在每个调用点”变成“集中在一处”。磁盘操作的错误类型就那几种ENOENT文件不存在、EACCES权限不足、EPERM操作被拒绝、EBUSY文件被占用、ENOSPC磁盘满、EISDIR拿目录当文件用。集中处理之后日志格式统一前端拿到的错误码也统一排查问题的速度能快不少。还有一个容易被忽略的点ipcMain.handle同一个通道只能注册一次重复注册会直接抛错。开发阶段热重载的时候如果模块被重新加载很容易撞上这个问题。解决办法是把注册逻辑放在应用启动时只执行一次的位置或者在开发环境下先ipcMain.removeHandler(channel)再注册。我倾向于前一种结构上更干净。提示event.senderFrame这个属性可以用来校验请求来自哪个页面。如果你的应用会加载多个窗口或者允许加载外部页面建议在wrap里加一层判断只接受来自自己主窗口的请求。这是纵深防御的一环成本很低。4.4 第四步渲染进程的调用封装与超时兜底页面这边不要直接调window.fileAPI再包一层。原因有三个统一处理ok字段、加上超时、把错误转成用户能看懂的话。async function callWithTimeout(fn, timeout 8000) { let timer const timeoutPromise new Promise((_, reject) { timer setTimeout(() { reject(Object.assign(new Error(操作超时), { code: E_TIMEOUT })) }, timeout) }) try { return await Promise.race([fn(), timeoutPromise]) } finally { clearTimeout(timer) } } async function readText(rel) { const res await callWithTimeout(() window.fileAPI.readText(rel)) if (!res.ok) throw Object.assign(new Error(res.message), { code: res.code }) return res.data }超时兜底这件事在磁盘操作里比想象中重要。用户把工作区放在一个网络盘或者外接硬盘上磁盘响应慢、或者刚好被系统占用readFile可能挂很久。页面如果没有超时用户看到的就是一个转圈永远不结束的加载状态除了强杀进程没别的办法。加个 8 秒超时超时之后提示“文件读取超时请检查文件是否被其他程序占用”体验立刻不一样。超时时间给多少合适得看具体场景。读几百 KB 的配置3 到 5 秒足够写大文件或者导出一堆文件8 到 15 秒比较合理。别设得太短机械硬盘读写稍微慢一点就误报也别干脆不设那样等于把进程的命交给运气。错误转成用户语言这部分我给一张简单的映射思路ENOENT说“文件不存在可能已被移动或删除”EACCES和EPERM说“没有权限访问这个位置试试换个文件夹”ENOSPC说“磁盘空间不足”EBUSY说“文件正被其他程序占用关掉之后重试”。用户不需要知道什么叫 errno他们只需要知道下一步该干什么。4.5 第五步原子写入与并发写入的控制写入这块writeFile直接用会有个隐患程序在写入过程中意外退出文件就成了半截内容原来的数据也没了。做法是先写临时文件成功之后再替换这就是常说的原子写入。async function writeText(relPath, content) { const abs resolveInside(relPath) const dir path.dirname(abs) await fsp.mkdir(dir, { recursive: true }) const tmp path.join(dir, .${path.basename(abs)}.${process.pid}.${Date.now()}.tmp) let handle try { handle await fsp.open(tmp, w) await handle.writeFile(content, utf8) await handle.sync() } finally { if (handle) await handle.close() } await fsp.rename(tmp, abs) }几个关键点。临时文件必须放在和目标文件同一个目录下因为rename在跨文件系统时会报EXDEV只有同一分区内才是原子替换。文件名里带上进程号和毫秒时间戳避免多个写入任务撞名。handle.sync()是把数据真正刷到磁盘不做这一步系统崩溃时可能只写进了页缓存文件看着存在但内容是空的。sync的代价是慢机械硬盘上可能要几十毫秒。所以它适合“用户主动保存”这种低频操作不适合高频写日志。日志场景我一般用追加写加定期刷盘或者干脆接受丢最后几行的风险。在 Windows 上还有个已知现象目标文件正在被另一个程序打开时rename会报EPERM。这不是代码写错了是操作系统的文件锁机制。处理办法是捕获这个错误提示用户关掉占用的程序再重试或者退一步改成直接覆盖写。并发写入也得管。用户连着点五次保存或者页面里一个循环发起了二十个写请求同时打开的文件句柄一多在 macOS 和 Linux 上很容易撞到EMFILE。系统对单进程能打开的文件描述符数量有限制ulimit -n查一下常见配置是 1024看着挺多但每个写操作要占一两个句柄加上应用本身的其他占用几百个并发就能打满。我的做法是加一个简易的并发池限制同时进行的写入任务不超过 8 个function createLimiter(max) { let active 0 const queue [] const next () { if (active max || queue.length 0) return active const { fn, resolve, reject } queue.shift() fn().then(resolve, reject).finally(() { active-- next() }) } return (fn) new Promise((resolve, reject) { queue.push({ fn, resolve, reject }) next() }) } const limitWrite createLimiter(8)并发度给 8 是个经验值。SSD 上再高一点也没事但收益递减机械硬盘上并发太高反而会让磁头来回跳总耗时变长。真要调就写个基准测试跑一遍 1 个、4 个、8 个、16 个并发的总耗时选拐点。我测过的几台机器上8 到 12 之间基本就是最优点。5. 常见问题与排查技巧实录上面那套代码跑起来之后日常开发基本够用了。但真正的麻烦往往在打包之后、在换一台机器之后、在用户的环境里出现。这一章把踩过的坑按类型整理出来附上排查思路。5.1 路径类报错打包前后两套路径开发阶段一切正常打包之后一运行就报ENOENT这是最典型的一类问题。原因多半是代码里用了__dirname往下拼路径去找可写目录。开发时__dirname是源码目录能读能写打包后它指向 asar 包内部那是个只读的虚拟文件系统写操作必然失败读操作也可能因为文件没被打进去而失败。排查方法很简单在报错的地方把app.isPackaged、process.resourcesPath、app.getPath(userData)三个值都打出来对比一下开发和生产环境的差异。凡是需要写入的路径全部改成基于app.getPath拼接确实需要读取打包进去的静态资源用process.resourcesPath加上相对路径同时确认构建配置里把这些文件标成了不打包进 asar 的外部资源。第二种路径问题是分隔符。Windows 上路径用反斜杠如果代码里写死了/去做字符串截取或者正则匹配就会出错。统一用path.sep、path.join、path.parse这些工具处理别自己写字符串操作。第三种是大小写。macOS 上文件名不区分大小写从数据库里读出来的路径写成了Data.json实际文件叫data.json在 macOS 上能打开到 Linux 上就找不到。跨平台项目里文件名一律用小写加连字符养成习惯能省很多事。5.2 编码与权限乱码、EACCES、EPERM、EMFILE乱码通常出在两处。一处是读写编码不一致readFile没传编码参数拿到的是 Buffer直接当字符串用就是一堆乱码。另一处是文件本身不是 UTF-8比如从某些旧系统导出的文本是 GBK 编码用 UTF-8 读必然乱。判断方法先按 Buffer 读进来看前三个字节是不是EF BB BF那是 UTF-8 的 BOM 头有的话读的时候要跳过不是 BOM 又出现乱码基本可以确定是别的编码需要转换。权限类错误里EACCES和EPERM容易混。EACCES是权限不足比如目标目录属于其他用户、或者文件被设成了只读EPERM是操作不被允许比如试图删除一个正在被使用的文件、试图在只读文件系统上写入。两者的处理方式不一样前者要换目录或者改权限后者要关掉占用的程序。EMFILE是文件描述符耗尽前面提过。除了加并发限制还可以查一下有没有忘记关闭的句柄。fsp.open拿到的FileHandle必须closecreateReadStream用完要destroy忘记这些句柄泄漏会随着运行时间累积最后整个应用卡死。ENOSPC是磁盘满这个错误在写入之前没法可靠预判只能在捕获之后提示用户清理空间。有个小技巧写大文件之前先用statfs查一下可用空间虽然fs/promises没有直接暴露这个接口但可以调fs.statfs或者用checkDiskSpace这类思路提前拦一道体验会好一些。5.3 高频问题速查表与几条我踩过的坑把上面这些整理成一张表出问题的时候对着查比翻日志快。错误码 / 现象常见原因处理方式ENOENT路径拼错、文件被删、打包后指向 asar 内部打印实际绝对路径改用app.getPath拼可写目录EACCES目录权限不足、文件只读换目录或提示用户调整权限EPERM文件被占用Windows 常见、只读文件系统提示关闭占用程序或改用覆盖写EBUSY目标文件正被其他进程使用重试或写入时换新文件名ENOSPC磁盘空间不足提示清理空间写入前预检查EMFILE并发过高、句柄未关闭加并发池检查FileHandle.closeEISDIR把目录当文件读/写操作前用stat判断类型EXDEV跨分区rename临时文件与目标放同一目录打包后读不到资源资源被打进 asar配置解包或用process.resourcesPath乱码编码不一致、有 BOM显式指定编码检测并跳过 BOM表里最后两条不是标准错误码但实际出现的频率一点不低所以一并列上。再说几条表里写不下、但我觉得挺值钱的经验。第一所有文件操作函数在开发阶段都加一行日志打印入参和耗时上线前用开关关掉。文件操作的 bug 复现成本高有日志能省太多时间。第二别在渲染进程里做任何路径拼接哪怕只是name .json也放到主进程去做因为分隔符和非法字符的处理在主进程里更可靠。第三写测试的时候用os.tmpdir()建临时目录测完删掉别在项目目录里留一堆测试残留那些东西会跟着构建一起打进去。第四也是我吃过亏的一条rename覆盖已存在的文件在大多数情况下是原子的但在某些网络文件系统上不保证。如果你的工作区可能被放在网络盘上写入策略要降级成“写临时文件、删旧文件、重命名新文件”并且做好中间失败的回滚。这个场景不常见但一旦碰上用户看到的就是文件凭空消失非常吓人。我个人在几个项目里反复用这套结构最大的感受是文件操作本身不难难的是把边界划清楚。哪些目录能碰、谁能发起调用、出错之后怎么表达给用户这三件事想明白了代码写起来反而很快。上篇先停在读写和通信这层文件监听、目录递归遍历、大文件分块传输这些留到下一篇聊那些东西涉及的面更宽混在一起讲反而容易乱。

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

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

免费获取报价 →
↑