什么是前端跨页面通信
在浏览器中,我们同时打开多个tab页,每个tab页可以粗略的理解为一个独立的运行环境,即使是全局对象也不会在多个tab间共享。然后我们希望这些独立的tab页之间同步数据、信息或状态。
比如:我们同时打开了列表页和a的详情页,在列表页对a的状态进行操作后,我们切到详情的tab页,在不进行刷新页面时,要怎么把在列表页操作后的状态同步过来?
Broadcast channel
BroadcastChannel 接口代理了一个命名频道,可以让指定 origin 下的任意 browsing context 来订阅它。它允许同源的不同浏览器窗口,Tab页,frame或者 iframe 下的不同文档之间相互通信。通过触发一个 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。
- 可以通过 BroadcastChannel() 创建一个链接到命名频道的对象
- 属性: name
- Event: message\ messageerror
- 方法: close()/postMessage()
// 创建一个 test1 的频道
const bc = new BroadcastChannel('test1')
console.log(bc.name) // test1
// 各个页面可以通过 onmessage 来监听被广播的消息
bc.onmessage = function(e) {
const data = e.data
}
// 发送消息时调用 postMessage 方法
bc.postMessage({from: 'other', msg});
// 关闭频道对象,告诉它不要再接收新的消息,并允许它最终被垃圾回收
bc.close()
注:同一个tab也发送的消息,该tab页监听不到
localStorage
当前页面使用的 storage 被其他页面修改时会触发 StorageEvent 事件。利用这个特性,我们可以在发送消息时,把消息写到 localStorage/sessionStorage 中,然后在各个页面内,通过监听 storage 事件即可收到通知。
属性:
- key:该属性代表被修改的键值。当被clear()方法清除之后该属性值为null。
- newValue:该属性代表修改后的新值。当被clear()方法清理后或者该键值对被移除,newValue 的值为 null
- oldValue:该属性代表修改前的原值。在设置新键值对时由于没有原始值,该属性值为 null
- storageArea:被操作的storage对象
- url:key发生改变的对象所在文档的URL地址
// 在各页面去通过监听 storage 事件去接收消息
window.addEventListener('storage', (e) => {
if (e.key === 'localStorage-msg') {
let data = JSON.parse(e.newValue)
let msgEle = document.createElement('p')
msgEle.innerHTML = data.date + ' ' + data.from + ': ' + data.msg
receiveEle.appendChild(msgEle)
}
})
// localStorage
window.localStorage.setItem('localStorage-msg', msg)
// sessionStorage
// window.sessionStorage.setItem('sessionStorage-msg', msg)
localStorage和sessionStorage区别:
- 存储在localStorage的数据可以长期保存;sessionStorage的数据在关闭当前页面时就会被清除。localStorage 可能因清除不及时引发问题
- 打开多个相同的URL tabs页时,会创建各自的sessionStorage,而localstorage只要在相同的协议、主机名和端口号,就能读取/修改同一份localStorage数据。sessionStorage只适用于iframe 的数据同步。
Service Worker
Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器,可以长期运行在后台,能够实现与页面的双向通信。多页面共享间的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现广播效果。
Service Worker 并不自动具备“广播通信”的功能,需要改造 Service Worker 的运行脚本,将其改造成消息中转站。在 Service Worker 中监听message事件,获取页面发送的信息。然后通过 self.clients.matchAll() 获取当前注册了 Service Worker 的所有页面,通过调用每个client的 postMessage 方法,向页面发送消息。这样就把从一处(某个Tab页面)收到的消息通知给了其他页面。
简单来说:就是通过worker和主线程之间能双向通信的特性,在worker脚本中接收到来自主线程的消息时,就将接收的消息发送给所有注册了该线程的页面
// page1.html
// 在页面注册 service worker
navigator.serviceWorker.register('./serviceWorker.js').then(() => {
console.log('Service Worker 注册成功');
})
// 在页面中监听 service Worker 发来的消息
navigator.serviceWorker.addEventListener('message', (e) => {
const data = e.data;
let msgEle = document.createElement('p')
msgEle.innerHTML = data.date + ' ' + data.from + ': ' + data.msg
receiveEle.appendChild(msgEle)
})
// 发送消息
btnSend.addEventListener('click', () => {
let msg = {
from: 'page 1',
msg: sendEle.value,
date: new Date().toLocaleString()
}
// 调用service worker 的 postMessage方法发送消息
navigator.serviceWorker.controller.postMessage(msg)
})
// serviceworker.js
self.addEventListener('message', (e) => {
console.log('serviceworker message', e)
e.waitUntil(
// self.clients.matchAll() 获取当前注册了该 service Worker 的所有页面
self.clients.matchAll().then((clients) => {
if(!clients || clients.length === 0) {
return
}
// clients 中记录了每一个tab页/iframe都记录在里面
clients.forEach((client) => {
console.log(7777, client)
client.postMessage(e.data)
})
})
)
})
SharedWorker
SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域,如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的。同一个 js url 只会创建一个SharedWorker,其他页面内使用同样的url创建sharedWorker,会复用已创建的worker。
- 通过SharedWorker()构造函数创建一个执行指定url脚本的共享 web worker
- 创建完SharedWorker() 会返回一个MessagePort 对象来访问worker,这个对象用SharedWorker.port 属性获得
- 如果已经用 addEventListener 监听了 onmessage 事件,则可以使用 start() 方法手动启动端口
- 在执行脚本中使用 onconnect 处理程序连接到相同端口
实现思路:简单来说,我们创建的这个共享线程是可以跟主线程进行双向通信的,利用这个特性,我们在worker 脚本里去收集所有共享了这个worker线程的端口,每当收到来自主线程的消息时,就就将收到的消息发送给所有共享了这个脚本的端口
// page.html 页面
if (!window.SharedWorker) {
alert("浏览器不支持SharedWorkder!")
} else {
// 创建一个共享进场对象
let myWorker = new SharedWorker("./worker.js")
// 通过监听 port.onmessage 监听worker的返回消息
myWorker.port.onmessage = function(e) {
console.log(5555, e)
let msgEl = document.createElement("p")
let data = e.data;
msgEl.innerText = data.date + " " + data.from + ":" + data.message
messagesEle.appendChild(msgEl)
};
btnSend.addEventListener("click", () => {
let message = messageEl.value
// 发送消息,将消息发送到 worker
myWorker.port.postMessage({
date: new Date().toLocaleString(),
message,
from: "page 1"
})
});
// 手动开启端口:启动发送该端口的消息队列;在未调用之前,发往该端口的消息都在队列中等待
myWorker.port.start()
// worker.js
// 通过这个worke.js 的脚本,实现让worker向多个页面发送消息, 讲port缓存在一个数组里,当需要向所有页面广播消息时,就遍历数组发送消息
let portList = []
onconnect = function(e) {
let port = e.ports[0]
// 在connect时将 port添加到 portPool中
ensurePorts(port)
port.onmessage = function(e) {
let data = e.data
// 将消息发送到每一个注册了该脚本的页面
broadcast(data)
}
port.start()
}
function ensurePorts(port) {
if (portList.indexOf(port) < 0) {
portList.push(port);
}
}
// 一个tab页是一个port,相同的两个tab页,在一个页面发送消息时,另一个页面也可以收到消息
function broadcast(msg) {
portList
.filter(port => selfPort !== port)
.forEach(port => port.postMessage(msg))
}
window.open + window.opener
当我们使用window.open打开页面时,方法会返回一个被打开页面window的引用。而在未显示指定noopener时,被打开的页面可以通过window.opener获取到打开它的页面的引用 , 通过这种方式我们就将这些页面建立起了联系(一种树形结构),通过这个树形结构我们可以实现一种“口口相传”的模式来实现跨页面通信。
let windowObjectReference = window.open(strUrl, strWindowName, [strWindowFeatures])
- strUrl:要在新打开的窗口中加载的URL
- strWindowName:新窗口的名称
- strWindowFeatures:一个可选参数,列出新窗口的特征(大小,位置,滚动条等)作为一个DOMString
- windowObjectReference : 打开的新窗口对象的引用,如果父子窗口满足“同源策略”,你可以通过这个引用访问新窗口的属性或方法
<body>
<h3>windowopen Page 1</h3>
<input type="button" value="page2" id="open-p2" />
<input type="button" value="page2-2" id="open-p2-2" />
<section style="margin-top:50px; text-align: center">
<input id="inputMessage" value="page 1的测试消息" />
<input type="button" value="发送消息" id="btnSend" />
<section id="messages">
<p>收到的消息:</p>
</section>
</section>
<script>
let receiveEle = document.getElementById("messages");
let sendEle = document.getElementById("inputMessage");
let btnSend = document.getElementById("btnSend");
let btnOpen = document.getElementById('open-p2')
let btnOpen2 = document.getElementById('open-p2-2')
// 把我们用window.open 打开的页面用pageList收集起来
let pageList = []
btnOpen.addEventListener('click', () => {
const win = window.open('./page2.html')
pageList.push(win)
})
btnOpen2.addEventListener('click', () => {
const win = window.open('./page2-2.html')
pageList.push(win)
})
// 发送消息
btnSend.addEventListener('click', () => {
let msg = sendEle.value
sendParents(msg)
sendChile(msg)
})
// 传消息给用 window.open 打开该页面的页面-- 父页面
function sendParents(msg) {
if(window.opener && !window.opener.closed){
msg.fromOpener = true
window.opener.postMessage({
fromOpener: true,
msg
})
}
}
// 传消息给被该页面用 window.open 打开的页面
function sendChile(msg) {
pageList
.filter(w => !w.closed)
.forEach(w => {
console.log('send', w)
w.postMessage({
fromOpener: false,
msg
})
})
}
// 页面监听 message,当接收到消息后,在向其他页面传递改消息
window.addEventListener('message', (e) => {
console.log('message', e)
const data = e.data
if(data.fromOpener) {
sendParents(data.msg)
} else {
sendChile(data.msg)
}
let msgEle = document.createElement('p')
msgEle.innerHTML = `${data.fromOpener ? '来自子页面' : '来自父页面'}:${data.msg}`
receiveEle.appendChild(msgEle)
})
</script>
</body>
注:页面只监听得到父页面和子页面发送的消息,祖先页面和兄弟页面是监听不到的,所以在接收消息时要进行特殊处理。在页面进行强制刷新后,页面间会失去关联,导致无法再进行通信。
postMessage
window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号,以及主机时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。
otherWindow.postMessage(message, targetOrigin, [transfer])
- otherWindow:其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames
- message:将要发送到其他 window的数据
- targetOrigin:指定哪些窗口能接收到消息事件,其值可以是 *(表示无限制)或者一个 URI
- transfer:可选,是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
上面window.open 实现同源的跨页面通信就是通过 postMessage 来实现的,下面我们来看一下如何实现非同源多页面之间的通信呢?
注: postMessage 实现跨页面通信的前提条件是两个tab也之间有 “父子”关系,所以单靠 postMessage 是无法实现的。
实现思路:在需要实现非同源跨页面通信的页面都去用 iframe 标签内嵌一个不可见的 iframe.html 页面,在页面与 iframe.html 页面间,我们使用 postMessage 来实现父子页面间的非同源跨页面通信,在 iframe.html 里实现同源的跨页面通信,从而实现非同源的多页面间的跨页面通信。
<!-- // 父页面 -->
<body>
<h3>跨域 Page 1</h3>
<section style="margin-top:50px; text-align: center">
<input id="inputMessage" value="page 1的测试消息" />
<input type="button" value="发送消息" id="btnSend" />
<section id="messages">
<p>收到的消息:</p>
</section>
</section>
<iframe src="http://www.kuayu-iframe.com/iframe.html" id="child" style="opacity: 0;"></iframe>
<script>
// 创建一个标识为 bcc-msg 的频道
let receiveEle = document.getElementById("messages");
let sendEle = document.getElementById("inputMessage");
let btnSend = document.getElementById("btnSend");
// 跨域传递消息时
btnSend.addEventListener('click', () => {
let msg = sendEle.value
// 父页面给iframe子页面发送消息
window.frames[0].postMessage({
from: 'p1',
msg
}, 'http://www.kuayu-iframe.com/')
})
window.addEventListener('message', (e) => {
console.log('fromIframe', e)
const data = e.data.data
let msgEle = document.createElement('p')
msgEle.innerHTML = data.from + ': ' + data.msg
receiveEle.appendChild(msgEle);
})
</script>
</body>
<!-- // iframe.html 页面 -->
<body>
<script>
// 创建一个标识为 bcc-msg 的频道
let bc = new BroadcastChannel('kuayu-msg')
// 监听通过postMessage发送来的消息
window.addEventListener('message', (e) => {
const data = e.data
// 将接收到的消息,通过 Broadcast Channel 广播给其他tab页里的iframe
bc.postMessage({
from: 'iframe',
data
})
})
// 监听个iframe tab 页 传来的消息
bc.onmessage = function(e) {
const data = e.data
// 从iframe页面将消息传递给父页面
window.parent.postMessage(e.data, '*')
}
</script>
</body>
总结:
- 广播模式:Broadcast Channe / Service Worker / StorageEvent / Shared Worker
- 口口相传模式:window.open + window.opener/postMessage
- 基于服务端:websocket / 轮询请求
各API实现跨页面的优缺点:
- Broadcast Channel:实时通信,随时关闭频道,功能全面;浏览器支持效果不好(IE-都不支持,chrome-54)
- storageEvent:浏览器支持效果好、API直观。部分浏览器隐身模式下,无法设置localStorage(如safari)。可能因LocalStorage清理不及时而引起问题
- shareworker:异步处理,不占用主线程,不用 ie 浏览器的话,还是非常推荐的(IE-都不支持)
- serviceworker:浏览器支持度不高,开启service worker可能会导致浏览器的缓存数据大大增加(IE-都不支持)
- window.open + window.opener:实现方式不优雅,无法形成统一的解决方案,且在强制刷新页面后会失去关联导致无法通信;实现简单,浏览器兼容性好
- postMessage:点对点通信,局限性比较大,可扩展性比较差。