本页目录

IPC 请求、事件、取消与错误合同

用完整可取消预览任务理解 invoke/handle、事件通知、发送者校验、超时与监听清理。

L2 · 能交付约 17 分钟阅读含示例、练习与验收
本页内容

从“调用一个函数”走向“发送一份请求”#

上一章的桥函数仍在 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,无需安装依赖。

task-core.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 };
task-check.cjs
'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 打开本地界面。

package.json
{
  "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" }
}
main.cjs
'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);
});
preload.cjs
'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);
    };
  }
});
index.html
<!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>
renderer.js
'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 });
sh
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。

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 教程ipcMainipcRendererwebContents安全指南。本章离线程序实际执行,Electron 代码做语法检查;没有付费接口、网络任务或已完成的 GUI 联调声明。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