Skip to content

在 Electron 中使用 webview 嵌入远程网页的最佳实践

写在前面

在 Electron 中嵌入第三方网页,webview 是能力最强、但也最容易被误用的一种方式

如果你只是简单地在页面里写一个 <webview src="...">,那很快就会遇到这些问题:

  • preload 路径怎么动态传?
  • webview 的 webContents 怎么和主进程通信?
  • 多个 webview 如何区分、路由消息?
  • 第三方页面如何“感知”自己运行在 Electron 中?

本文基于一个可运行的完整示例,一步步拆解一个工程化可落地的 webview 接入方案。

本文是上一篇《使用 BrowserWindow 加载第三方网页的最佳实践》的进阶篇,重点解决 “嵌入式网页 + 自有前端壳” 场景。


场景介绍

在 BrowserWindow 示例中,我们直接通过 loadURL 加载远程网页;而在实际项目中,更常见的是:

  • 外层是 自己可控的前端应用(Vue / React)
  • 内部嵌入一个或多个 第三方 Web 系统

这正是 <webview> 的典型使用场景。

本示例将演示:

  • 如何在 Vue 组件中安全、可控地使用 <webview>
  • 如何为 webview 配置专属 preload
  • 如何通过 webContentsId 建立稳定的通信链路
  • 如何让被嵌入页面做到「浏览器 / Electron 双环境兼容」

目录结构

本示例的关键文件如下:

  • main.js:主进程,创建 BrowserWindow,管理 webview 通信
  • preload.jswebview 专属预加载脚本
  • App.vue:Vue 3 宿主页面,承载 <webview>
  • renderer.html:被嵌入的第三方网页(可独立部署)

主进程:webview 的「消息路由中心」

主进程不仅要创建窗口,还需要:

  • 为 webview 提供 preload 的 绝对路径
  • 记录 webview 对应的 webContentsId
  • 在 webview 与主进程之间 中转消息

完整代码如下(保持原样):

js
const { app, BrowserWindow, Menu, ipcMain, webContents } = require("electron");
const path = require("path");
const { setupIpcHandlers } = require("./ipc-handlers");

// 检测是否为开发环境
const isDev = process.env.NODE_ENV === "development";

// 保持对窗口对象的全局引用,如果不这么做的话,当JavaScript对象被
// 垃圾回收的时候,窗口会被自动地关闭
let mainWindow;
let webviewContentsId = null;

function createWindow() {
  // 创建浏览器窗口
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    autoHideMenuBar: false,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      enableRemoteModule: false,
      preload: path.join(__dirname, "preload.js"),
      webviewTag: true,
      webSecurity: false,
    },
    icon: path.join(__dirname, "..", "public", "icon.png"), // 可选:应用图标
    show: false, // 先隐藏窗口
    titleBarStyle: "default",
  });

  // 加载应用
  if (isDev) {
    // 开发环境:加载本地服务器
    mainWindow.loadURL("http://localhost:5173");

    // 打开开发者工具
    mainWindow.webContents.openDevTools();
  } else {
    // 生产环境:加载打包后的文件
    mainWindow.loadFile(path.join(__dirname, "..", "dist", "index.html"));
    mainWindow.webContents.openDevTools();
  }

  // 当窗口准备好显示时显示
  mainWindow.once("ready-to-show", () => {
    mainWindow.show();
  });

  // 当窗口被关闭时发出
  mainWindow.on("closed", () => {
    // 取消引用 window 对象,如果你的应用支持多窗口的话,
    // 通常会把多个 window 对象存放在一个数组里面,
    // 与此同时,你应该删除相应的元素。
    mainWindow = null;
  });
  // 👉 监听渲染进程请求 preload 路径
  ipcMain.handle("get-webview-preload-path", () => {
    // return path.join(__dirname, 'preload.js') // 动态返回 webview 的 preload 路径
    const preloadFullPath = path.join(__dirname, "preload.js");
    return `file://${preloadFullPath}`;
  });
  ipcMain.on("webview-id", (event, id) => {
    webviewContentsId = id;
    console.log("保存webview webContentsId:", id);
  });
  ipcMain.on("webview-message", (event, message) => {
    console.log("收到来自webview的消息:", message);
    if (webviewContentsId) {
      const wc = webContents.fromId(webviewContentsId);
      if (wc) {
        wc.send(
          "main-to-webview",
          `主进程收到消息: ${message} ${new Date().valueOf()}`,
        );
      }
    }
  });
}

