上文介绍了 如何在 Electron 中优雅的进行进程间通讯,接下来说说如何在 Electron 实现多标签页模式,如下图。
Electron 都发展这么多年了,让人想不到的是,要实现一个多标签页的功能居然没有能用的轮子。能在 Github 上找到 Star 最多的一个轮子(Tab component for Electron)也已经不再更新,而且还是使用 Electron 建议不再使用的 WebView 实现的(Web 嵌入 | Electron)。后面也有人基于 BrowserView 实现了一套,但是现在 Electron 又不推荐使用 BrowserView 了,建议使用 WebContentsView。因为项目比较急,没有花太多时间去研究了,就用比较 low 的方案 - iframe 自己搓了一个。
直接看 HTML 的结构吧,如下
也就是一个 tab 对应一个 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]; // 执行完回调删除
}
}
});
};
渲染进程主动发送消息:
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 进行标识,发送消息就是给当前激活的 tab 对应的 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 页面,属于双向通讯。如果没有做好通讯的封装,处理起来想想都麻烦,而现在只需要关注业务代码就好了。
完整代码:
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();
}
}