Electron for 鸿蒙PC - 自定义菜单栏组件深度剖析
前言
在将 MarkText(一款 Electron 桌面 Markdown 编辑器)适配到鸿蒙 PC 平台的过程中,我们遇到了一个严重的技术障碍:鸿蒙系统不允许从子窗口再创建子窗口,导致 Electron 原生菜单最多只能支持 2-3 级嵌套。对于功能丰富的桌面应用来说,这个限制是致命的。
本文将详细记录我们如何通过完全自定义的 HTML/CSS/JavaScript 菜单栏来突破这一限制,实现了支持任意层级嵌套的菜单系统,完美解决了 MarkText 在鸿蒙 PC 上的菜单问题。
关键词:鸿蒙PC、Electron适配、自定义菜单、子窗口限制、跨平台

欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/
目录
鸿蒙PC平台的菜单限制
1.1 错误现象
当我们首次在鸿蒙 PC 上运行 MarkText 时,控制台出现了这样的错误:
[ERROR:ohos_popup.cc(129)] Cannot create subwindow from another subwindow
表现:
- 一级菜单可以正常显示(如"文件"、“编辑”)
- 二级菜单勉强可以显示(如"文件"下的"打开文件")
- 三级及以上菜单完全无法显示(如"文件" → “最近打开” → 具体文件列表)
1.2 技术原因
根据 Electron 官方文档,Electron 的原生菜单实现原理:
主窗口 (BrowserWindow)
└─> 菜单栏 (Menu)
└─> 一级菜单项点击 → 创建子窗口 (Popup Window)
└─> 二级菜单项点击 → 创建子窗口的子窗口 (Popup Window)
└─> 三级菜单 → ❌ 鸿蒙系统禁止!
鸿蒙系统限制:
- 窗口层级限制:不允许"子窗口的子窗口"
- 这是操作系统级别的限制,Electron 无法绕过
- 其他平台(Windows、macOS、Linux)没有这个问题
1.3 MarkText 的菜单需求
MarkText 作为功能丰富的 Markdown 编辑器,菜单结构复杂:
| 一级菜单 | 二级菜单项数量 | 三级菜单 | 是否受影响 |
|---|---|---|---|
| 文件(File) | 10+ | ✅ 有(最近打开) | ❌ 无法显示 |
| 编辑(Edit) | 9 | ✅ 有(查找替换) | ❌ 无法显示 |
| 段落(Paragraph) | 8 | ✅ 有(标题层级) | ❌ 无法显示 |
| 格式(Format) | 11 | ❌ 无 | ✅ 正常 |
| 视图(View) | 5 | ❌ 无 | ✅ 正常 |
结论:约 40% 的菜单功能无法使用,严重影响用户体验。
问题分析与技术选型
2.1 可选方案对比
| 方案 | 可行性 | 优点 | 缺点 |
|---|---|---|---|
| 方案1:简化菜单结构 | ✅ 可行 | 简单 | ❌ 功能缺失,用户体验差 |
| 方案2:等待鸿蒙系统更新 | ⚠️ 不确定 | 无需改动 | ❌ 时间不可控 |
| 方案3:使用 Electron 对话框 | ⚠️ 勉强 | 原生 API | ❌ 交互体验差 |
| 方案4:自定义 HTML 菜单栏 | ✅ 可行 | 完全控制,无限制 | ⚠️ 开发工作量大 |
最终选择:方案4 - 自定义 HTML 菜单栏
理由:
- ✅ 完全不依赖系统窗口,无层级限制
- ✅ 可以实现任意层级的菜单嵌套
- ✅ 样式完全可控,可以做得比原生更美观
- ✅ 一次开发,所有平台通用
2.2 技术架构设计
┌─────────────────────────────────────────┐
│ 渲染进程 (Renderer Process) │
│ ┌───────────────────────────────────┐ │
│ │ 自定义菜单栏组件 │ │
│ │ (custom-menu-bar.js) │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ 菜单配置数据 │ │ │
│ │ │ - 层级结构定义 │ │ │
│ │ │ - 动作标识 │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ DOM 生成器 │ │ │
│ │ │ - 动态生成菜单 HTML │ │ │
│ │ │ - 递归处理子菜单 │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ 事件处理系统 │ │ │
│ │ │ - 点击、悬停、键盘 │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
│ ↕ IPC │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 主进程 (Main Process) │
│ ┌───────────────────────────────────┐ │
│ │ 菜单动作处理器 │ │
│ │ - 文件操作 │ │
│ │ - 编辑操作 │ │
│ │ - 窗口管理 │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
核心思路:
- 在渲染进程用 HTML/CSS 绘制菜单
- 通过 IPC 与主进程通信执行实际操作
- 完全绕过系统窗口限制
自定义菜单栏完整实现
3.1 菜单数据结构设计
// custom-menu-bar.js
/**
* 菜单配置 - 完整还原 MarkText 原有菜单结构
*/
const menuConfig = [
{
label: '文件',
items: [
{ label: '新建文件', action: 'file-new-file', accelerator: 'CmdOrCtrl+N' },
{ label: '新建窗口', action: 'file-new-tab', accelerator: 'CmdOrCtrl+Shift+N' },
{ type: 'separator' },
{ label: '打开文件', action: 'file-open-file', accelerator: 'CmdOrCtrl+O' },
{ label: '打开文件夹', action: 'file-open-folder', accelerator: 'CmdOrCtrl+Shift+O' },
{
label: '最近打开', // 三级菜单!
items: [
{ label: '清空最近打开', action: 'file-clear-recently-used' },
{ type: 'separator' },
// 动态生成的最近文件列表
]
},
{ type: 'separator' },
{ label: '保存', action: 'file-save', accelerator: 'CmdOrCtrl+S' },
{ label: '另存为', action: 'file-save-as', accelerator: 'CmdOrCtrl+Shift+S' },
{ type: 'separator' },
{ label: '导出',
items: [ // 三级菜单!
{ label: '导出为 HTML', action: 'file-export-html' },
{ label: '导出为 PDF', action: 'file-export-pdf' },
]
},
{ type: 'separator' },
{ label: '退出', action: 'file-quit', accelerator: 'CmdOrCtrl+Q' }
]
},
{
label: '编辑',
items: [
{ label: '撤销', action: 'edit-undo', accelerator: 'CmdOrCtrl+Z' },
{ label: '重做', action: 'edit-redo', accelerator: 'CmdOrCtrl+Shift+Z' },
{ type: 'separator' },
{ label: '剪切', action: 'edit-cut', accelerator: 'CmdOrCtrl+X' },
{ label: '复制', action: 'edit-copy', accelerator: 'CmdOrCtrl+C' },
{ label: '粘贴', action: 'edit-paste', accelerator: 'CmdOrCtrl+V' },
{ type: 'separator' },
{ label: '查找', action: 'edit-find', accelerator: 'CmdOrCtrl+F' },
{
label: '查找替换', // 三级菜单!
items: [
{ label: '查找下一个', action: 'edit-find-next', accelerator: 'F3' },
{ label: '查找上一个', action: 'edit-find-previous', accelerator: 'Shift+F3' },
{ label: '替换', action: 'edit-replace', accelerator: 'CmdOrCtrl+H' }
]
}
]
},
{
label: '段落',
items: [
{
label: '标题', // 三级菜单!
items: [
{ label: '一级标题', action: 'paragraph-heading-1' },
{ label: '二级标题', action: 'paragraph-heading-2' },
{ label: '三级标题', action: 'paragraph-heading-3' },
{ label: '四级标题', action: 'paragraph-heading-4' },
{ label: '五级标题', action: 'paragraph-heading-5' },
{ label: '六级标题', action: 'paragraph-heading-6' }
]
},
{ label: '段落', action: 'paragraph-paragraph' },
{ label: '水平线', action: 'paragraph-horizontal-line' },
{ type: 'separator' },
{ label: '表格', action: 'paragraph-table' },
{ label: '代码块', action: 'paragraph-code-fence' },
{ label: '引用', action: 'paragraph-quote-block' },
{ label: '列表', action: 'paragraph-ul-list' }
]
},
{
label: '格式',
items: [
{ label: '加粗', action: 'format-strong', accelerator: 'CmdOrCtrl+B' },
{ label: '斜体', action: 'format-emphasis', accelerator: 'CmdOrCtrl+I' },
{ label: '下划线', action: 'format-underline', accelerator: 'CmdOrCtrl+U' },
{ label: '删除线', action: 'format-strikethrough', accelerator: 'CmdOrCtrl+D' },
{ type: 'separator' },
{ label: '行内代码', action: 'format-inline-code', accelerator: 'CmdOrCtrl+`' },
{ label: '行内数学公式', action: 'format-inline-math' },
{ type: 'separator' },
{ label: '清除格式', action: 'format-clear-format' }
]
},
{
label: '视图',
items: [
{ label: '全屏', action: 'view-toggle-full-screen', accelerator: 'F11' },
{ label: '源代码模式', action: 'view-source-code-mode' },
{ label: '专注模式', action: 'view-focus-mode' },
{ label: '打字机模式', action: 'view-typewriter-mode' },
{ type: 'separator' },
{ label: '重新加载', action: 'view-reload', accelerator: 'CmdOrCtrl+R' }
]
},
{
label: '帮助',
items: [
{ label: '关于 MarkText', action: 'help-about' },
{ label: '检查更新', action: 'help-check-updates' }
]
}
]
关键点:
- ✅ 完整保留了原有菜单结构
- ✅ 支持任意层级嵌套(三级、四级都可以)
- ✅ 保留了快捷键提示(
accelerator) - ✅ 每个菜单项都有唯一的
action标识
3.2 动态生成菜单 HTML
/**
* 生成菜单栏 HTML
*/
function createMenuBarHTML(config) {
let html = '<div class="custom-menu-bar">'
config.forEach((menu, index) => {
html += `
<div class="menu-item" data-menu="${index}">
<span class="menu-label">${menu.label}</span>
${createSubmenuHTML(menu.items, index, 1)}
</div>
`
})
html += '</div>'
return html
}
/**
* 递归生成子菜单 HTML(核心!支持无限层级)
*/
function createSubmenuHTML(items, parentIndex, level) {
if (!items || items.length === 0) return ''
let html = `<div class="submenu" data-level="${level}">`
items.forEach((item, index) => {
if (item.type === 'separator') {
// 分隔线
html += '<div class="menu-separator"></div>'
} else if (item.items) {
// 有子菜单 - 递归生成!
html += `
<div class="submenu-item has-children" data-action="${item.action || ''}">
<span class="item-label">${item.label}</span>
<span class="submenu-arrow">▶</span>
${createSubmenuHTML(item.items, parentIndex, level + 1)}
</div>
`
} else {
// 普通菜单项
html += `
<div class="submenu-item" data-action="${item.action}">
<span class="item-label">${item.label}</span>
${item.accelerator ? `<span class="accelerator">${item.accelerator}</span>` : ''}
</div>
`
}
})
html += '</div>'
return html
}
核心亮点:
- ✅ 递归生成:
createSubmenuHTML函数递归调用自己,支持无限层级 - ✅ 层级标记:
data-level属性记录当前层级,便于样式控制 - ✅ 子菜单标识:
.has-children类标记有子菜单的项
3.3 CSS 样式实现
/**
* 注入菜单栏样式
*/
function injectMenuStyles() {
const style = document.createElement('style')
style.id = 'custom-menu-styles'
style.textContent = `
/* === 菜单栏容器 === */
.custom-menu-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 32px;
background: #2c2c2c;
color: #ffffff;
display: flex;
align-items: center;
padding: 0 10px;
z-index: 10000;
user-select: none;
-webkit-app-region: drag; /* 允许拖动窗口 */
font-size: 13px;
}
/* === 顶级菜单项 === */
.menu-item {
position: relative;
padding: 0 12px;
height: 100%;
display: flex;
align-items: center;
cursor: pointer;
-webkit-app-region: no-drag; /* 菜单项不可拖动 */
transition: background 0.15s ease;
}
.menu-item:hover,
.menu-item.active {
background: #404040;
}
/* === 子菜单容器 === */
.submenu {
display: none; /* 默认隐藏 */
position: absolute;
min-width: 220px;
background: #2c2c2c;
border: 1px solid #404040;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
padding: 4px 0;
z-index: 10001;
}
/* 一级子菜单定位(在顶级菜单下方) */
.menu-item > .submenu {
top: 100%;
left: 0;
}
/* 多级子菜单定位(在父菜单右侧) */
.submenu .submenu {
top: -4px;
left: 100%;
}
/* === 子菜单项 === */
.submenu-item {
padding: 8px 20px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
transition: background 0.15s ease;
}
.submenu-item:hover {
background: #404040;
}
/* 有子菜单的项:悬停时显示子菜单 */
.submenu-item.has-children:hover > .submenu {
display: block;
}
/* === 快捷键提示 === */
.accelerator {
margin-left: 40px;
opacity: 0.6;
font-size: 11px;
}
/* === 子菜单箭头 === */
.submenu-arrow {
margin-left: 20px;
font-size: 10px;
opacity: 0.6;
}
/* === 分隔线 === */
.menu-separator {
height: 1px;
background: #404040;
margin: 4px 0;
}
/* === 显示子菜单(点击激活) === */
.menu-item.active > .submenu {
display: block;
}
/* === 动画效果 === */
.submenu {
opacity: 0;
transform: translateY(-5px);
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-item.active > .submenu,
.submenu-item.has-children:hover > .submenu {
opacity: 1;
transform: translateY(0);
}
`
document.head.appendChild(style)
console.log('[MenuBar] 样式注入完成')
}
CSS 关键技巧:
- ✅ 嵌套定位:
.submenu .submenu选择器处理多级菜单定位 - ✅ 悬停显示:
:hover > .submenu实现鼠标悬停显示子菜单 - ✅ 层级叠加:
z-index确保菜单始终在最上层 - ✅ 平滑动画:
transition实现淡入淡出效果
3.4 事件处理系统
/**
* 绑定菜单事件
*/
function bindMenuEvents() {
const menuBar = document.querySelector('.custom-menu-bar')
if (!menuBar) return
// === 1. 顶级菜单点击事件 ===
menuBar.querySelectorAll('.menu-item').forEach(item => {
item.addEventListener('click', function(e) {
e.stopPropagation()
// 关闭其他菜单
menuBar.querySelectorAll('.menu-item').forEach(m => {
if (m !== this) m.classList.remove('active')
})
// 切换当前菜单
this.classList.toggle('active')
})
})
// === 2. 子菜单项点击事件 ===
menuBar.querySelectorAll('.submenu-item:not(.has-children)').forEach(item => {
item.addEventListener('click', function(e) {
const action = this.getAttribute('data-action')
if (!action) return
e.stopPropagation()
console.log('[MenuBar] 菜单动作:', action)
// 关闭所有菜单
menuBar.querySelectorAll('.menu-item').forEach(m => {
m.classList.remove('active')
})
// 发送 IPC 消息到主进程
if (window.ipcRenderer) {
window.ipcRenderer.send('menu-action', action)
} else {
console.warn('[MenuBar] ipcRenderer 不可用')
}
})
})
// === 3. 点击页面其他地方关闭菜单 ===
document.addEventListener('click', function(e) {
if (!menuBar.contains(e.target)) {
menuBar.querySelectorAll('.menu-item').forEach(m => {
m.classList.remove('active')
})
}
})
// === 4. ESC 键关闭菜单 ===
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
menuBar.querySelectorAll('.menu-item').forEach(m => {
m.classList.remove('active')
})
}
})
console.log('[MenuBar] 事件绑定完成')
}
3.5 主进程 IPC 处理
// main.js (主进程)
const { ipcMain, dialog, app } = require('electron')
/**
* 处理菜单动作
*/
ipcMain.on('menu-action', (event, action) => {
const win = BrowserWindow.fromWebContents(event.sender)
console.log('[Main] 收到菜单动作:', action)
switch (action) {
case 'file-new-file':
win.webContents.send('mt::new-untitled-tab')
break
case 'file-open-file':
dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [
{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] },
{ name: 'All Files', extensions: ['*'] }
]
}).then(result => {
if (!result.canceled && result.filePaths.length > 0) {
win.webContents.send('mt::open-file', result.filePaths[0])
}
})
break
case 'file-save':
win.webContents.send('mt::save-file')
break
case 'file-quit':
app.quit()
break
case 'edit-undo':
win.webContents.undo()
break
case 'edit-redo':
win.webContents.redo()
break
case 'view-toggle-full-screen':
win.setFullScreen(!win.isFullScreen())
break
// ... 更多动作处理
default:
console.warn('[Main] 未处理的菜单动作:', action)
}
})
与原生菜单的对比
4.1 功能对比
| 功能 | 原生菜单(鸿蒙PC) | 自定义菜单 |
|---|---|---|
| 菜单层级 | ❌ 最多 2-3 层 | ✅ 无限制 |
| 样式定制 | ❌ 不可定制 | ✅ 完全控制 |
| 动画效果 | ⚠️ 系统默认 | ✅ 自定义动画 |
| 跨平台一致性 | ❌ 各平台不同 | ✅ 完全一致 |
| 开发复杂度 | ✅ 简单 | ⚠️ 较复杂 |
| 性能 | ✅ 原生性能 | ✅ 优秀(CSS 硬件加速) |
| 内存占用 | ✅ 低 | ✅ 低(纯 HTML/CSS) |
4.2 实际效果对比
原生菜单(鸿蒙PC):
文件
├─ 新建文件 ✅
├─ 打开文件 ✅
├─ 最近打开
│ └─ (无法显示)❌
└─ 保存 ✅
自定义菜单:
文件
├─ 新建文件 ✅
├─ 打开文件 ✅
├─ 最近打开 ✅
│ ├─ 清空最近打开 ✅
│ ├─ document1.md ✅
│ └─ document2.md ✅
├─ 导出 ✅
│ ├─ 导出为 HTML ✅
│ └─ 导出为 PDF ✅
└─ 保存 ✅
4.3 性能测试
测试环境:鸿蒙PC、MarkText 应用
| 指标 | 原生菜单 | 自定义菜单 |
|---|---|---|
| 首次渲染时间 | ~50ms | ~80ms |
| 点击响应时间 | ~10ms | ~15ms |
| 内存占用 | +2MB | +3MB |
| CPU 占用(悬停) | ~1% | ~1.5% |
结论:性能差异可忽略不计,用户无感知。
遇到的坑与解决方案
5.1 坑1:菜单栏遮挡页面内容
问题:菜单栏固定在顶部,遮挡了原有内容。
解决方案:
// 初始化时调整页面布局
function adjustPageLayout() {
document.body.style.paddingTop = '32px' // 菜单栏高度
console.log('[MenuBar] 页面布局已调整')
}
5.2 坑2:子菜单超出屏幕边界
问题:靠近屏幕右侧的菜单,子菜单会超出屏幕。
解决方案:
// 动态调整子菜单位置
function adjustSubmenuPosition(submenu) {
const rect = submenu.getBoundingClientRect()
const windowWidth = window.innerWidth
if (rect.right > windowWidth) {
// 超出右边界,改为向左展开
submenu.style.left = 'auto'
submenu.style.right = '100%'
}
}
5.3 坑3:菜单栏可拖动区域冲突
问题:整个菜单栏设置了 -webkit-app-region: drag,导致菜单项无法点击。
解决方案:
.custom-menu-bar {
-webkit-app-region: drag; /* 整体可拖动 */
}
.menu-item {
-webkit-app-region: no-drag; /* 菜单项不可拖动 */
}
5.4 坑4:快捷键不生效
问题:自定义菜单只显示快捷键,但不会触发。
解决方案:
// 主进程注册全局快捷键
const { globalShortcut } = require('electron')
app.whenReady().then(() => {
globalShortcut.register('CommandOrControl+N', () => {
mainWindow.webContents.send('mt::new-untitled-tab')
})
globalShortcut.register('CommandOrControl+O', () => {
// 触发打开文件
})
// ... 注册更多快捷键
})
5.5 坑5:初始化时机问题
问题:页面加载时 DOM 未准备好,菜单栏注入失败。
解决方案:参考我们的另一篇文章《Electron for 鸿蒙PC - DOM初始化时机与多重保险机制》,使用多重保险机制:
// 多重初始化机制
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMenuBar)
} else {
initMenuBar()
}
window.addEventListener('load', () => {
if (!menuBarInitialized) {
initMenuBar()
}
})
setTimeout(() => {
if (!menuBarInitialized) {
initMenuBar()
}
}, 1000)
总结与展望
6.1 成果总结
通过实现自定义菜单栏,我们成功解决了 MarkText 在鸿蒙 PC 上的菜单问题:
✅ 完全突破了鸿蒙系统的子窗口限制
✅ 支持任意层级的菜单嵌套(3级、4级、5级都可以)
✅ 保留了所有原有菜单功能(100% 功能可用)
✅ 跨平台统一体验(Windows、macOS、Linux、鸿蒙PC 完全一致)
✅ 性能优异(用户无感知的性能差异)
✅ 代码可维护(1169 行,结构清晰)
6.2 关键技术点
- 递归 HTML 生成:支持无限层级菜单
- CSS 嵌套定位:
.submenu .submenu实现多级菜单 - 事件委托:高效的事件处理
- IPC 通信:渲染进程与主进程协作
- 多重初始化保险:确保可靠加载
6.3 适用场景
这套方案不仅适用于鸿蒙 PC,也适用于:
- ✅ 需要高度自定义菜单样式的应用
- ✅ 需要复杂菜单层级的应用
- ✅ 需要跨平台统一体验的应用
- ✅ 原生菜单无法满足需求的场景
6.4 后续优化方向
- 键盘导航:支持方向键导航菜单
- 无障碍支持:添加 ARIA 属性
- 主题切换:支持浅色/深色主题
- 菜单搜索:快速查找菜单项
- 性能优化:虚拟滚动处理超长菜单
6.5 源码地址
完整代码已开源在 MarkText for HarmonyOS 项目中:
- 项目地址:https://gitcode.com/szkygc/marktext
- 文件路径:
web_engine/src/main/resources/resfile/resources/app/custom-menu-bar.js - 代码行数:1169 行
- 许可证:MIT
相关资源
Electron 官方文档:
鸿蒙PC开发资源:
技术难度:⭐⭐⭐⭐ 中高级
实战价值:⭐⭐⭐⭐⭐ 解决鸿蒙PC适配核心问题
推荐指数:⭐⭐⭐⭐⭐ 鸿蒙PC Electron应用必备方案
更多推荐



所有评论(0)