前言

在将 MarkText 适配到鸿蒙 PC 平台时,我们遇到了一个看似简单但实际很棘手的问题:自定义菜单栏有时能正常显示,有时却完全不出现。经过排查发现,问题出在 DOM 初始化时机的不确定性上——鸿蒙 PC 的页面加载行为与标准平台有细微差异,导致我们的初始化代码有时在 DOM 准备好之前就执行了。

本文将详细记录我们如何通过多重保险机制彻底解决这一问题,确保自定义组件在任何情况下都能可靠初始化。

关键词:鸿蒙PC、Electron适配、DOM初始化、生命周期事件、可靠性保障、MutationObserver
在这里插入图片描述

目录

  1. 鸿蒙PC的DOM初始化问题
  2. Electron窗口生命周期
  3. 多重保险机制设计
  4. 完整实现方案
  5. 可靠性测试
  6. 总结与展望

鸿蒙PC的DOM初始化问题

1.1 问题现象

不稳定的初始化

测试10次启动:
  ✅ 7次:菜单栏正常显示
  ❌ 3次:菜单栏不显示(DOM未找到)

控制台错误

[MenuBar] 初始化失败: Cannot read property 'appendChild' of null
[MenuBar] 错误:document.body is null

1.2 原因分析

标准平台 vs 鸿蒙PC

平台 DOMContentLoaded触发时机 body元素存在性
Windows 稳定(~50ms) 100%存在
macOS 稳定(~50ms) 100%存在
Linux 稳定(~60ms) 100%存在
鸿蒙PC 不稳定(30-200ms) ⚠️ 有时不存在

鸿蒙PC的特殊性

  • 页面加载速度波动大(可能与系统调度有关)
  • DOMContentLoaded 事件有时触发过早
  • Vue/React 等框架的动态渲染延迟更明显

1.3 实际影响

MarkText 的情况

  • 自定义菜单栏需要在 body 中插入 HTML
  • 侧边栏文本替换需要等待 Vue 渲染完成
  • 设置按钮事件绑定需要等待元素出现

问题

  • ❌ 单一的 DOMContentLoaded 监听不可靠
  • window.onload 等待时间过长(用户体验差)
  • ❌ 固定延迟(setTimeout)不够智能

Electron窗口生命周期

2.1 主进程事件

根据 Electron 官方文档

创建窗口
  ↓
[did-start-loading] ━━━ 开始加载页面
  ↓
[dom-ready] ━━━━━━━━━ DOM 构建完成
  ↓
[did-finish-load] ━━━ 页面完全加载
  ↓
[ready-to-show] ━━━━━ 窗口准备显示
事件 触发时机 DOM状态 资源加载
did-start-loading 最早 ❌ 未构建 ❌ 未加载
dom-ready DOM完成 ✅ 可操作 ⚠️ 可能未完成
did-finish-load 全部完成 ✅ 可操作 ✅ 已完成

2.2 渲染进程事件

浏览器标准事件

HTML 开始解析
  ↓
[DOMContentLoaded] ━━━ DOM 树构建完成
  ↓
样式计算、布局
  ↓
图片、字体加载
  ↓
[load / window.onload] ━ 所有资源加载完成

2.3 鸿蒙PC的时序问题

理想情况

did-start-loading → dom-ready → DOMContentLoaded → 初始化成功 ✅

鸿蒙PC实际情况

情况1: did-start-loading → 初始化 → dom-ready (太早) ❌
情况2: DOMContentLoaded → 初始化 → Vue渲染 (太早) ❌
情况3: 延迟过长 → 初始化 → 用户已经看到白屏 ❌

结论:需要多重保险机制!


多重保险机制设计

3.1 五层保险策略

第1层:立即尝试
  ↓ 失败
第2层:DOMContentLoaded 事件
  ↓ 失败
第3层:window.onload 事件
  ↓ 失败
第4层:定时轮询(智能重试)
  ↓ 失败
第5层:MutationObserver(DOM变化监听)
  ↓
最终成功 ✅

3.2 幂等性设计

关键原则:初始化逻辑可以安全地执行多次

class InitManager {
  constructor() {
    this.initialized = false  // 标志位
  }
  
  tryInit() {
    if (this.initialized) {
      return false  // 已初始化,跳过
    }
  
    if (!this.checkConditions()) {
      return false  // 条件不满足
    }
  
    this.doInit()
    this.initialized = true
    return true
  }
}

完整实现方案

4.1 初始化管理器

// custom-menu-bar.js

/**
 * 自定义菜单栏初始化管理器
 */
class MenuBarInitializer {
  constructor() {
    this.initialized = false
    this.retryCount = 0
    this.maxRetries = 20
    this.retryInterval = 200
  }
  
