IPC 请求、事件、取消与错误合同
用完整可取消预览任务理解 invoke/handle、事件通知、发送者校验、超时与监听清理。
本页内容
从“调用一个函数”走向“发送一份请求”#
上一章的桥函数仍在 renderer 所属环境执行。现在我们让页面请求主进程完成一个耗时预览任务,并在等待时展示进度、允许取消。任务只把小段文本转成大写,不读文件、不联网,也不调用模型。简单业务能把通信机制暴露出来:结果如何返回、错误如何表达、取消是否真的到达任务、页面关闭后谁清理资源。
本章前置是能区分 main、preload 与 renderer,并理解桥的复制边界。目标不是记住两个 API 名字,而是能定义一个完整请求合同。你需要明确允许哪个页面调用、输入最大多大、成功与失败有什么形状、结果属于哪次操作,以及谁负责停止已经不再需要的工作。
进程之间没有共享普通变量的承诺。页面构造的对象经过序列化送到另一侧,主进程拿到的是可处理的数据,不是页面对象的活引用。把这一点想清,才能理解为什么不能传 DOM 节点、为什么 Error 不能直接承担完整业务合同,以及为什么取消要传请求编号而不是传一个闭包函数。
两种通信形式及其返回合同#
ipcRenderer.invoke(channel, ...args) 与 ipcMain.handle(channel, listener) 配对。invoke 返回 Promise,handle 可以同步返回值,也可以返回 Promise;最终值成为调用结果。适合“读取设置”“生成预览”这种一次请求对应一次最终结果的操作。通道名由应用定义,Electron 不理解 preview:run 的业务含义,也不会默认验证输入。
ipcRenderer.send 与 ipcMain.on 配对,表达发送一条消息,没有自动关联的业务返回值。主进程若需要反向通知,必须显式发送消息。适合进度事件、状态通知或本章的取消意图。不要在 on 回调中 return 一个对象,然后期待页面的 send 自动拿到它;这是两种不同合同。
从主进程发送消息可以用 webContents.send;event.reply 会回复原始发送框架,而 event.sender.send 发向对应页面的主框架。本例只接受特定窗口的主框架,所以向该 webContents 发送进度与合同一致。若支持 iframe,就必须重新设计目标与权限,不能只把主框架检查删掉。
不采用 sendSync。同步 IPC 会阻塞调用侧,主进程若又等待页面工作,容易形成卡顿甚至相互等待。熟悉同步函数并不意味着桌面桥应该同步;页面可以用加载状态表达异步等待,主进程则应返回明确可终止的任务结果,而不是用阻塞换取表面上直线式的代码。
数据传输与错误不能靠猜测#
Electron IPC 使用结构化克隆相关机制,原型链不会作为业务身份保留;函数、Promise、Symbol、WeakMap、WeakSet 以及部分 DOM 和 Electron 特殊对象不能这样发送。最容易维护的合同是小型普通数据对象。Vue 的响应式代理也应先转换成明确字段的 DTO,避免把整个组件状态树当成请求。
handle 中抛出的异常会使 invoke 拒绝,但页面拿到的 Error 不是原来的对象,自定义属性不应被当成稳定传输合同。我们将预期失败返回为 {ok:false,error:{code,message}},成功返回为 {ok:true,value}。传输失败仍可能使 Promise 拒绝,页面需要 catch;两层错误各自存在,不要只处理其中一种。
本例限定请求为 {requestId,text}:编号是长度有界的字符串,文本最多一千个 UTF-16 单元。编号用于关联进度、取消与最终结果,不是用户身份证明,也不是持久化幂等键。本例只有只读预览,完成后重复编号不会造成磁盘副作用;以后执行写入时,应另行定义重复请求的业务语义。
实验一:可离线执行的任务内核#
建立 ipc-lab 目录,先保存 task-core.cjs 和 task-check.cjs。内核不依赖 Electron,因而可先验证取消和发送者规则;真实 Electron 的窗口与框架身份仍需要宿主联调。执行 node task-check.cjs,无需安装依赖。
'use strict';
const { setTimeout: delay } = require('node:timers/promises');
function isTrusted(event, owner, expectedURL) {
// 必须先排除已销毁窗口和不存在的发送框架。
if (!owner || owner.isDestroyed() || !event.senderFrame) return false;
const contents = owner.webContents;
return event.sender === contents
&& event.senderFrame === contents.mainFrame
&& event.senderFrame.url === expectedURL;
}
function validRequest(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).length === 2
&& typeof value.requestId === 'string'
&& /^[a-zA-Z0-9-]{8,80}$/.test(value.requestId)
&& typeof value.text === 'string'
&& value.text.length > 0 && value.text.length <= 1000;
}
async function runPreview(text, { signal, onProgress, delayMs = 400 }) {
for (let step = 1; step <= 3; step += 1) {
// 真正把信号交给等待接口;只设置一个 cancelled 变量不会停止等待。
await delay(delayMs, undefined, { signal });
signal.throwIfAborted();
onProgress({ step, total: 3 });
}
signal.throwIfAborted();
return { text: text.toUpperCase(), units: text.length };
}
module.exports = { isTrusted, validRequest, runPreview };
'use strict';
const assert = require('node:assert/strict');
const { isTrusted, validRequest, runPreview } = require('./task-core.cjs');
async function main() {
const frame = { url: 'file:///app/index.html' };
const contents = { mainFrame: frame };
const owner = { isDestroyed: () => false, webContents: contents };
assert.equal(isTrusted({ sender: contents, senderFrame: frame }, owner, frame.url), true);
assert.equal(isTrusted({ sender: contents, senderFrame: { url: frame.url } }, owner, frame.url), false);
assert.equal(isTrusted({ sender: contents, senderFrame: null }, owner, frame.url), false);
assert.equal(validRequest({ requestId: 'request-1', text: 'hello' }), true);
assert.equal(validRequest({ requestId: 'request-1', text: 'a', path: '/etc' }), false);
const progress = [];
const controller = new AbortController();
const output = await runPreview('hello', {
signal: controller.signal, delayMs: 1, onProgress: value => progress.push(value.step)
});
assert.deepEqual(progress, [1, 2, 3]);
assert.equal(output.text, 'HELLO');
const cancelled = new AbortController();
let calls = 0;
await assert.rejects(runPreview('hello', {
signal: cancelled.signal, delayMs: 1,
onProgress: () => { calls += 1; cancelled.abort(); }
}), error => error.name === 'AbortError');
assert.equal(calls, 1);
console.log('通过:发送者、请求形状、三步完成与中途取消');
}
main().catch(error => { console.error(error); process.exitCode = 1; });
内核通过传入 signal 和 onProgress 与外界协作。它不关心 DOM,也不保存全局窗口;因此同一逻辑可以离线验证,再由主进程提供通信适配。delayMs 默认四百毫秒只是本教材制造可见进度的参数,不是 Electron API 默认值;测试改成一毫秒,验证事件顺序而不耗费真实等待时间。
实验二:完整 IPC 应用#
在同一目录追加以下五个文件。task-core.cjs 是上面已经完整给出的真实依赖,task-check.cjs 仅用于离线验证,不参与窗口运行。首次执行 npm install 会下载 Electron 44.3.0 二进制,然后 npm start 打开本地界面。
{
"name": "electron-ipc-lab",
"version": "1.0.0",
"private": true,
"main": "main.cjs",
"scripts": { "start": "electron .", "check": "node task-check.cjs" },
"devDependencies": { "electron": "44.3.0" }
}
'use strict';
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { isTrusted, validRequest, runPreview } = require('./task-core.cjs');
const pagePath = path.join(__dirname, 'index.html');
const pageURL = pathToFileURL(pagePath).href;
const jobs = new Map(); // 每个 webContents 同时只允许一个任务
let mainWindow = null;
const fail = (code, message) => ({ ok: false, error: { code, message } });
function stopJob(contentsId, reason) {
const job = jobs.get(contentsId);
if (job && !job.controller.signal.aborted) {
job.reason = reason;
job.controller.abort();
}
}
ipcMain.handle('preview:run', async (event, request) => {
if (!isTrusted(event, mainWindow, pageURL)) return fail('FORBIDDEN', '来源不被允许');
if (!validRequest(request)) return fail('BAD_INPUT', '请求字段或长度不合法');
const ownerId = event.sender.id;
if (jobs.has(ownerId)) return fail('BUSY', '当前窗口已有任务');
const job = {
requestId: request.requestId,
controller: new AbortController(),
reason: 'CANCELLED'
};
jobs.set(ownerId, job);
const deadline = setTimeout(() => stopJob(ownerId, 'TIMEOUT'), 5000);
try {
const value = await runPreview(request.text, {
signal: job.controller.signal,
onProgress(progress) {
if (!isTrusted(event, mainWindow, pageURL)) {
stopJob(ownerId, 'CANCELLED');
return;
}
event.sender.send('preview:progress', {
requestId: request.requestId, ...progress
});
}
});
return { ok: true, value };
} catch (error) {
if (job.controller.signal.aborted) {
return fail(job.reason, job.reason === 'TIMEOUT' ? '任务超时' : '任务已取消');
}
console.error('预览任务失败:', error);
return fail('INTERNAL', '预览失败');
} finally {
clearTimeout(deadline);
if (jobs.get(ownerId) === job) jobs.delete(ownerId);
}
});
function onCancel(event, requestId) {
if (!isTrusted(event, mainWindow, pageURL) || typeof requestId !== 'string') return;
const job = jobs.get(event.sender.id);
if (job && job.requestId === requestId) stopJob(event.sender.id, 'CANCELLED');
}
ipcMain.on('preview:cancel', onCancel);
function createWindow() {
const win = new BrowserWindow({
width: 780, height: 560,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true, sandbox: true, nodeIntegration: false
}
});
mainWindow = win;
const contentsId = win.webContents.id; // 销毁后不再读取对象属性
win.webContents.on('will-navigate', event => event.preventDefault());
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('did-start-navigation', details => {
if (details.isMainFrame && !details.isSameDocument) stopJob(contentsId, 'CANCELLED');
});
win.webContents.on('render-process-gone', () => stopJob(contentsId, 'CANCELLED'));
win.webContents.on('destroyed', () => stopJob(contentsId, 'CANCELLED'));
win.on('closed', () => {
stopJob(contentsId, 'CANCELLED');
if (mainWindow === win) mainWindow = null;
});
void win.loadFile(pagePath).catch(error => { console.error(error); app.quit(); });
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
}).catch(error => { console.error(error); app.quit(); });
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('will-quit', () => {
for (const id of jobs.keys()) stopJob(id, 'CANCELLED');
ipcMain.removeHandler('preview:run');
ipcMain.removeListener('preview:cancel', onCancel);
});
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('previewAPI', {
run: request => ipcRenderer.invoke('preview:run', request),
cancel: requestId => ipcRenderer.send('preview:cancel', requestId),
onProgress(callback) {
if (typeof callback !== 'function') throw new TypeError('需要回调函数');
let active = true;
const wrapped = (_event, data) => {
if (!active) return; // 已进入派发快照的监听也不再转发
// 不把 Electron event 对象传入网页。
if (data && typeof data.requestId === 'string'
&& Number.isInteger(data.step) && data.total === 3) {
callback({ requestId: data.requestId, step: data.step, total: data.total });
}
};
ipcRenderer.on('preview:progress', wrapped);
return () => {
if (!active) return;
active = false;
ipcRenderer.removeListener('preview:progress', wrapped);
};
}
});
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'; connect-src 'none'">
<title>IPC 预览任务</title>
<script src="./renderer.js" defer></script>
</head>
<body>
<main>
<h1>文本预览</h1>
<label for="text">待处理文本</label>
<textarea id="text" maxlength="1000">hello electron</textarea>
<button id="start" type="button">生成预览</button>
<button id="cancel" type="button" disabled>取消任务</button>
<p id="status" role="status">等待开始</p>
<pre id="result"></pre>
</main>
</body>
</html>
'use strict';
const api = window.previewAPI;
const startButton = document.querySelector('#start');
const cancelButton = document.querySelector('#cancel');
const status = document.querySelector('#status');
const result = document.querySelector('#result');
let activeId = null;
const unsubscribe = api.onProgress(progress => {
if (progress.requestId !== activeId) return; // 旧任务不能覆盖当前页面状态
status.textContent = '处理中 ' + progress.step + '/' + progress.total;
});
startButton.addEventListener('click', async () => {
if (activeId !== null) return;
const requestId = crypto.randomUUID();
activeId = requestId;
startButton.disabled = true;
cancelButton.disabled = false;
status.textContent = '开始处理';
result.textContent = '';
try {
const reply = await api.run({
requestId, text: document.querySelector('#text').value
});
if (activeId !== requestId) return;
status.textContent = reply.ok ? '完成' : reply.error.message;
if (reply.ok) result.textContent = reply.value.text;
} catch {
if (activeId === requestId) status.textContent = '通信失败,请重新打开窗口后重试';
} finally {
if (activeId === requestId) {
activeId = null;
startButton.disabled = false;
cancelButton.disabled = true;
}
}
});
cancelButton.addEventListener('click', () => {
if (!activeId) return;
api.cancel(activeId);
cancelButton.disabled = true;
status.textContent = '正在请求取消';
});
window.addEventListener('pagehide', () => {
if (activeId) api.cancel(activeId);
activeId = null;
unsubscribe();
}, { once: true });
node task-check.cjs
npm install
npm start
预期正常任务出现三次进度,最终显示大写文本;处理中取消,最终显示“任务已取消”;空文本得到输入错误。页面只在最终回复后结束加载状态,“正在请求取消”不提前冒充任务已停止。真实 GUI、窗口重载与跨进程消息时序尚未启动验收;离线内核测试和脚本语法是本轮已有证据。
为什么发送者需要三重检查#
只比较 URL 不够,因为另一个窗口或子框架也可能出现相同地址;只比较 webContents 不够,因为消息可能来自它的子框架;只检查“协议是 file”也不够,因为不同本地文件不应因此共享全部权限。我们检查预期窗口对象、当前主框架身份与精确页面 URL,拒绝 senderFrame 缺失的情况。
本地 file URL 的 origin 常不能区分具体文件,因此这里使用 pathToFileURL 构造可信完整 URL,而不是手工拼接或只比较 origin。示例没有路由 hash 与查询参数,精确匹配足够。以后换成自定义 app 协议时,应按那个协议的可信来源和路径重新定义合同,不能简单把本例 URL 改成 startsWith('app:')。
验证只证明请求来自允许的页面位置,不证明页面上每段代码都可信。若页面存在 XSS,注入脚本仍可能调用允许的桥方法。因此动作本身也要限权、限长、限制并发。发送者检查与最小能力互相补充,不能选其中一项后宣布另一项不需要。
取消、超时与结果竞态如何闭环#
取消消息携带请求编号,并且只允许取消同一发送者拥有的当前任务。若省略所有者检查,一个窗口就可能取消另一个窗口的任务;若不检查编号,旧任务的迟到取消可能误伤新任务。主进程 jobs 表是任务权威状态,页面 activeId 则是 UI 关联状态,两者负责不同问题。
发送取消只是意图。底层 delay 接收 AbortSignal,真正中断等待;最终 invoke 回复才告诉页面任务结束。Promise.race 超时只能停止等待结果,若没有同时中断底层任务,工作仍会继续消耗资源。本例使用主进程超时调用同一个中止机制,避免把“页面不再等待”误写为“任务已停止”。
finally 清理超时器和任务表,保证成功、取消、失败都走到资源释放。删除前比较 job 身份,防止旧任务清理后来放到同一个键上的新任务。每个窗口最多一个任务和文本长度上限也让资源消耗有界;这些是教学应用选择的限制,并非 Electron 内置的默认配额。
导航开始时取消当前文档任务,renderer 崩溃和 webContents 销毁也触发取消,不能只依赖 pagehide。当前官方导航事件提供 details.isMainFrame 与 details.isSameDocument;旧教程的位置参数已标记弃用,应按使用版本核对。页内导航不替换文档,真实重载则需要断开旧任务归属。
实验三与练习:订阅必须只清理自己#
练习实现一个事件适配器:页面拿到纯数据与取消函数,收不到底层 event;取消自己不会删掉其他订阅;重复取消无副作用。提示是保存包装后的函数引用,removeListener 必须拿同一个引用,而不是重新写一个看起来相同的箭头函数。
参考答案:完整离线监听清理验证
保存 subscription-check.cjs,执行 node subscription-check.cjs。
'use strict';
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
function subscribe(callback, emitter = bus) {
let active = true;
const wrapped = (_event, data) => {
if (!active) return; // removeListener 不撤销已经开始的 emit 快照
callback({ step: data.step });
};
emitter.on('progress', wrapped);
return () => {
if (!active) return;
active = false;
emitter.removeListener('progress', wrapped);
};
}
const first = [];
const second = [];
const stopFirst = subscribe(data => first.push(data));
const stopSecond = subscribe(data => second.push(data));
bus.emit('progress', { secret: '底层对象' }, { step: 1 });
stopFirst();
stopFirst();
bus.emit('progress', { secret: '底层对象' }, { step: 2 });
assert.deepEqual(first, [{ step: 1 }]);
assert.deepEqual(second, [{ step: 1 }, { step: 2 }]);
assert.equal(bus.listenerCount('progress'), 1);
stopSecond();
assert.equal(bus.listenerCount('progress'), 0);
// 更早的监听器在同一次 emit 中取消后面的订阅。
const inFlightBus = new EventEmitter();
let stopLate = () => {};
let lateCalls = 0;
const earlier = () => stopLate();
inFlightBus.on('progress', earlier);
stopLate = subscribe(() => { lateCalls += 1; }, inFlightBus);
inFlightBus.emit('progress', {}, { step: 3 });
assert.equal(lateCalls, 0);
inFlightBus.removeListener('progress', earlier);
assert.equal(inFlightBus.listenerCount('progress'), 0);
console.log('通过:只传数据、独立取消、幂等清理与同轮派发取消');
Node 事件派发开始后会按当轮监听快照执行,removeListener 不能撤回已经进入快照的回调。因此包装函数先检查 active,取消函数先把 active 设为 false,再移除监听;离线回归专门覆盖较早监听器在同一轮派发中取消后面订阅的情况。这样“取消后不再转发给页面”才由适配器自己保证,而不是错误归因于 off 的行为。
Vue 集成时,可以在组件挂载阶段订阅,在卸载时调用返回的取消函数。不要在每次渲染中重复订阅,也不要使用 removeAllListeners 清掉同一通道所有消费者。主进程 handle 属于应用级服务,示例注册一次;窗口重建只更新允许的窗口引用,不重复注册同一 handle。
沿着一次完整请求检查每一个交接点#
页面先生成编号并进入等待状态,再调用桥。桥固定使用预览通道,主进程收到后先检查发送者,再检查输入,最后登记任务。这个顺序有实际意义:来自不允许页面的请求不应该先触发文件探测或复杂校验;输入不合法的请求不应该先占用一个任务槽位;任务开始等待前必须登记,否则紧接着到达的取消消息找不到它。
主进程完成一步工作后发出进度,页面只接受当前编号。最后一步完成后,主进程返回最终结果并清理任务;页面收到结果,才把状态切换到完成或失败。进度与最终结果用途不同:进度帮助人理解等待,最终结果决定这次操作是否成立。不能因为看到了第三步进度,就在页面提前写“保存成功”或开启依赖最终数据的操作。
还要考虑取消发生得太晚。若任务已经完成,取消消息到达时任务表中已经没有对应工作,主进程忽略它,最终成功结果仍可能到达页面。这不是违反取消合同,而是说明取消通常是尽力中止尚未完成的工作,不能倒转已经发生的结果。对支付、发邮件或磁盘覆盖等副作用,还必须单独定义提交点和补偿,不能把本章只读预览的取消方式机械套上去。
本例在用户点击取消后显示“正在请求取消”,是为了保留这层不确定性。如果底层无法中止,正确界面应说明正在等待任务结束,而不是立即显示“已取消”又在几秒后弹出结果。用户看到的状态应该对应有证据的阶段,这与前端管理 HTTP 请求的原则一致,但桌面场景还多了一层主进程资源。
输入大小、任务数量与进度频率是三个不同限制#
输入长度限制防止一次请求搬运过多数据;并发限制防止同一窗口一次创建大量任务;进度频率限制防止大量小消息挤满页面处理队列。只有其中一个并不足够。例如每次文本都很短,但页面每毫秒启动一次任务,仍可能耗尽主进程资源;任务只有一个,但每处理一个字节就发送进度,也可能使页面不断重排。
本例固定三次进度和一个活动任务,因此这些数量天然可解释。以后处理真实文档时,可以按时间节流或按块汇总进度,并把总长度、最大任务数和最大队列长度写进合同。主进程不应相信按钮禁用能限制请求数量,因为页面代码可以绕过按钮直接调用桥。限制必须在掌握资源的一側再次执行。
大型文档还涉及内存峰值。页面先持有一份字符串,跨桥和 IPC 又可能产生复制,主进程解析时再创建中间结构;文件本身只有几十兆,并不意味着整个流程只占同样大小的内存。设计导入功能时,更合理的方式可能是主进程根据用户选择直接读取并处理文件,只把页面需要的摘要或分块送回,而不是先把整份文件绕页面传一圈。
把传输错误和业务错误画成两条路径#
业务失败说明请求已经到达服务,服务按合同作出了拒绝。例如文本为空、当前窗口已有任务、用户取消或任务超时,都可以用稳定错误码表达。页面通常能展示明确提示,并知道是否允许重新输入或重试。错误码不应依赖中文文案,这样界面翻译不会改变程序分支。
传输错误则可能发生在请求到达前或回复返回时:通道没有处理器、对象无法序列化、renderer 已销毁等。此时调用者不一定知道主进程是否已经做过部分工作。对本章只读预览可以安全重试;对有副作用的操作,盲目重试可能重复执行。需要请求幂等键、结果查询或恢复协议的原因,正是这种“没收到结果不等于没执行”。
日志也要服务于定位而不是泄漏内容。记录请求编号、操作名、持续时间和稳定错误码,通常已经能关联一次故障;把全部笔记正文、绝对磁盘路径或用户凭证写进终端,会扩大敏感数据暴露面。本例内部错误只在主进程记录,页面获得简短提示。生产中还应控制日志保存位置、保留时间和访问权限,这些并不由 IPC 自动提供。
对消息的可信度保持精确理解#
主进程发来的数据来自应用内部服务,但页面仍可以做形状检查,帮助发现版本不一致或程序错误。preload 的进度适配器只摘出编号、步数和总步数,不把整个对象原样透传。这里的校验是合同完整性保护,不是把主进程当成网络攻击者;不同层检查的目的可以不同。
同样,发送者校验是基于 Electron 提供的事件对象,而不是让页面在 payload 里写一个 trusted: true。客户端自报的来源、角色或权限都只是输入,不能作为授权证据。若以后增加账号登录,窗口来源检查与账号权限还需要同时成立:可信页面也可能代表一个没有当前文档权限的用户。
当前示例的错误结果不携带堆栈,也不把原始系统异常字符串直接呈现给用户。系统异常可能包含文件路径或内部模块名,且不同平台文案不稳定。主进程需要保留诊断信息,页面需要稳定可理解的结果,这两种输出应各自选择合适内容。不是所有信息都应该为了调试方便跨越边界。
如何把这个实验迁入 Vue#
迁移时首先保留 task-core 和主进程服务,然后把页面变量改为组件状态。组件挂载时订阅一次进度,卸载时取消当前请求并取消订阅;编号仍用于阻止旧结果写入新状态。不要在 watchEffect 每次重跑时都追加监听,也不要因为组件已卸载就假设主进程一定知道。
把桥接口作为外部依赖封装,还能在浏览器开发模式使用明确的假实现。但假实现必须保留相同输入、输出与取消语义,不能直接返回一个字符串,而真实接口返回联合结果。测试替身越贴近合同,越能帮助发现界面状态错误;它仍然不能代替 Electron 发送者、序列化与窗口生命周期的真实联调。
常见错误、掌握标准与来源#
没有注册 handle、通道拼错或序列化失败,都可能使 invoke 拒绝;BAD_INPUT 则是服务正常收到请求后的业务拒绝。前者看通信日志,后者看参数合同。若取消后又显示成功,先检查底层是否接收信号,以及 UI 是否核对请求编号,不要先靠延长按钮禁用时间掩盖竞态。
掌握标准是可以画出请求、进度、取消和最终结果的顺序,并分别指出窗口、请求与订阅的资源所有者。自测一:on 回调 return 能成为 send 的结果吗?不能。自测二:AbortController 放在页面里就自动取消主进程工作吗?不能,必须有取消消息和主进程执行配合。自测三:同一个 file origin 能代表同一个可信页面吗?不能,应按明确窗口、框架与入口规则验证。
官方来源为 IPC 教程、ipcMain、ipcRenderer、webContents 与 安全指南。本章离线程序实际执行,Electron 代码做语法检查;没有付费接口、网络任务或已完成的 GUI 联调声明。