// 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法
app.whenReady().then(() => {
  // 设置IPC处理器
  setupIpcHandlers();

  // 创建菜单
  // createMenu()

  // 创建窗口
  createWindow();

  // 在 macOS 中,当所有窗口都被关闭的时候,重新创建一个窗口是一个常见的做法
  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

// 当全部窗口关闭时退出应用
app.on("window-all-closed", () => {
  // 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
  // 否则绝大部分应用及其菜单栏会保持激活。
  if (process.platform !== "darwin") {
    app.quit();
  }
});

// 在这个文件中,你可以续写应用剩下主进程代码。
// 也可以拆分成几个文件,然后用 require 导入。

关键设计点说明

1️⃣ 动态返回 webview 的 preload 路径

js
ipcMain.handle("get-webview-preload-path", () => {
  const preloadFullPath = path.join(__dirname, "preload.js");
  return `file://${preloadFullPath}`;
});

原因只有一个:

渲染进程无法自行可靠地拼出 preload 的绝对路径

由主进程统一返回,是最稳妥、也最易维护的方案。


2️⃣ 使用 webContentsId 精准定位 webview

js
ipcMain.on("webview-id", (event, id) => {
  webviewContentsId = id;
});
  • 每一个 webview 都对应一个独立的 webContents
  • 拿到 ID 后,主进程即可通过:
js
webContents.fromId(webviewContentsId);

进行定向通信,而不是广播。


3️⃣ 主进程作为“中立路由器”

js
ipcMain.on("webview-message", (event, message) => {
  const wc = webContents.fromId(webviewContentsId);
  wc?.send("main-to-webview", message);
});

主进程不关心页面细节,只负责:

  • 接收
  • 校验(可扩展)
  • 转发

这是 webview 架构里最健康的职责划分


webview preload:唯一可信的能力入口

preload.jswebview 世界与 Electron 世界之间的唯一桥梁

完整代码保持不变:

js
const { contextBridge, ipcRenderer } = require("electron");

const electron = {
  ipcRenderer: {
    invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
    send: (channel, ...args) => ipcRenderer.send(channel, ...args),
    on: (channel, listener) => ipcRenderer.on(channel, listener),
    removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
  },
};

const electronAPI = {
  sendWebContentsId: (id) => ipcRenderer.send("webview-id", id),

  sendMessageToElectron: (msg) => {
    ipcRenderer.send("webview-message", msg);
  },
  onMainToWebview: (callback) => {
    ipcRenderer.on("main-to-webview", (event, data) => {
      callback(data);
    });
  },
};
if (process.contextIsolated) {
  try {
    // 暴露 electron ipcRenderer 部分 API
    contextBridge.exposeInMainWorld("electron", electron);

    // 这里必须暴露对象,不能直接暴露布尔
    contextBridge.exposeInMainWorld("__isElectron", true);

    // 额外暴露给网页用来发送消息给主进程
    contextBridge.exposeInMainWorld("electronAPI", electronAPI);
  } catch (error) {
    console.error(error);
  }
} else {
  window.electron = electron;
  window.electronAPI = electronAPI;
  window.__isElectron = true;
}

preload 的职责边界

它只做三件事:

  1. 封装 ipcRenderer(而不是直接暴露)
  2. 暴露业务级 API(electronAPI
  3. 注入环境标记:window.__isElectron

这样做的直接好处是:

  • 第三方页面完全不依赖 Electron 内部实现
  • API 可被版本化、文档化、甚至单独发包

Vue 宿主页面:把 webview 当成组件

App.vue 中,webview 被当成一个受控组件来使用。

vue
<template>
  <div class="app">
    <webview
      v-if="preloadPath"
      ref="webviewRef"
      :src="webUrl"
      style="width: 100%; height: 100%"
      :preload="preloadPath"
      nodeintegration
      :webpreferences="{ contextIsolation: true, nodeIntegration: true }"
      @dom-ready="onReady"
    />
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
const webviewRef = ref(null);
const webUrl = ref("http://test.com/api/renderer.html");
const preloadPath = ref("");

onMounted(async () => {
  const preload = await window.electron.ipcRenderer.invoke(
    "get-webview-preload-path",
  );
  // 把 Windows 路径转换成 file:// 协议路径
  preloadPath.value = preload;
});

const onReady = () => {
  webviewRef.value.openDevTools();
  // 主动发送webContentsId给主进程
  const id = webviewRef.value.getWebContentsId();
  window.electronAPI.sendWebContentsId(id);
};
</script>

<style scoped>
.app {
  width: 100vw;
  height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
  display: flex;
  flex-direction: column;
}
</style>

设计亮点拆解

  • preloadPath 异步获取:避免硬编码路径
  • v-if="preloadPath":防止 webview 提前初始化
  • @dom-ready:作为 webview 的生命周期起点
  • 主动回传 webContentsId:建立通信锚点

这一层的核心思想是:

webview 是能力组件,而不是 iframe


被嵌入页面:彻底与 Electron 解耦

renderer.html 与 BrowserWindow 示例保持一致,完整代码如下(未删减):

html
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>测试页面</title>
    <style>
      body {
        font-family: Arial, sans-serif;
        max-width: 800px;
        margin: 0 auto;
        padding: 20px;
      }

      button {
        padding: 8px 16px;
        margin: 5px;
        cursor: pointer;
      }

      #Env,
      #message {
        margin: 15px 0;
        padding: 10px;
        border: 1px solid #ddd;
        min-height: 20px;
      }
    </style>
  </head>

  <body>
    <h1>Third-party page (simulated)</h1>
    <button id="button">判断环境</button>
    <button id="sendBtn">发送消息给 Electron</button>
    <div id="Env"></div>
    <div id="message"></div>

    <script>
      window.addEventListener("DOMContentLoaded", () => {
        const elements = {
          button: document.getElementById("button"),
          sendBtn: document.getElementById("sendBtn"),
          envDisplay: document.getElementById("Env"),
          messageDisplay: document.getElementById("message"),
        };

        const checkEnvironment = () => {
          const isElectron = Boolean(window.__isElectron);
          console.log(
            "[Renderer]",
            `环境检测: ${isElectron ? "Electron" : "Browser"}`,
          );

          elements.envDisplay.textContent = isElectron
            ? "当前运行在 Electron 环境中"
            : "当前是浏览器普通环境";

          // 新增:浏览器环境下隐藏相关元素
          if (!isElectron) {
            elements.sendBtn.style.display = "none";
            elements.messageDisplay.style.display = "none";
          } else {
            elements.sendBtn.style.display = "";
            elements.messageDisplay.style.display = "";
          }
        };

        const sendMessageToElectron = () => {
          if (window.electronAPI?.sendMessageToElectron) {
            window.electronAPI.sendMessageToElectron("Hello from WebView!");
          } else {
            console.warn("[Renderer]", "electronAPI 不可用");
          }
        };

        elements.button.addEventListener("click", checkEnvironment);
        elements.sendBtn.addEventListener("click", sendMessageToElectron);

        if (window.electronAPI?.onMainToWebview) {
          window.electronAPI.onMainToWebview((data) => {
            elements.messageDisplay.textContent = data;
            console.log("[Renderer]", "收到主进程消息:", data);
          });
        } else {
          console.warn("[Renderer]", "无法建立主进程通信");
        }

        // 新增:初始化时自动检测环境
        checkEnvironment();
      });
    </script>
  </body>
</html>

页面侧只依赖三个抽象

  • window.__isElectron
  • electronAPI.sendMessageToElectron
  • electronAPI.onMainToWebview

这意味着:

  • 同一份页面
  • 可以运行在 浏览器 / Electron / webview

这是长期可维护架构的关键。


运行与调试建议

强烈建议同时打开 三个 DevTools

  1. 主进程日志
  2. BrowserWindow(Vue)DevTools
  3. webview 内部 DevTools

重点检查:

  • preload 是否正确加载
  • webContentsId 是否成功回传
  • IPC 通道是否一一对应

什么时候该用 webview?

适合:

  • 嵌入完整、复杂的第三方系统
  • 需要强隔离、独立会话
  • 需要多 webview 并存

不适合:

  • 简单页面展示
  • 不需要双向通信的场景

总结

如果说 BrowserWindow 是「直接托管网页」,

那么 webview 更像是:

在你自己的前端应用里,运行一个“受管控的浏览器实例”

通过本文这套模式,你可以:

  • 安全
  • 可控
  • 可扩展

的在 Electron 中嵌入第三方 Web 系统。