Electron多标签页实现:基于iframe的通信方案与局限

作者:桌面漫游时间:2026-10-31 17:05:56浏览:410

本文详解在Electron中实现多标签页的一种基于iframe的替代方案。针对官方推荐的BrowserView和WebContentsView因更新或兼容性导致的开发困境,文章展示了如何通过iframe结合postMessage机制,构建主进程、渲染进程与子页面间的双向通信链路。内容涵盖消息封装、刷新逻辑及登出流程,并指出了该方案在涉及第三方页面时因同源策略导致的通信局限。

Electron多标签页实现背景

前文已介绍如何在Electron中优雅地进行进程间通信,本文将重点探讨如何在Electron中实现多标签页模式,具体效果可参考下图。

Electron多标签页实现背景

尽管Electron已发展多年,但令人意外的是,目前尚未出现成熟且广泛使用的多标签页组件库。GitHub上Star数最高的“Tab component for Electron”已停止维护,且其实现依赖于Electron官方建议弃用的WebView(Web嵌入 | Electron)。后续虽有基于BrowserView的实现,但Electron官方目前更推荐使用WebContentsView。由于项目时间紧迫,未深入调研最新API,因此采用了一种较为基础的方案——利用iframe自行构建多标签页功能。

基于iframe的结构设计

首先查看HTML的结构,如下所示。

Electron多标签页实现背景

核心逻辑很简单,即每个标签页对应一个iframe元素。

多进程通信机制

界面实现相对简单,难点在于主进程、渲染进程(即包含iframe的页面)以及各个iframe之间的通信机制。

在实际业务场景中,关闭标签页时通常需要弹出确认框。用户确认后,iframe内的页面需调用接口执行登出操作,随后通知主进程关闭该窗口。这一消息链路涉及主进程、渲染进程和iframe页面,且需要支持双向通信。

渲染进程与iframe通信实现

前文已讲解主进程与渲染进程间的通信封装,此处重点阐述渲染进程(即宿主页面)与iframe之间的通信实现。

渲染进程负责监听并处理来自iframe的消息:

export const addIframeWebEventListener = () => {
  window.addEventListener("message", async (event) => {
    const message = event.data as {
      iframeWebCmd: string;
      cbid: string;
      code: number;
      data: never;
    };
    if (message.iframeWebCmd) {
      console.log(message);
      if (message.iframeWebCmd !== "postMessageCallback") {
        if (handle[message.iframeWebCmd]) {
          try {
            const res = await handle[message.iframeWebCmd](message.data);
            invokeCallback(message.cbid, res);
          } catch (ex: unknown) {
            invokeErrorCallback(message.cbid, ex);
          }
        } else {
          invokeErrorCallback(
            message.cbid,
            `方法不存在:${message.iframeWebCmd}`,
          );
        }
      } else {
        if (message.code === 200) {
          (callbacks[message.cbid] || function () {})(message.data);
        } else {
          (errorCallbacks[message.cbid] || function () {})(message.data);
        }
        delete callbacks[message.cbid]; // 执行完回调删除
        delete errorCallbacks[message.cbid]; // 执行完回调删除
      }
    }
  });
};

同时,渲染进程也可主动向iframe发送消息:

function postMessage(
  data: { electronWebCmd: string; data?: any },
  cb?: (data: any) => void,
  errorCb?: (data: any) => void,
) {
  const iframe = document.getElementById(
    tabStore.currentTabId.value!,
  ) as HTMLIFrameElement;
  if (cb) {
    const cbid = Date.now();
    callbacks[cbid] = cb;
    iframe?.contentWindow?.postMessage(
      {
        ...data,
        cbid,
      },
      "*",
    );
    if (errorCb) {
      errorCallbacks[cbid] = errorCb;
    }
  } else {
    iframe?.contentWindow?.postMessage(data, "*");
  }
}

export function request<T = unknown>(params: { cmd: string; data?: any }) {
  return new Promise<T>((resolve, reject) => {
    postMessage(
      { electronWebCmd: params.cmd, data: params.data },
      (res) => {
        resolve(res);
      },
      (error) => {
        reject(error);
      },
    );
  });
}

每个iframe均通过唯一的id进行标识,发送消息时只需指定当前激活标签页对应的iframe即可。

当需要渲染进程向iframe发送指令时,可封装为类似HTTP请求的调用方式。例如,触发iframe页面刷新:

export function refresh() {
  return request({
    cmd: "refresh",
  });
}

以下是相关完整代码:

/* eslint-disable no-case-declarations */
/* eslint-disable no-shadow */

