Electron 实战全解析:基于 WebContentView 的多视图管理系统

48 阅读4分钟

一、 为什么从 Webview 迁移到 WebContentView?

在 Electron 开发的早期,webview 标签是嵌入外部网页的唯一选择。但随着 Chromium 架构的演进,webview 的缺陷在复杂应用(如社交平台多账号管理)中愈发明显。

1.1 Webview 的局限性

  • 进程开销大:每一个 webview 都是一个独立的代理进程,通过异步 IPC 进行通信,在大规模多实例场景下,内存和 CPU 开销呈指数级增长。
  • 渲染稳定性差webview 存在于渲染进程的 DOM 树中,受限于 DOM 的渲染流水线。如果渲染进程卡顿,webview 也会随之卡顿。
  • 控制粒度不足:无法在主进程直接精细化控制其生命周期、Session 隔离以及底层网络拦截。

1.2 WebContentView 的救赎

WebContentView(在最新 Electron 中取代了之前的 BrowserView)是原生视图:

  • 原生性能:直接挂载在主窗口的 contentView 上,由主进程直接操控其生命周期,不经过渲染进程转发。
  • 完美隔离:支持独立的 partition 隔离 Session,为每一个嵌入页面创建完全独立的缓存和 Cookie 空间,是多账号切换应用的基石。
  • 后台运行:通过配置可以绕过 Chromium 的后台节流限制,确保业务脚本持续运行。

二、 架构设计:管理器模式与状态机

在处理多个视图时,绝对不能散乱地创建实例。我们需要一套严密的管理逻辑,将其分为 视图包装器(Wrapper)全局管理器(Manager) 以及 UI 占位同步(Vue Component)

2.1 核心状态流转

webviewManager.js 中,我们为视图定义了完整的生命周期状态:

  • PENDING:视图已在队列中排队,等待加载。
  • LOADING:视图正在初始化并加载 URL。
  • LOADED:DOM 加载完成,脚本注入成功,处于可用状态。

三、 主进程核心实现:WebViewWrapper

包装器是单个视图的“壳”,负责处理 Session、注入脚本以及位置调整。

3.1 实例化与安全配置

// WebViewWrapper 核心代码
import { WebContentsView } from 'electron';
import path from 'path';
import fs from 'fs';

class WebViewWrapper {
    constructor(webviewId) {
        this.webviewId = webviewId;
        this.isActive = false;
        this.size = { x: 0, y: 0, width: 0, height: 0 };
        this.offset = { left: 0, right: 0, top: 0, bottom: 0 };

        // 1. 实现 Session 隔离
        const sessionName = `persist:tg_${this.webviewId}`;
        
        this.webview = new WebContentsView({
            webPreferences: {
                partition: sessionName,
                backgroundThrottling: false, // 核心:防止后台/隐藏时节流
                preload: path.join(__dirname, 'preload.js'),
                contextIsolation: false,
                webSecurity: false,
                allowRunningInsecureContent: true
            }
        });

        // 2. 拦截并修改响应头 (处理 CORS 和 PNA)
        this.webview.webContents.session.webRequest.onHeadersReceived((details, callback) => {
            const headers = { ...details.responseHeaders };
            headers['Access-Control-Allow-Origin'] = ['https://cdn.wadesk.io'];
            headers['Access-Control-Allow-Private-Network'] = ['true']; // 允许私有网络请求
            callback({ responseHeaders: headers, cancel: false });
        });

        this.initEvents();
    }

    initEvents() {
        this.webview.webContents.on('dom-ready', () => {
            this.injectJs(); // 注入自定义业务脚本
        });
    }

    // 脚本注入逻辑
    injectJs() {
        this.webview.webContents.executeJavaScript(`window.__webviewId = '${this.webviewId}';`);
        // 这里可以读取本地文件并注入
    }
}

3.2 攻克“后台休眠”难题

为了保证聊天页面在后台不掉线,我们采用“移出视口”而非“隐藏”的策略。

setActive(v) {
    this.isActive = v;
    if (this.isActive) {
        this.resize(); // 恢复到正确坐标
        this.webview.setVisible(true);
    } else {
        try {
            // 将其移动到视口外极远的地方(-10000px)
            // 这样可以保持 JS 继续执行,同时用户看不见
            this.webview.setBounds({ x: -10000, y: -10000, width: 0, height: 0 });
        } catch (error) {
            console.error('setActive hide error', error);
        }
    }
}