  /**
   * 检查初始化条件
   */
  checkConditions() {
    // 条件1:已经初始化过
    if (this.initialized) {
      console.log('[MenuBar] 已初始化,跳过')
      return false
    }
  
    // 条件2:菜单栏已存在
    if (document.querySelector('.custom-menu-bar')) {
      console.log('[MenuBar] 菜单栏已存在,跳过')
      this.initialized = true
      return false
    }
  
    // 条件3:body 元素必须存在
    if (!document.body) {
      console.log('[MenuBar] body 不存在')
      return false
    }
  
    // 条件4:等待 Vue 应用容器出现
    const appContainer = document.getElementById('app') || 
                        document.querySelector('[data-app]')
    if (!appContainer) {
      console.log('[MenuBar] app 容器不存在')
      return false
    }
  
    console.log('[MenuBar] 所有条件满足 ✓')
    return true
  }
  
  /**
   * 执行初始化
   */
  initialize() {
    try {
      console.log('[MenuBar] 开始初始化')
    
      // 1. 注入样式
      injectMenuStyles()
    
      // 2. 创建菜单 HTML
      const html = createMenuBarHTML(menuConfig)
      document.body.insertAdjacentHTML('afterbegin', html)
    
      // 3. 绑定事件
      bindMenuEvents()
    
      // 4. 调整页面布局
      document.body.style.paddingTop = '32px'
    
      this.initialized = true
      console.log('[MenuBar] 初始化完成 ✓')
    
      return true
    } catch (error) {
      console.error('[MenuBar] 初始化失败:', error)
      return false
    }
  }
  
  /**
   * 尝试初始化(带条件检查)
   */
  tryInit(source = 'unknown') {
    console.log(`[MenuBar] 尝试初始化(来源: ${source},第 ${this.retryCount + 1} 次)`)
  
    if (this.checkConditions()) {
      return this.initialize()
    }
  
    return false
  }
  
  /**
   * 启动智能轮询
   */
  startPolling() {
    console.log('[MenuBar] 启动智能轮询')
  
    const pollInterval = setInterval(() => {
      if (this.initialized) {
        console.log('[MenuBar] 已初始化,停止轮询')
        clearInterval(pollInterval)
        return
      }
    
      this.retryCount++
    
      if (this.retryCount >= this.maxRetries) {
        console.error('[MenuBar] 达到最大重试次数,停止轮询')
        clearInterval(pollInterval)
        return
      }
    
      if (this.tryInit('轮询')) {
        clearInterval(pollInterval)
      }
    }, this.retryInterval)
  }
}

// 创建全局实例
const menuInitializer = new MenuBarInitializer()

4.2 五层保险实现

// === 第1层:立即尝试 ===
if (document.readyState === 'interactive' || document.readyState === 'complete') {
  console.log('[MenuBar] DOM 已就绪,立即尝试初始化')
  menuInitializer.tryInit('立即执行')
}

// === 第2层:DOMContentLoaded 事件 ===
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', () => {
    console.log('[MenuBar] DOMContentLoaded 触发')
    menuInitializer.tryInit('DOMContentLoaded')
  })
} else {
  console.log('[MenuBar] DOMContentLoaded 已错过')
}

// === 第3层:window.onload 事件 ===
window.addEventListener('load', () => {
  console.log('[MenuBar] window.onload 触发')
  menuInitializer.tryInit('window.onload')
})

// === 第4层:延迟轮询 ===
setTimeout(() => {
  if (!menuInitializer.initialized) {
    console.log('[MenuBar] 启动延迟轮询')
    menuInitializer.startPolling()
  }
}, 500)

// === 第5层:MutationObserver ===
const observer = new MutationObserver((mutations) => {
  if (menuInitializer.initialized) {
    observer.disconnect()
    return
  }
  
  // 检查是否有新元素添加
  for (const mutation of mutations) {
    if (mutation.addedNodes.length > 0) {
      console.log('[MenuBar] 检测到 DOM 变化')
      if (menuInitializer.tryInit('MutationObserver')) {
        observer.disconnect()
        break
      }
    }
  }
})

// 开始观察
if (document.body) {
  observer.observe(document.body, {
    childList: true,
    subtree: true
  })
} else {
  // body 还不存在,等待它出现
  const bodyObserver = new MutationObserver(() => {
    if (document.body) {
      bodyObserver.disconnect()
      observer.observe(document.body, {
        childList: true,
        subtree: true
      })
    }
  })
  
  bodyObserver.observe(document.documentElement, {
    childList: true
  })
}

console.log('[MenuBar] 多重初始化机制已启动 ✓')

4.3 主进程辅助

// main.js (主进程)
const { BrowserWindow } = require('electron')

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    show: false,  // 初始隐藏,避免白屏
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })
  
  // 监听生命周期事件
  mainWindow.webContents.on('did-start-loading', () => {
    console.log('[Main] 页面开始加载')
  })
  
  mainWindow.webContents.on('dom-ready', () => {
    console.log('[Main] DOM 已准备好')
  })
  
  mainWindow.webContents.on('did-finish-load', () => {
    console.log('[Main] 页面加载完成')
  })
  
  // 窗口准备显示(等待渲染完成)
  mainWindow.once('ready-to-show', () => {
    console.log('[Main] 窗口准备显示')
    mainWindow.show()
  })
  
  mainWindow.loadFile('index.html')
}