import { useTabsStore } from "@/store/tabs";
import handle from "./handle";

/* eslint-disable @typescript-eslint/no-explicit-any */
const callbacks: { [propName: string]: (data: any) => void } = {};
const errorCallbacks: { [propName: string]: (data: any) => void } = {};

const tabStore = useTabsStore();

function postMessage(
  data: { electronWebCmd: string; data?: any },
  cb?: (data: any) => void,
  errorCb?: (data: any) => void,
) {
  const iframe = document.getElementById(
    tabStore.currentTabId.value!,
  ) as HTMLIFrameElement;
  if (cb) {
    const cbid = Date.now();
    callbacks[cbid] = cb;
    iframe?.contentWindow?.postMessage(
      {
        ...data,
        cbid,
      },
      "*",
    );
    if (errorCb) {
      errorCallbacks[cbid] = errorCb;
    }
  } else {
    iframe?.contentWindow?.postMessage(data, "*");
  }
}

export function request<T = unknown>(params: { cmd: string; data?: any }) {
  return new Promise<T>((resolve, reject) => {
    postMessage(
      { electronWebCmd: params.cmd, data: params.data },
      (res) => {
        resolve(res);
      },
      (error) => {
        reject(error);
      },
    );
  });
}

function invokeCallback<T = unknown>(cbid: string, res: T) {
  (
    document.getElementById(tabStore.currentTabId.value!) as HTMLIFrameElement
  )?.contentWindow?.postMessage(
    {
      electronWebCmd: "postMessageCallback",
      cbid,
      data: res,
      code: 200,
    },
    "*",
  );
}

function invokeErrorCallback(cbid: string, res: unknown) {
  (
    document.getElementById(tabStore.currentTabId.value!) as HTMLIFrameElement
  )?.contentWindow?.postMessage(
    {
      electronWebCmd: "postMessageCallback",
      cbid,
      data: res,
      code: 400,
    },
    "*",
  );
}

export const addIframeWebEventListener = () => {
  window.addEventListener("message", async (event) => {
    const message = event.data as {
      iframeWebCmd: string;
      cbid: string;
      code: number;
      data: never;
    };
    if (message.iframeWebCmd) {
      console.log(message);
      if (message.iframeWebCmd !== "postMessageCallback") {
        if (handle[message.iframeWebCmd]) {
          try {
            const res = await handle[message.iframeWebCmd](message.data);
            invokeCallback(message.cbid, res);
          } catch (ex: unknown) {
            invokeErrorCallback(message.cbid, ex);
          }
        } else {
          invokeErrorCallback(
            message.cbid,
            `方法不存在:${message.iframeWebCmd}`,
          );
        }
      } else {
        if (message.code === 200) {
          (callbacks[message.cbid] || function () {})(message.data);
        } else {
          (errorCallbacks[message.cbid] || function () {})(message.data);
        }
        delete callbacks[message.cbid]; // 执行完回调删除
        delete errorCallbacks[message.cbid]; // 执行完回调删除
      }
    }
  });
};

iframe内部页面同样需要监听并处理来自外部的消息:

export const addElectronWebWebEventListener = () => {
  window.addEventListener("message", async (event) => {
    const message = event.data as {
      electronWebCmd: string;
      cbid: string;
      code: number;
      data: never;
    };
    if (message.electronWebCmd) {
      if (message.electronWebCmd !== "postMessageCallback") {
        if (handle[message.electronWebCmd]) {
          try {
            const res = await handle[message.electronWebCmd](message.data);
            invokeCallback(message.cbid, res);
          } catch (ex: unknown) {
            invokeErrorCallback(message.cbid, ex);
          }
        } else {
          invokeErrorCallback(
            message.cbid,
            `方法不存在:${message.electronWebCmd}`,
          );
        }
      } else {
        if (message.code === 200) {
          (callbacks[message.cbid] || function () {})(message.data);
        } else {
          (errorCallbacks[message.cbid] || function () {})(message.data);
        }
        delete callbacks[message.cbid]; // 执行完回调删除
        delete errorCallbacks[message.cbid]; // 执行完回调删除
      }
    }
  });
};

iframe页面也可主动向外发送消息:

function postMessage(
  data: { iframeWebCmd: string; data?: unknown },
  cb?: (data: unknown) => void,
  errorCb?: (data: unknown) => void,
) {
  if (cb) {
    const cbid = Date.now();
    callbacks[cbid] = cb;
    window.parent?.postMessage(
      {
        ...data,
        cbid,
      },
      "*",
    );
    if (errorCb) {
      errorCallbacks[cbid] = errorCb;
    }
  } else {
    window.parent?.postMessage(data, "*");
  }
}

