Electron + React应用如何实现多窗口数据共享
概述
当我在设计跨平台(安卓、electron桌面端)应用的架构方案时,考虑的无非是几个因素:
- 跨平台脚本按需加载,抹平平台差异性
- 开发无感知。比如electron端多窗口是通过window.open实现,在安卓端就是一个简单的web弹窗。如何将这个解耦,是我一直思考的问题
对于第一点,我在上一篇文章已经介绍过。今天介绍的是多窗口数据共享方案
windows多窗口之间通信有很多种方式,比如可以通过window.opener或者postmessage等。但这些方式都比较繁琐。今天介绍一种能让我们用简单的方式实现window多窗口之间的数据共享。比如下图,父子窗口直接共享数据。可以点击这里在线尝试一下这个demo
对应的源码如下:
import { useState } from "react";
import "./App.css";
import Modal from "./Modal";
function App() {
const [count, setCount] = useState(0);
const [modalVisible, setModalVisible] = useState(false);
return (
<div className="App">
<div
onClick={() => {
setCount(count + 1);
}}
>
这是主窗口计数器:<span className="count">{count}</span>
</div>
<div>
<button
onClick={() => {
setModalVisible(true);
}}
>
打开子窗口
</button>
</div>
<Modal onClose={() => setModalVisible(false)} visible={modalVisible}>
<div onClick={() => setCount(count + 1)}>
子窗口共享父窗口的计数器:<span className="count">{count}</span>
</div>
</Modal>
</div>
);
}
export default App;
可以看到,这里面子窗口我封装成了一个Modal组件,把窗口当作一个弹窗使用,极其简单。在开始介绍Modal组件的实现方式前,我们先来看下window.open的特性
window.open
当我们使用window.open打开一个子窗口时,我们可以获得子窗口的句柄,我们就可以使用这个句柄调用子窗口的方法和属性。然后在子窗口可以通过window.opener访问父窗口的方法
const externalWindow = window.open(
"",
"",
"top=300,width=300,height=300,left=200"
)
如果我们给window.open第一个参数传入空字符串,那么这两个窗口属于同源窗口,父子窗口的数据完全共享!
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Open Window Test</title>
<style>
span {
color: red;
}
div {
margin-bottom: 20px;
}
</style>
</head>
<body>
<div id="parent-window">
父窗口的计数器:<span id="parent-count">0</span>
</div>
<div>
<button id="open-child">打开子窗口</button>
</div>
<div id="child-window">子窗口的计数器:<span id="child-count">0</span></div>
</body>
<script>
const openChildBtn = document.getElementById("open-child");
const childWindow = document.getElementById("child-window");
const parentWindow = document.getElementById("parent-window");
const childCount = document.getElementById("child-count");
const parentCount = document.getElementById("parent-count");
let count = 0;
openChildBtn.onclick = () => {
const externalWindow = window.open(
"",
"",
"top=300,width=300,height=300,left=200"
);
externalWindow.document.body.appendChild(childWindow);
};
const render = () => {
childCount.innerHTML = count;
parentCount.innerHTML = count;
};
childWindow.onclick = () => {
count++;
render();
};
parentWindow.onclick = () => {
count++;
render();
};
</script>
</html>
在上面的代码中,当我们点击打开子窗口时,调用window.open打开了一个子窗口,同时将父窗口的
child-window
接点append到子窗口中。不管是点击父窗口还是子窗口中的按钮,两边的计数器都同时更新!但美中不足的是,子窗口计数器的颜色原本是红色,现在变成了黑色?这是什么原因
实际上当我们打开子窗口时,并没有给子窗口添加任何样式,这点可以通过检查控制台就知道了
要解决这个问题也很简单,我们只需要在打开子窗口时,将父窗口的head标签clone到子窗口就可以
openChildBtn.onclick = () => {
const externalWindow = window.open(
"",
"",
"top=300,width=300,height=300,left=200"
);
externalWindow.document.body.appendChild(childWindow);
externalWindow.document.body.appendChild(document.head.cloneNode(true));
};
效果如下:
但是,这里有个问题,如果我们关闭子窗口,然后再次打开时,此时再点击子窗口,发现计数器没有更新,这是为什么?
原因很简单,当我们关闭子窗口时,浏览器会卸载所有dom节点的事件监听器,childWindow.onclick此时会被重置为null。我们再次打开子窗口时,需要给子窗口的dom节点再次绑定事件。
小结
当我们通过window.open打开子窗口时,如果传入的第一个参数是空字符串,那么子窗口可以直接使用父窗口的变量、dom节点。以上面的child-window
节点为例,父子窗口共享同一个dom节点引用。
window.open 结合 React createPortal 实现子窗口
通过前面的demo我们知道,window.open打开的子窗口,关闭再打开时,事件会被卸载。而React createPortal在挂载的时候会重新绑定事件。因此我们可以利用这些特性实现父窗口打开子窗口的功能,同时能保持各种事件正常,数据共享等。下面是Modal组件的实现:
import React, { memo, useRef, useMemo, useEffect } from "react";
import ReactDOM from "react-dom";
import { stringify } from "qs";
import { copyStyles } from "./util";
const WindowPortal = ({
children,
closeAfterBlur = true,
onBlur,
onClose,
visible,
winOptions,
}) => {
const windowInstance = useRef(null);
const winOptionRef = useRef(null);
const containerEl = useMemo(() => {
if (visible) {
return document.createElement("div");
}
}, [visible]);
winOptionRef.current = winOptions;
useEffect(() => {
if (!visible) {
windowInstance.current && windowInstance.current.close();
return;
}
// 默认选项
const defaultBrowserWindowOptions = {
transparent: true,
backgroundColor: "#00000000",
width: 400,
height: 400,
// frame: false,
// x: 0,
// y: 0,
// movable: true,
// resizable: false,
};
const browserwinOptions = {
...defaultBrowserWindowOptions,
...winOptionRef.current,
};
browserwinOptions.__portalType = "modal"; // 和主进程约定的协议,用于拦截窗口的创建
const { x, width, y, height } = browserwinOptions;
// 如果没有传递x和y,则打开时默认居中
const left = x === undefined ? window.screen.width / 2 - width / 2 : x;
let top = y === undefined ? window.screen.height / 2 - height / 2 : y;
windowInstance.current = window.open(
"",
stringify(browserwinOptions),
`left=${left},top=${top},width=${width},height=${height}`
);
if (!windowInstance.current) return;
containerEl?.setAttribute("style", "width: 100%; height: 100%;");
windowInstance.current.document.body.appendChild(containerEl);
windowInstance.current.document.head.innerHTML = "";
copyStyles(document, windowInstance.current.document);
}, [visible, containerEl]);
useEffect(() => {
if (!windowInstance.current) return;
const onBlurInner = () => {
// 默认失焦关闭
if (closeAfterBlur && windowInstance.current) {
windowInstance.current.close();
onClose && onClose();
}
if (onBlur) {
onBlur();
}
};
const onBeforeunload = () => {
onClose();
};
windowInstance.current.addEventListener("blur", onBlurInner);
windowInstance.current.addEventListener("beforeunload", onBeforeunload);
return () => {
if (!windowInstance.current) return;
windowInstance.current.removeEventListener("blur", onBlurInner);
windowInstance.current.removeEventListener(
"beforeunload",
onBeforeunload
);
};
}, [visible, onBlur, onClose, closeAfterBlur]);
useEffect(() => {
window.addEventListener("beforeunload", () => {
windowInstance.current && windowInstance.current.close();
});
return () => {
windowInstance.current && windowInstance.current.close();
};
}, []);
if (!visible) return null;
return ReactDOM.createPortal(children, containerEl);
};
export default memo(WindowPortal);
copyStyles
方法主要负责复制父窗口的样式并应用到子窗口,实现如下:
export const copyStyles = (sourceDoc, targetDoc) => {
Array.from(sourceDoc.styleSheets).forEach((styleSheet) => {
if (styleSheet.cssRules) {
// 内联样式
const newStyleEl = sourceDoc.createElement('style');
Array.from(styleSheet.cssRules).forEach((cssRule) => {
newStyleEl.appendChild(sourceDoc.createTextNode(cssRule.cssText));
});
targetDoc.head.appendChild(newStyleEl);
} else if (styleSheet.href) {
// 外联样式
const newLinkEl = sourceDoc.createElement('link');
newLinkEl.rel = 'stylesheet';
newLinkEl.href = styleSheet.href;
targetDoc.head.appendChild(newLinkEl);
}
});
};
这样我们就可以实现在web端打开多个子窗口,将子窗口作为modal使用,并且能够很方便地共享数据。具体demo可以在这里玩玩,具体源码在这里。
Electron多窗口数据共享
多窗口的场景在web端是很少见的。但是在electron端,就比较常见。一般来说,在electron端多窗口的通信无非就是基于ipc的多进程通信方式。但是如果我们的应用是单页应用,独立部署。如果还是基于ipc通信的方式达到多窗口数据共享的话,就比较麻烦。通过上面的例子,我们就可以简单的实现electron多窗口数据共享。
在electron端,我们只需要拦截window.open创建窗口的事件,并调用new Browserwindow创建新的窗口即可
const { app, BrowserWindow, Menu } = require("electron");
const path = require("path");
const { parse } = require("qs");
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nativeWindowOpen: true, // 如果要在render进程中通过window.open打开新的窗口,则需要设置这个值为true
},
});
win.webContents.on(
"new-window",
(event, url, frameName, disposition, options, additionalFeatures) => {
const frameOptions = parse(frameName) || {};
for (let key in frameOptions) {
const v = frameOptions[key];
if (v === "true" || v === "false" || !isNaN(Number(v))) {
frameOptions[key] = JSON.parse(v);
}
}
if (frameOptions.__portalType === "modal") {
event.preventDefault();
options = {
...options,
...frameOptions,
};
event.newGuest = new BrowserWindow(options);
}
}
);
win.loadURL("http://localhost:3000");
win.webContents.openDevTools();
};
app.whenReady().then(() => {
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
转载自:https://juejin.cn/post/7183201422509506617