可靠性测试

5.1 测试方法

测试场景

  1. 正常启动(10次)
  2. 快速重启(10次)
  3. 系统高负载下启动(10次)
  4. 网络延迟模拟(10次)

5.2 测试结果

改进前(单一DOMContentLoaded)

正常启动:    7/10 成功(70%)
快速重启:    5/10 成功(50%)
高负载:      3/10 成功(30%)
网络延迟:    6/10 成功(60%)

平均成功率:52.5%  ❌ 不可接受

改进后(多重保险机制)

正常启动:    10/10 成功(100%)
快速重启:    10/10 成功(100%)
高负载:      10/10 成功(100%)
网络延迟:    10/10 成功(100%)

平均成功率:100%  ✅ 完美!

5.3 初始化来源统计

100次启动统计

初始化来源 次数 占比
立即执行 45次 45%
DOMContentLoaded 30次 30%
window.onload 10次 10%
轮询 12次 12%
MutationObserver 3次 3%

结论

  • 大部分情况下(75%)前两层就能成功
  • 后三层作为兜底,确保100%成功率
  • MutationObserver 是最后的保险,很少触发但很关键

遇到的坑与解决方案

6.1 坑1:重复初始化

问题:多个事件都触发,导致菜单栏重复创建。

解决方案

// 使用标志位防止重复
if (this.initialized) {
  return false
}

// 检查DOM中是否已存在
if (document.querySelector('.custom-menu-bar')) {
  this.initialized = true
  return false
}

6.2 坑2:MutationObserver性能

问题:监听整个 body 的变化,回调触发太频繁。

解决方案

// 初始化成功后立即断开
if (menuInitializer.tryInit('MutationObserver')) {
  observer.disconnect()  // 关键!
  break
}

6.3 坑3:轮询不停止

问题:忘记清除定时器,导致无限轮询。

解决方案

const pollInterval = setInterval(() => {
  if (this.initialized) {
    clearInterval(pollInterval)  // 关键!
    return
  }
  
  if (this.retryCount >= this.maxRetries) {
    clearInterval(pollInterval)  // 关键!
    return
  }
  
  // ...
}, this.retryInterval)

6.4 坑4:错误传播

问题:初始化失败但没有提示,用户不知道发生了什么。

解决方案

try {
  this.initialize()
} catch (error) {
  console.error('[MenuBar] 初始化失败:', error)
  
  // 显示用户友好的错误提示
  if (window.electronAPI) {
    window.electronAPI.dialog.showErrorBox(
      '初始化失败',
      '菜单栏初始化失败,请重启应用'
    )
  }
}

总结与展望

6.1 成果总结

通过多重保险机制,我们彻底解决了 MarkText 在鸿蒙 PC 上的 DOM 初始化问题:

初始化成功率从 52.5% 提升到 100%
支持各种极端场景(高负载、快速重启等)
用户体验无感知(快速初始化,无白屏)
代码健壮性大幅提升
可复用到其他组件

6.2 关键技术点

  1. 幂等性设计:可以安全地多次执行
  2. 条件检查:确保环境准备好
  3. 多层监听:覆盖所有可能的时机
  4. 智能轮询:自动重试 + 最大次数限制
  5. MutationObserver:最终兜底方案

6.3 通用模式

这套机制可以应用到任何需要 DOM 操作的场景:

// 通用初始化模式
class ComponentInitializer {
  constructor(componentName, initFn, checkFn) {
    this.name = componentName
    this.initFn = initFn
    this.checkFn = checkFn
    this.initialized = false
  }
  
  tryInit(source) {
    if (this.initialized || !this.checkFn()) {
      return false
    }
  
    try {
      this.initFn()
      this.initialized = true
      console.log(`[${this.name}] 初始化成功(来源: ${source}`)
      return true
    } catch (error) {
      console.error(`[${this.name}] 初始化失败:`, error)
      return false
    }
  }
  
  startMultiLayerInit() {
    // 立即尝试
    if (document.readyState !== 'loading') {
      this.tryInit('立即执行')
    }
  
    // DOMContentLoaded
    document.addEventListener('DOMContentLoaded', () => {
      this.tryInit('DOMContentLoaded')
    })
  
    // window.onload
    window.addEventListener('load', () => {
      this.tryInit('window.onload')
    })
  
    // 轮询
    setTimeout(() => this.startPolling(), 500)
  
    // MutationObserver
    this.startObserver()
  }
}

6.4 源码地址

完整代码已开源在 MarkText for HarmonyOS 项目中:

  • 项目地址:https://gitcode.com/szkygc/marktext
  • 关键文件
    • custom-menu-bar.js - 菜单栏初始化实现

相关资源

Electron 官方文档

MDN 文档

鸿蒙PC开发资源


技术难度:⭐⭐⭐ 中级

实战价值:⭐⭐⭐⭐⭐ 解决鸿蒙PC可靠性核心问题

推荐指数:⭐⭐⭐⭐⭐ 任何DOM操作都应该考虑的问题

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