export function request<T = unknown>(params: { cmd: string; data?: unknown }) {
  return new Promise<T>((resolve, reject) => {
    postMessage(
      { iframeWebCmd: params.cmd, data: params.data },
      (res) => {
        resolve(res as T);
      },
      (error) => {
        reject(error);
      },
    );
  });
}

通过上述封装,iframe页面发送消息变得十分简洁:

/**
 * @description 获取 mac 地址
 * @returns
 */
export const getMac = () => {
  return request<string>({
    cmd: "getMac",
  });
};

以获取mac地址为例,消息传递路径为:iframe页面 -> 渲染进程 -> 主进程,随后反向返回:主进程 -> 渲染进程 -> iframe页面,实现了完整的双向通信。若未做好通信封装,处理此类逻辑将极为繁琐;而通过现有封装,开发者仅需关注业务代码本身。

以下是iframe通信部分的完整代码:

import handle from "./handle";

/* eslint-disable no-shadow */
const callbacks: { [propName: string]: (data: unknown) => void } = {};
const errorCallbacks: { [propName: string]: (data: unknown) => void } = {};
function postMessage(
  data: { iframeWebCmd: string; data?: unknown },
  cb?: (data: unknown) => void,
  errorCb?: (data: unknown) => void,
) {
  if (cb) {
    const cbid = Date.now();
    callbacks[cbid] = cb;
    window.parent?.postMessage(
      {
        ...data,
        cbid,
      },
      "*",
    );
    if (errorCb) {
      errorCallbacks[cbid] = errorCb;
    }
  } else {
    window.parent?.postMessage(data, "*");
  }
}

export function request<T = unknown>(params: { cmd: string; data?: unknown }) {
  return new Promise<T>((resolve, reject) => {
    postMessage(
      { iframeWebCmd: params.cmd, data: params.data },
      (res) => {
        resolve(res as T);
      },
      (error) => {
        reject(error);
      },
    );
  });
}

function invokeCallback<T = unknown>(cbid: string, res: T) {
  window.parent?.postMessage(
    {
      iframeWebCmd: "postMessageCallback",
      cbid,
      data: res,
      code: 200,
    },
    "*",
  );
}

function invokeErrorCallback(cbid: string, res: unknown) {
  window.parent?.postMessage(
    {
      iframeWebCmd: "postMessageCallback",
      cbid,
      data: res,
      code: 400,
    },
    "*",
  );
}
export const addElectronWebWebEventListener = () => {
  window.addEventListener("message", async (event) => {
    const message = event.data as {
      electronWebCmd: string;
      cbid: string;
      code: number;
      data: never;
    };
    if (message.electronWebCmd) {
      if (message.electronWebCmd !== "postMessageCallback") {
        if (handle[message.electronWebCmd]) {
          try {
            const res = await handle[message.electronWebCmd](message.data);
            invokeCallback(message.cbid, res);
          } catch (ex: unknown) {
            invokeErrorCallback(message.cbid, ex);
          }
        } else {
          invokeErrorCallback(
            message.cbid,
            `方法不存在:${message.electronWebCmd}`,
          );
        }
      } else {
        if (message.code === 200) {
          (callbacks[message.cbid] || function () {})(message.data);
        } else {
          (errorCallbacks[message.cbid] || function () {})(message.data);
        }
        delete callbacks[message.cbid]; // 执行完回调删除
        delete errorCallbacks[message.cbid]; // 执行完回调删除
      }
    }
  });
};

方案局限性与第三方页面限制

在Electron中基于iframe实现多标签页存在一个显著缺陷:若iframe加载的是第三方页面,则无法与其进行跨域通信。例如,在实现刷新标签页功能时,向iframe发送消息后,第三方页面需执行如下代码(受限于同源策略,通常无法直接注入或通信):

refresh: () => {
    const iframeID = getIframeId();
    if (iframeID) {
      let href = location.href;
      if (href.indexOf("?") === -1) {
        href = href + `?iframeId=${iframeID}`;
      } else {
        if (href.indexOf("iframeId") === -1) {
          href = href + `&iframeId=${iframeID}`;
        }
      }
      location.href = href;
      setTimeout(() => {
        location.reload();
      }, 500);
    } else {
      location.reload();
    }
  }

至此,关于Electron多标签页模式的实现详解已完成。如需了解更多Electron多标签页相关内容,建议查阅本站过往文章或继续浏览后续系列教程。感谢大家的支持!

作者最新文章