Electron 的通知在鸿蒙 PC 上形同虚设,我直接弃用了
你有没有试过在 Electron 里弹一个通知,结果鸿蒙 PC 上啥都没出现?我一开始以为是代码写错了,调了半小时 new Notification() 的参数,最后发现——不是我没写对,是这玩意儿在鸿蒙上根本就没打算好好工作。
先上结论:如果你正在做 Electron + 鸿蒙 PC 的桌面应用,别指望 HTML5 Notification API 能给你带来任何一致性体验。 我最后把通知系统整个重写了一遍,用一套自研的 HybridNotifier 接管了所有平台。这篇文章告诉你为什么原生通知在鸿蒙上这么拉胯,以及我的替代方案长什么样。
坑一:鸿蒙 PC 根本不认你的通知权限
Electron 的 Notification 模块在 Windows 和 macOS 上都能正常申请权限,流程你也熟:
// main processconst { Notification } = require('electron');const notify = () => { if (!Notification.isSupported()) { console.log('不支持通知'); return; } new Notification({ title: '任务完成', body: '你的导出已经搞定了' }).show();};
这段代码在鸿蒙 PC 上跑,不会报错,也不会弹窗。 就像什么都没发生。你查日志,啥异常没有。你怀疑人生,开始怀疑自己的代码。
我翻了三天资料才搞明白:鸿蒙 PC 的桌面环境(OpenHarmony/鸿蒙 Next 的 PC 分支)没有实现 Chromium 的通知后端。 也就是说,Electron 底层调用 libnotify 或者 Windows 的 Toast API 时,鸿蒙那边根本没有对应的接收端。通知不是被拒绝了,而是直接掉进了黑洞。
更恶心的是,Notification.permission 在渲染进程里返回 "default",永远不会变成 "granted" 或 "denied"。你没法判断用户到底授权了没有,因为系统层面就没给你这个能力。
// renderer process — 在鸿蒙 PC 上永远返回 "default"console.log(Notification.permission); // "default"Notification.requestPermission().then(p => { console.log(p); // 还是 "default",永远不会变});
我一开始还傻乎乎地写了一套轮询逻辑,每隔 5 秒检查一次 permission,以为用户会在某个时刻突然授权。跑了两个小时,日志里全是 "default"。那一刻我觉得自己像个傻子。
坑二:主进程的通知样式丑到没法用
好,假设你放弃渲染进程的通知,改用主进程的 electron.Notification,情况会好一点吗?
会弹出来了,但丑得你不敢认。
鸿蒙 PC 的通知中心对原生 Toast 的支持非常基础。Electron 发过来的通知没有图标(icon 参数被忽略),没有动作按钮(actions 参数被忽略),甚至标题和正文的字体都大得离谱,像是系统默认的调试样式。
我试过一个最简配置:
new Notification({ title: '下载完成', body: 'report_2026_Q1.pdf 已保存', icon: path.join(__dirname, 'assets/icon.png'), // 被忽略 silent: false, timeoutType: 'default'}).show();
弹出来的效果是什么?一个白底黑字的方块,标题和正文一样大,没有图标,没有进度条,点一下就直接消失,没有任何回调。on('click') 事件在鸿蒙 PC 上大约 70% 的概率不触发,我测了 20 次,点了 14 次有反应,剩下 6 次跟没点一样。
这种可靠性,你敢用在生产环境?我是不敢。
顺便说一句,鸿蒙的文档排版真是……算了,不说也罢。你搜"鸿蒙 PC 通知 API",官方文档给的是鸿蒙原生 Ability 的 NotificationManager,跟 Electron 半毛钱关系没有。
坑三:通知堆叠和去重完全失控
在 Windows 上,你可以用 tag 参数来控制通知的去重:
new Notification({ title: '同步中', body: '正在处理第 3/10 个文件', tag: 'sync-progress' // 相同 tag 会替换旧通知}).show();
这个 tag 在鸿蒙 PC 上完全失效。 你每发一次通知,通知中心就多一条。同步 10 个文件,通知中心堆 10 个方块。用户要是没及时清理,任务栏右侧能叠出一个通天塔。
我试过自己维护一个通知 ID 映射表,发新通知前手动调用 close() 关掉旧的。结果 notification.close() 在鸿蒙上也有延迟,有时候旧通知关掉了,新通知还没出来,中间会出现一个空白闪烁。用户体验稀烂。
而且鸿蒙的通知中心没有"分组"或者"折叠"的概念。Windows 可以把同一个应用的通知收进一个抽屉,macOS 有堆叠,鸿蒙呢?平铺,全部平铺。 你的应用要是通知稍微频繁一点,用户打开通知中心看到的全是你的消息,第一反应就是卸载。
我的解决方案:HybridNotifier,一个自研的跨平台通知层
折腾了两天,我决定彻底弃用 Electron 原生的 Notification。 不是封装,不是适配,是弃用。我基于 Electron 的 BrowserWindow 写了一套自研通知系统,在鸿蒙 PC 上走自定义弹窗,在 Windows/macOS 上 fallback 到原生通知。
核心思路很简单:如果平台原生通知不可靠,就自己画一个。
架构设计
HybridNotifier├── 平台检测 (isHarmonyOS)├── 鸿蒙路径: 自定义 BrowserWindow 弹窗│ ├── 支持图标、进度条、动作按钮│ ├── 支持 tag 去重和堆叠动画│ └── 支持点击/关闭/超时回调└── 标准路径: electron.Notification(Win/Mac)
核心实现
// notifier/HybridNotifier.jsconst { BrowserWindow, ipcMain, screen } = require('electron');const path = require('path');class HybridNotifier { constructor(options = {}) { this.isHarmony = this.detectHarmonyOS(); this.activeNotifications = new Map(); // tag -> window this.queue = []; this.maxVisible = options.maxVisible || 3; this.gap = options.gap || 10; this.width = options.width || 360; this.height = options.height || 100; this.displayTime = options.displayTime || 5000; } detectHarmonyOS() { // 鸿蒙 PC 的 user agent 或 process.env 特征 const ua = process.env.USER_AGENT || ''; const platform = require('os').platform(); // 实际项目中可通过更可靠的方式检测,比如特定文件存在性 return platform === 'linux' && ua.includes('HarmonyOS'); } show(options) { if (this.isHarmony) { this.showCustom(options); } else { this.showNative(options); } } showNative(options) { const { Notification } = require('electron'); const n = new Notification({ title: options.title, body: options.body, icon: options.icon, silent: options.silent }); if (options.onClick) n.on('click', options.onClick); if (options.onClose) n.on('close', options.onClose); n.show(); } showCustom(options) { const tag = options.tag || `notify_{encodeURIComponent(html)}`); win.once('ready-to-show', () => { win.showInactive(); // 渐入动画可以通过 CSS 实现 }); // 超时自动关闭 const timer = setTimeout(() => { this.closeNotification(tag); }, options.duration || this.displayTime); // 监听渲染进程的交互事件 ipcMain.once(`notify-click-{tag}`, () => { clearTimeout(timer); this.closeNotification(tag); if (options.onClose) options.onClose(); }); this.activeNotifications.set(tag, win); // 窗口关闭时清理并检查队列 win.on('closed', () => { this.activeNotifications.delete(tag); this.processQueue(); }); } calculateX() { const { width: sw } = screen.getPrimaryDisplay().workAreaSize; return sw - this.width - 20; } calculateY() { const { height: sh } = screen.getPrimaryDisplay().workAreaSize; const count = this.activeNotifications.size; return sh - (this.height + this.gap) * (count + 1) - 40; } closeNotification(tag) { const win = this.activeNotifications.get(tag); if (win && !win.isDestroyed()) { win.close(); } } processQueue() { if (this.queue.length > 0 && this.activeNotifications.size < this.maxVisible) { const next = this.queue.shift(); this.showCustom(next); } } generateHTML(options) { const { title, body, icon, progress, actions = [] } = options; const iconHtml = icon ? `<img src="{progress}%;background:#3b82f6;height:100%;transition:width 0.3s;"></div> </div>` : ''; const actionsHtml = actions.map((a, i) => `<button onclick="actionClick({a.text}</button>` ).join(''); return `<!DOCTYPE html><html><head> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: rgba(255,255,255,0.98); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15); padding: 16px; width: 100vw; height: 100vh; overflow: hidden; animation: slideIn 0.3s ease-out; } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .container { display: flex; align-items: flex-start; } .content { flex: 1; } .title { font-size: 14px; font-weight: 600; color: #111; margin-bottom: 4px; } .body { font-size: 13px; color: #555; line-height: 1.4; } .actions { margin-top: 10px; text-align: right; } .close-btn { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 16px; cursor: pointer; color: #999; } </style></head><body onclick="window.notifyClick()"> <button class="close-btn" onclick="event.stopPropagation(); window.notifyClose()">×</button> <div class="container">{title}</div> <div class="body">{progressHtml} <div class="actions">{options.tag || Date.now()}'); window.notifyClose = () => ipcRenderer.send('notify-close-{options.tag || Date.now()}', idx); }; </script></body></html>`; }}module.exports = HybridNotifier;
使用方式
const HybridNotifier = require('./notifier/HybridNotifier');const notifier = new HybridNotifier();// 普通通知notifier.show({ title: '构建完成', body: 'v2.3.1 打包成功,耗时 4m 12s', tag: 'build-status', onClick: () => { // 打开构建日志目录 require('electron').shell.openPath('/path/to/logs'); }});// 带进度条的通知notifier.show({ title: '正在上传', body: 'report.pdf', tag: 'upload-progress', progress: 67, duration: 10000 // 进度通知停留久一点});// 带动作按钮notifier.show({ title: '检测到更新', body: 'v2.4.0 已发布,修复了 3 个崩溃问题', tag: 'update-available', actions: [ { text: '立即更新', action: 'update' }, { text: '稍后', action: 'dismiss' } ], onClick: () => startUpdate(), duration: 15000});
效果对比
说实话,这套自定义通知在 Windows 上其实没必要,原生通知够用了。但在鸿蒙 PC 上,这是唯一能保证用户体验的方案。
一个额外的坑:鸿蒙 PC 的 alwaysOnTop 也有问题
写 HybridNotifier 的时候我还踩了一个额外的坑:alwaysOnTop: true 在鸿蒙 PC 的某些版本上会让窗口无法接收点击事件。 鼠标移上去,通知窗口不响应,点击直接穿透到后面的应用。
我的 workaround 是加上 focusable: false 并且把窗口类型设为 type: 'notification'(Electron 在 Linux 上支持的窗口类型之一),同时把 skipTaskbar 和 showInactive 配合起来用,才勉强让点击事件正常捕获。
const win = new BrowserWindow({ // ... 其他配置 alwaysOnTop: true, focusable: false, // 关键:避免抢占焦点 type: 'notification', // Linux 窗口类型提示 // ...});
这个坑我查了半天 Electron 的 issue 列表,发现 Linux 通知窗口的 focus 问题从 2019 年就开始有人报,到现在都没彻底解决。鸿蒙 PC 基于 OpenHarmony 的图形栈,某种程度上继承了 Linux 桌面环境的这些老毛病。
最后
如果你也在做 Electron + 鸿蒙 PC 的适配,我的建议是:尽早放弃对原生通知的幻想。 不是 Electron 的错,也不是鸿蒙的错(好吧,有一部分是),而是这两个东西凑在一起,通知链路中间断了一环。
自己画一个通知弹窗,花不了你半天时间,但能给用户一个稳定、可控、可定制的体验。反正我以后不会再用 new Notification() 了,至少在鸿蒙 PC 上不会。
你遇到过类似的问题吗?或者你有更好的 workaround?欢迎留言。
本文遵循 MIT 协议,转载请注明出处。