在渲染进程里敲下require('fs'),控制台回你一句require is not defined,这个瞬间几乎是每个刚接触 Electron 的人都会经历的。明明装了 Node,明明在命令行里node -e "require('fs')"跑得好好的,怎么一进窗口就歇菜了?这个问题的答案,牵扯出的是 Electron 最核心的一套设计:进程模型、上下文隔离、预加载脚本、IPC 通道,以及 Node.js 文件系统模块在新版本里被层层收紧的调用边界。这篇内容就是围绕这条链路展开的——从"为什么不行"讲到"应该怎么做",再到"怎么做得稳"。适合已经能跑起一个空白 Electron 窗口、准备动手做本地文件读写(配置文件、日志、用户导入导出、批量处理)的开发者,也适合那些代码能跑但总觉得哪里别扭、想搞清楚底层逻辑的人。文中涉及的版本行为以较新的 Electron 为准,老版本会有差异,我会在关键处标注。
1. 渲染进程里的 require 为什么突然就没了
1.1 两个进程,两套运行环境
Electron 应用启动后至少存在两类进程。主进程跑在纯 Node.js 环境里,没有 DOM、没有window,但有完整的fs、path、child_process;渲染进程跑在 Chromium 里,有 DOM、有window、有document,但默认情况下 Node.js 的那套东西被彻底拿掉了。这不是 bug,是刻意为之。渲染进程加载的页面本质上是网页,网页的来源可能是本地 HTML,也可能是某个远程地址,一旦允许它直接调用文件系统,等于把用户整块硬盘的操作权限交给了一段可能被注入的脚本。
很多人记忆里的"Electron 可以直接用 Node"来自很早的版本。那时候nodeIntegration默认是开的,渲染进程里require('fs')畅通无阻。从 Electron 5 开始这个默认值翻转成false,Electron 12 又把contextIsolation的默认值改成true,两道闸门先后落下,老教程里的写法就集体失效了。所以你看到的require is not defined,其实是安全策略生效的正常表现,不是环境装错了。
这里有个容易被忽略的细节:即使在nodeIntegration: true的老配置下,渲染进程拿到的fs也是"完整版",没有任何路径限制。一段第三方脚本只要被执行,就能遍历用户主目录、读取浏览器配置、写入启动项。这类风险在内部工具里可能无所谓,但只要你的应用会分发出去,就不该留这个口子。
1.2 三种方案的真实取舍
要在渲染进程里完成文件操作,常见三条路,各自的代价差别很大。
| 方案 | 配置 | 优点 | 代价 |
|---|---|---|---|
| 打开 nodeIntegration | nodeIntegration: true, contextIsolation: false | 写法最直接,require随便用 | 安全边界完全消失,页面里任何脚本都能操作磁盘 |
| 主进程独揽 + IPC | 默认配置即可 | 权限收口在主进程,可控性最强 | 需要设计通道,异步交互写法略绕 |
| preload 里直接操作 fs | sandbox: false+ preload | 调用链短,渲染层写起来像本地函数 | preload 仍在渲染侧,一旦页面被攻破仍有暴露面 |
我自己的项目里基本只用第二种。原因很现实:文件操作天然是异步的、需要错误处理的、需要和 UI 状态联动的,把它放在主进程里反而让代码结构更清晰——主进程负责"能干哪些事",渲染进程负责"什么时候要干"。第一种方案只在临时调试、一次性脚本、内部演示这类场景里用,用完就删。第三种方案适合那种"读一个配置值""存一个偏好设置"的轻量场景,功能一多,preload 文件会迅速膨胀成一个难以维护的杂货铺。
还有一个折中做法值得提一句:把常用的文件操作封装成一个小型的服务模块,放在主进程侧,preload 只做通道转发,业务语义全部通过通道名表达,比如config:read、export:write、log:append。这样渲染进程完全不知道底下是fs还是别的实现,将来换成数据库或者远程存储,上层代码一行都不用改。
1.3 被沙箱悄悄掐掉的 preload
这里有个坑,踩过的人不在少数。Electron 20 之后,preload 脚本默认运行在沙箱环境里(sandbox: true),此时 preload 能require的模块被限制在一个很小的白名单内:electron、events、timers、url之类。你在 preload 顶部写const fs = require('fs'),控制台直接报module not found,而且报错位置很隐蔽,容易被误判成路径写错了。
解决办法有两个:一是webPreferences.sandbox = false,让 preload 回到完整的 Node 环境;二是干脆不在 preload 里碰fs,把所有文件操作推到主进程。我倾向后者,因为关掉沙箱等于主动放弃一层防护,而这道防护恰恰是成本最低的那层。如果确实需要在前端做一些同步的路径拼接,path模块在沙箱里也不可用,最省事的做法是在 preload 里手写几个字符串工具函数,或者让主进程算好路径再回传。
顺带说一个排查技巧:当你在 preload 里遇到"某个模块明明存在却加载不到"的情况,先打印一下process.versions和process.sandboxed,确认当前 preload 到底跑在哪种模式下,比翻文档快得多。
2. 搭一条能长期用的 IPC 文件通道
2.1 preload 只暴露动词,不暴露工具
设计通道时最容易犯的错,是把fs的每个方法都包一层暴露出去,结果window.api.readFile、window.api.writeFile、window.api.readdir、window.api.stat……一路铺开,等于在渲染层复刻了一遍 Node 的 API。这样做有两个问题:一是暴露面太大,渲染层能干的坏事一件没少;二是把实现细节焊死在了接口上,将来你想加个缓存、加个审计、换成加密存储,全都得改前端。
更合理的做法是按业务动词来切。比如一个笔记类应用,暴露的应该是note:load、note:save、note:list、note:remove,每个通道内部该用readFile还是readdir是主进程的自由。通道名统一加前缀,既方便在日志里过滤,也方便做权限分组——将来要做"某些窗口只允许读不允许写",按前缀匹配就行。
// preload.js const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('noteApi', { load: (id) => ipcRenderer.invoke('note:load', id), save: (id, content) => ipcRenderer.invoke('note:save', { id, content }), list: () => ipcRenderer.invoke('note:list'), remove: (id) => ipcRenderer.invoke('note:remove', id), });注意这里用的是invoke/handle而不是send/on。invoke返回 Promise,天然带请求-响应语义,错误也能通过 reject 传回渲染层;send/on是单向广播,做请求响应得自己维护一个请求 ID 映射表,纯属给自己找麻烦。Electron 7 之后invoke/handle已经足够稳定,新项目没有理由再用老写法。
2.2 主进程侧的参数校验不能省
通道建好之后,主进程收到的参数是"来自渲染层"的,必须当成不可信输入对待。就算整个应用都是你自己写的,也得防住两种意外:一是渲染层传了undefined或者null,主进程里path.join(base, undefined)直接抛TypeError;二是拼接出来的路径跑出了预期目录,比如传个../../,文件就落到别处去了。
一个简单可靠的校验方式,是先算绝对路径,再判断它是否还在允许的根目录下:
const path = require('path'); const fs = require('fs/promises'); const ROOT = path.join(app.getPath('userData'), 'notes'); function resolveSafe(id) { if (typeof id !== 'string' || !/^[\w-]{1,64}$/.test(id)) { throw new Error('INVALID_ID'); } const target = path.resolve(ROOT, id + '.md'); const rel = path.relative(ROOT, target); if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error('OUT_OF_SCOPE'); } return target; }这段代码里有两层保护。第一层用正则把 id 限制在字母数字下划线短横线,长度不超过 64,直接堵死路径穿越字符;第二层用path.relative做兜底校验,即使正则被绕过(比如将来放宽了字符集),越界路径也会被拦下。第二层是必须的,因为正则这种东西随着需求变化很容易被改松,而路径校验是硬约束。
2.3 错误怎么从主进程传回渲染层
ipcMain.handle里抛出的错误,渲染层await时会被包装成一个 Error,但message前面会被加上一段类似Error invoking remote method 'note:load':的前缀,原始的err.code也会丢。这意味着渲染层没法直接判断"是文件不存在还是权限不够"。
两种处理方式。一种是在主进程里把错误规范化成普通对象再返回,比如统一返回{ ok: true, data }或{ ok: false, code: 'ENOENT', message: '文件不存在' },渲染层判断ok字段即可。另一种是让错误消息带上机器可读的前缀,渲染层用正则抠出来。我更推荐前者,虽然多一层包装,但接口语义清晰,也不会因为 Electron 内部错误包装格式变化而崩掉。
// main.js ipcMain.handle('note:load', async (_e, id) => { try { const file = resolveSafe(id); const content = await fs.readFile(file, 'utf8'); return { ok: true, data: content }; } catch (err) { return { ok: false, code: err.code || err.message, message: err.message }; } });统一返回值结构还有个额外好处:渲染层的类型定义好写,TypeScript 里一个联合类型就能覆盖,不用为每个通道单独描述异常路径。
3. fs 的三种调用风格,什么时候用哪个
3.1 同步 API 在主进程里是毒药吗
fs.readFileSync用起来实在太舒服了,一行搞定,不用 await,不用 try-catch 包 Promise。于是很多人在主进程里大量使用同步 API,然后发现应用启动时窗口白屏几百毫秒,或者打开一个大文件时整个界面卡住。
原因很直接:主进程是单线程的事件循环,同步 IO 会把整个循环堵死,而主进程同时负责窗口管理、菜单、托盘、IPC 分发。它一堵,所有窗口的消息响应都跟着延迟。所以判断标准很简单——这段代码会不会在用户可感知的时机执行。启动时读一个几 KB 的配置文件,堵塞时间在微秒级,用同步完全没问题,反而让初始化逻辑线性可读;读一个几十 MB 的用户数据文件,必须异步。
我自己的习惯是:应用启动阶段的配置读取、版本号读取、内置资源加载,用同步;任何由用户操作触发的文件读写,一律异步。这条分界线在项目里跑两年都没出过问题。
3.2 回调、Promise、还是流
Node 的fs模块同时提供三套 API,很多人第一次见会懵。
| 风格 | 入口 | 适用场景 | 注意点 |
|---|---|---|---|
| 回调 | fs.readFile(p, cb) | 维护老代码 | 错误优先回调,嵌套多了难读 |
| Promise | require('fs/promises').readFile | 绝大多数业务逻辑 | 推荐,配合 async/await |
| 流 | fs.createReadStream(p) | 大文件、需要边读边处理 | 必须处理 error 和背压 |
回调风格现在基本只在读老代码时遇到,新写的代码没必要再用。Promise 风格从 Node 10 起稳定,直接require('fs/promises')或者require('fs').promises都行,前者更干净。
流的价值在文件超过一定体积后才体现出来。经验阈值大概在几 MB:低于这个量级,readFile一次性读进内存,代码简单,性能也不差;超过几十 MB,尤其是要处理图片、音视频、大型 JSON 或者日志文件时,流式处理几乎是唯一选择,因为一次性读进来可能直接把内存打爆,而且用户要等到全部读完才能看到第一个字节。
流的写法有个常见错误:只监听data事件而不处理error,一旦读取中途出错(文件被删、磁盘拔出),进程会抛未捕获异常直接崩掉。正确的姿势是用pipeline,它会自动处理错误传播和流销毁:
const { pipeline } = require('stream/promises'); const { createReadStream, createWriteStream } = require('fs'); await pipeline( createReadStream(src), createWriteStream(dest) );stream/promises的pipeline从 Node 15 起可用,Electron 对应版本都比较新,放心用。老版本可以用require('stream').pipeline加util.promisify包一层。
3.3 flag、encoding、mode 这几个参数别瞎传
fs.writeFile(file, data, options)的第三个参数看起来是可选的,实际上它决定了行为差异很大。
flag默认是'w',意思是"存在就截断重写,不存在就创建"。这个默认值在多数场景下是对的,但有两种情况会出事。一是追加日志,必须用'a',否则每次写都把之前的日志清空;二是"只在不存在时创建",必须用'wx',文件已存在时直接抛EEXIST,这个特性可以用来做简易的锁文件或者防重复初始化。
还有'r+'(读写,不存在则报错)和'w+'(读写,存在则截断),后者在需要"先写后立刻读回校验"的场景里用得上,但要注意截断是发生在打开的那一刻,不是你写入的时候。
encoding不传的时候,readFile返回的是Buffer,writeFile接受字符串时会按 utf8 编码。这里有个隐蔽的坑:读一个 GBK 编码的文本文件,用默认 utf8 读出来是乱码,而且不会报错。判断编码需要额外的库或者 BOM 探测,如果你的应用要处理用户导入的文本,这一步不能省。另外带 BOM 的 UTF-8 文件读出来开头会多一个\uFEFF,做 JSON 解析时会直接报错,记得content.replace(/^\uFEFF/, '')处理一下。
mode参数在 Windows 上基本不起作用,在 Linux 和 macOS 上才生效。默认 0o666(受 umask 影响),如果写的是包含敏感信息的文件(比如本地缓存的凭证),显式传{ mode: 0o600 }会更稳妥。
4. 路径这件事,Electron 里错一次够你查半天
4.1 app.getPath 的每个目录到底该放什么
Electron 提供了app.getPath(name)来拿系统标准目录,这些路径在不同平台上解析结果不同,但语义一致。用错目录的后果是:开发时一切正常,打包分发后用户发现数据"消失"了,或者被系统安全策略拦下。
| 名称 | 典型用途 | 说明 |
|---|---|---|
userData | 应用配置、数据库、缓存 | 最常用,打包后按应用名独立目录 |
appData | 同级父目录 | 存放多个应用共享的数据时才用 |
temp | 临时文件 | 系统可能定期清理,不能存持久数据 |
logs | 日志目录 | 需要先调app.setAppLogsPath()才会创建 |
downloads | 用户下载目录 | 导出文件时的默认位置 |
documents | 文档目录 | 用户可见的文件放这里 |
home | 用户主目录 | 除非明确需要,不要往里写东西 |
exe | 可执行文件所在目录 | 安装目录通常只读,不能当数据目录 |
一个高频错误是把用户数据写到path.dirname(app.getPath('exe')),也就是安装目录旁边。开发时因为项目目录可写,感觉没问题;打包安装到系统目录后,普通用户权限下这个位置往往不可写,writeFile直接抛EACCES。更糟的是某些平台会把安装目录做成只读挂载,连管理员都改不了。
正确的做法是:用户产生的数据一律进userData,需要让用户看到的导出文件走dialog.showSaveDialog让用户自己选位置,临时中间文件进temp并且用完就删。这三条分清楚,跨平台基本不会出问题。
4.2 __dirname 在开发和打包后根本不是一回事
开发时__dirname指向源码目录,你能看到src/main.js和旁边的assets/;打包后代码被塞进app.asar,__dirname变成.../resources/app.asar/main。看起来只是路径变了,实际上行为差异不小。
第一,asar 包内部是只读的。你在开发时往__dirname旁边写文件很顺畅,打包后直接EROFS或者EACCES。第二,fs.readdir在 asar 里能正常工作,但fs.stat返回的 size 是虚拟的,某些依赖文件大小的逻辑会失灵。第三,任何需要真实文件路径的外部程序(比如调用系统播放器打开视频)都无法直接读取 asar 内的文件,必须先解压到临时目录。
所以判断标准是"这个文件在打包后还在不在原位":内置的只读资源(图标、模板、默认配置)放 asar 里,用__dirname相对定位没问题;运行时要修改的东西,一律走app.getPath('userData');需要交给外部程序处理的文件,先fs.copyFile到temp再传路径。
4.3 path.join、path.resolve 和那个反斜杠
path.join('a', 'b')是把片段拼起来,path.resolve('a', 'b')是从当前工作目录出发算出绝对路径。差异在于后者会受process.cwd()影响,而process.cwd()在 Electron 里取决于应用是怎么启动的——从命令行启动是启动目录,从桌面图标启动可能是用户主目录。所以任何跟process.cwd()有关的路径逻辑都是不可靠的,我在自己的代码里基本禁用path.resolve处理相对片段,只保留它做"把相对路径转绝对"的兜底。
另一个跨平台问题是分隔符。Windows 用反斜杠,macOS 和 Linux 用正斜杠。path.join会自动处理,但如果你手写字符串拼接dir + '/' + file,在 Windows 上虽然多数 Node API 也能吃,但一旦这个路径要传给前端做显示、存进数据库做对比、或者拼进 URL,反斜杠就会引发各种诡异问题。
稳妥的做法是:内部处理路径全程用path.join和path.resolve,需要持久化或者跨进程传递时统一转成"正斜杠形式"(用path.posix或者手动replace(/\\/g, '/')),显示给用户时再转回本地格式。另外 macOS 默认文件系统大小写不敏感但可配置,Linux 敏感,Windows 不敏感,所以做文件去重或者路径比较时,永远不要假设"大小写不同就是两个文件"。
5. 读写之外的日常操作:遍历、批量、进度
5.1 readdir 的两种返回值和递归遍历
fs.readdir(dir)默认只返回文件名数组,要判断是文件还是目录还得再stat一次,一个目录下 1000 个条目就是 1000 次额外系统调用。现代写法是传{ withFileTypes: true },返回的Dirent对象直接带isFile()、isDirectory()、isSymbolicLink(),一次调用拿全信息。
const entries = await fs.readdir(dir, { withFileTypes: true }); for (const ent of entries) { if (ent.isDirectory()) { await walk(path.join(dir, ent.name)); } else if (ent.isFile() && ent.name.endsWith('.md')) { files.push(path.join(dir, ent.name)); } }递归遍历时有两个必须处理的情况。一是符号链接,如果目录里有指向父目录的软链,简单的递归会无限循环直到栈溢出。withFileTypes拿到的isSymbolicLink()可以提前跳过,或者用fs.realpath解析后记录已访问路径去重。二是权限,某些系统目录(比如带特殊标记的缓存目录)在遍历时会抛EACCES,一个目录读不了不该中断整个遍历,必须 try-catch 包住单个条目继续往下走。
批量处理还有一层并发控制要考虑。直接Promise.all几百个文件的读取,会瞬间打开几百个文件句柄,轻则触发系统限制(Linux 默认 1024),重则整个进程卡死。经验做法是限制并发数在 8 到 16 之间,用一个简单的信号量或者分批处理,既跑满 IO 又不会撑爆句柄。
5.2 大文件的进度上报
用户点了"导入一个大文件",界面上什么都没有,他会以为软件卡死了。这时候需要把进度从主进程推到渲染层。invoke/handle是请求-响应模式,做不了持续推送,得配合webContents.send。
// main.js ipcMain.handle('import:file', async (e, filePath) => { const stat = await fs.stat(filePath); const total = stat.size; let done = 0; const stream = createReadStream(filePath); stream.on('data', (chunk) => { done += chunk.length; e.sender.send('import:progress', { done, total }); }); await pipeline(stream, createWriteStream(dest)); return { ok: true }; });e.sender是发起请求的那个窗口,用它发进度事件比用全局BrowserWindow.getAllWindows()[0]靠谱,多窗口场景下不会串台。渲染层用ipcRenderer.on('import:progress', ...)接收,记得在组件卸载时removeListener,否则窗口切换几次之后监听器会叠加,进度条会以几倍速跳。
进度上报的频率也需要控制。每读一个 chunk 就发一次,对于 64KB 的 chunk 来说意味着一秒钟可能发上千条 IPC 消息,渲染层处理不过来反而更卡。实际做法是加个节流,比如累计到 100ms 或者进度变化超过 1% 才发一次。
5.3 原子写入:别让崩溃留下半个文件
直接writeFile覆盖一个重要配置文件,如果写入过程中程序崩溃或者断电,用户会得到一个被截断的文件,下次启动解析失败,配置全丢。这个问题在小文件上概率低,但只要用户量上去,一定会遇到。
原子写入的思路是:先写到同目录下的临时文件,写完之后用rename覆盖目标。rename在同一个文件系统内是原子操作,要么成功要么不变,不存在中间状态。
async function writeAtomic(target, data) { const tmp = target + '.tmp-' + process.pid; await fs.writeFile(tmp, data, 'utf8'); try { await fs.rename(tmp, target); } catch (err) { await fs.unlink(tmp).catch(() => {}); throw err; } }这里三个细节容易漏。临时文件必须和目标在同一目录,跨目录可能跨挂载点,rename会抛EXDEV。临时文件名要带进程 ID 或者随机后缀,避免两个进程同时写互相覆盖。rename失败时记得清理临时文件,否则目录里会堆一堆垃圾。
Windows 上还有个特殊情况:如果目标文件正被另一个程序打开着,rename会抛EPERM或EBUSY。这种情况没有通用的解决法子,只能在业务层加个重试,或者提示用户关闭占用程序。
6. 错误码比错误消息有用得多
6.1 按 err.code 分流处理
Node 的文件系统错误对象上都有code属性,这是唯一可靠的判断依据。message是给人看的,格式可能随平台和版本变化;code是稳定的,跨平台一致。
| code | 含义 | 常见处理方式 |
|---|---|---|
ENOENT | 文件或目录不存在 | 首次运行场景,创建默认文件 |
EACCES | 权限不足 | 提示用户换位置或提权 |
EPERM | 操作被系统拒绝 | Windows 上多为文件被占用 |
EEXIST | 目标已存在 | 配合wxflag 做存在性判断 |
EISDIR | 期望文件却是目录 | 参数类型校验没做 |
ENOTDIR | 路径中间某段是文件 | 路径拼接出错了 |
ENOSPC | 磁盘空间不足 | 提示用户清理磁盘 |
EBUSY | 文件正被占用 | 重试或提示关闭程序 |
EROFS | 只读文件系统 | 写到了 asar 或系统目录 |
实际编码时最有价值的是ENOENT。很多应用第一次启动时配置文件不存在,这是完全正常的,不该当成错误弹窗。判断逻辑写成"读不到就当空配置,然后立即创建一个默认的",用户完全无感知。反过来EACCES和ENOSPC必须明确告知用户,因为这两类问题用户自己能解决,藏着掖着只会让他反复尝试。
EROFS值得单独说一句。如果你在打包后的应用里看到这个错,八成是路径落到了app.asar内部或者安装目录,回头检查一下是不是哪里用了__dirname拼路径。这个错在开发环境永远不会出现,只在真实安装包里冒出来,所以打包测试必须做,不能只跑npm start。
6.2 存在性判断的两种写法
想判断文件存不存在,fs.exists是最直觉的写法,但它已经废弃很久了,原因是存在竞态:你查完到用它之间,文件可能已经被删了。正确做法是"直接操作,捕获 ENOENT":
try { const content = await fs.readFile(file, 'utf8'); return content; } catch (err) { if (err.code === 'ENOENT') return null; throw err; }这叫 EAFP(先做再求原谅),比 LBYL(先查再做)少一次系统调用,也没有竞态窗口。如果确实需要提前判断(比如要在 UI 上显示状态),用fs.access(file, fs.constants.F_OK)配合 try-catch,或者fs.stat顺便把大小、修改时间一起拿到。
还有一个隐蔽的坑:fs.access和fs.stat在某些特殊文件系统(网络盘、挂载点)上可能返回成功,但实际读写时仍然失败。所以任何"检查通过"的结果都不能当成绝对保证,真正的读写操作依然要包 try-catch。
6.3 符号链接和权限的边界情况
用户目录里存在符号链接是很常见的,尤其在做开发相关工具时。fs.readFile会跟随符号链接读到目标文件,这在多数时候是期望行为;但fs.rm删除一个指向目录的软链时,默认只删链接本身,除非传了recursive才可能误删目标内容。生产环境里删除操作一定要先lstat看一眼是不是软链,确认清楚了再动手。
权限方面,Linux 和 macOS 上还有一层"文件属性"(immutable flag),被设置了不可变属性的文件连 root 都改不了,writeFile会抛EPERM。这种情况基本只出现在用户手动设置过或者某些同步工具留下的文件上,处理方式只能是提示用户。
还有一个跨平台的坑:路径长度。Windows 传统上有 260 字符的路径限制(除非启用了长路径支持),用户如果导入一个层级很深的目录,拼出来的路径超长时,readFile会抛ENAMETOOLONG。做批量导入功能时,最好在写入前检查一下目标路径长度,超长的改成短哈希文件名。
7. 一些不太适合写进文档的实战体会
先说最实在的一条:把文件读写全部收口到一个storage模块里,不要让fs的调用散落在各个业务逻辑中。我接手过一个项目,fs.writeFile在十几个文件里各写各的,有的传 encoding 有的不传,有的用绝对路径有的用相对路径,最后排查一个编码 bug 花了大半天。收口之后,编码策略、错误处理、原子写入这些横切关注点只在一个地方维护,改动成本降了一个数量级。
第二条是关于测试的。文件操作这类代码,最容易在"本机跑得好好的,用户电脑上出问题"上翻车。我的做法是在开发阶段就给自己准备三套环境变量:一个把数据目录指到临时目录(跑自动化测试),一个指到带中文和空格的路径(验证路径处理),一个指到只读目录(验证错误提示)。这三个场景覆盖了绝大多数线上反馈的问题类型,尤其是中文路径——encodeURI、decodeURI和path混用的时候,中文目录名出问题的概率相当高。
第三条是关于版本兼容。Electron 的 Node 版本绑定在框架里,你没法像纯 Node 项目那样自由升级。fs.rm的recursive选项、fs.cp、stream/promises的pipeline,这几个 API 的可用版本都不一样。写代码前先console.log(process.versions.node)确认一下,比照着文档写完再发现方法不存在要省事得多。如果项目需要兼容较老的 Electron 版本,建议在启动时做一次能力探测,缺哪个补哪个的 polyfill,而不是在业务代码里到处写if (fs.rm) ... else ...。
最后提一个容易忽略的点:fs.watch和fs.watchFile的可靠性差异很大,前者依赖系统通知机制,后者靠轮询。在 Linux 上fs.watch对某些编辑器保存文件的方式(先写临时文件再改名)会漏事件,在 macOS 上递归监听的支持又和 Windows 不一样。文件监听、系统对话框、拖拽导入,以及打包后 asar 相关的那些特殊处理,涉及的面比读写本身更杂,展开讲篇幅会很长,下一次单独聊这块。