resize() {
    try {
        this.webview.setBounds({
            x: this.size.x + this.offset.left,
            y: this.size.y + this.offset.top,
            width: this.size.width - this.offset.left - this.offset.right,
            height: this.size.height - this.offset.top - this.offset.bottom
        });
    } catch (e) { console.error(e); }
}

四、 全局调度中心:WebviewManager

管理器负责维护所有视图,并实现并发控制(加载池)。

4.1 加载池并发调度

为了防止瞬时加载过多账号导致系统卡死,我们设置了 maxPoolSize

class WebviewManager {
    constructor(win) {
        this.win = win;
        this.webviews = new Map();
        this.loadingQueue = [];   // 待加载队列
        this.loadingPool = new Set(); // 正在加载的任务池
        this.maxPoolSize = 3;     // 默认最大加载并发数
    }

    // 初始化数据
    initWithData(data) {
        data.forEach(({ webview_id }) => {
            this.loadingQueue.push({ webview_id });
        });
        this.processLoadingQueue();
    }

    // 处理加载队列
    processLoadingQueue() {
        while (this.loadingQueue.length > 0 && this.loadingPool.size < this.maxPoolSize) {
            const { webview_id } = this.loadingQueue.shift();
            this.startLoadingWebview(webview_id);
        }
    }

    startLoadingWebview(webviewId) {
        if (this.loadingPool.has(webviewId)) return;
        
        this.loadingPool.add(webviewId);
        const wv = new WebViewWrapper(webviewId);
        
        this.webviews.set(webviewId, wv);
        this.win.contentView.addChildView(wv.view); // 将原生视图挂载到窗口

        wv.view.webContents.on('did-finish-load', () => {
            this.loadingPool.delete(webviewId);
            this.processLoadingQueue(); // 一个加载完,自动开始下一个
        });
    }
}

五、 渲染进程实战:Vue 占位与同步

index.vue 中,原生视图不属于 DOM,因此我们需要实时同步坐标。

5.1 占位符设计

<template>
  <div class="main-box">
    <SideBar />
    <div ref="centerContent" class="tab-content-box">
      <div v-if="isLoading" class="loading-overlay">
        <span>正在加载中...</span> </div>
    </div>
  </div>
</template>

5.2 防抖同步逻辑

// index.vue 的 setup 部分
import { debounce } from 'lodash';

const centerContent = ref(null);

const syncBounds = () => {
  if (!centerContent.value) return;
  
  // 获取 DOM 占位符的实时坐标
  const rect = centerContent.value.getBoundingClientRect();
  
  // 发送 IPC 通知主进程调整 view 的 Bounds
  ipcRenderer.send('webview-sync-size', {
    x: Math.floor(rect.x),
    y: Math.floor(rect.y),
    width: Math.floor(rect.width),
    height: Math.floor(rect.height)
  });
};

// 关键:防抖处理,防止 resize 时 IPC 堵塞
const debouncedSync = debounce(syncBounds, 100);

onMounted(() => {
  window.addEventListener('resize', debouncedSync);
  syncBounds(); // 首次进入初始化
});

六、 进阶与优化

6.1 彻底的资源释放

当移除一个视图时,如果不按照以下顺序,会出现内存泄漏:

  1. 从父视图移除:this.win.contentView.removeChildView(view)
  2. 关闭 WebContents:view.webContents.close()
  3. 清理管理器的 Map 引用。

6.2 加载状态监控

通过 notifyStatusChange 将主进程的状态推送到 Vue 层。

  • Pending: 在队列中排队,渲染进程显示“排队中...”。
  • Loading: 开始加载,渲染进程显示 Spinner。
  • Loaded: 加载完成,原生视图显示,UI 层隐藏 Loading。

七、 结语

通过 WebviewManager 的统一调度,我们解决了 Electron 应用中多视图管理的三个核心难题:

  1. 秩序问题:通过加载队列和并发池,确保了大规模实例下的系统稳定性。
  2. 布局问题:通过占位符 + 防抖同步,实现了 WebContentView 与 Vue 组件的丝滑融合。
  3. 活跃问题:通过“移出视口”策略,攻克了 Chromium 的后台节流限制,确保业务脚本持续运行。

这套架构目前支撑了我们在复杂即时通讯客户端中的核心业务逻辑,希望对你的 Electron 进阶之路有所启发。