前言
欢迎关注同名公众号《熊的猫》,文章会同步更新!
在日常工作中,面对不同的需求场景,你可能会遇到需要进行多文档页面间交互的实现,例如在 A 页面跳转到 B 页面进行某些操作后,A 页面需要针对该操作做出一定的反馈等等,这个看似简单的功能,却也需要根据不同场景选择不同的方案。
这里所说的场景实际上可分为两个大方向:同源策略、文档加载方式,那么本篇文章就来探讨一下这两个方面。
同源策略 & 文档加载方式
在正式开始之前,我们还是先简单聊一下同源策略和页面加载方式,如果你已经足够了解了,可以选择跳过阅读。
同源策略
基本概念
所谓的 同源策略 实际上是 浏览器 的一个重要的 安全策略,主要是用于限制 一个源 的文档 或者 其加载的脚本 是否可以与 另一个源 的资源进行交互。
【注意】这里的目标是浏览器,也就是只有浏览器有同源策略的限制,例如服务端就不存在什么同源策略,这里的浏览器包括 桌面端浏览器、移动端浏览器、微信内置浏览器、虚拟浏览器(
虚拟环境中运行的网络浏览器
) 等。
所谓的 源 就是我们常说的 协议、主机名(域名)、端口,所以所谓的 同源 也就是指两个 URL 的 协议、主机名(域名)、端口 等信息要完全匹配。
主要作用
同源策略 可以用来阻隔恶意文档,减少可能被攻击的媒介,下面还是通过一个 CSRF 例子讲解一下没有同源限制会发生什么。
CSRF 攻击
假设你在 A 网站上进行了登录并成功登入网站后,你发现 A 网站上出现了一个广告弹窗(),于是放纵不羁爱自由的你(写着:拒绝 huang,拒绝 du,拒绝 pingpangqiu
)点开了它,发现这个网站居然不讲武德,啥也不是...为了验证真理
表明平静如水,背地里实则已经悄悄向 A 站点服务器 发送了请求操作,并且身份验证信息用的是你刚刚登录的认证信息(由于没有同源限制 cookies
会被自动携带在目标请求中),但服务端并不知道这是个假冒者,于是允许了本次操作,结果就是......
文档加载方式
因为这里是说多页面交互,所以前提是至少有一个页面 A 存在,那么基于 A 页面来讲有以下几种方式去加载 B 页面文档:
window.location.href
<a href="xx" target="xx">
window.open
iframe
这一部分这里先简单提及,更详细的内容放到最后作为扩展去讲,也许你会奇怪怎么没有 history.pushState 和 location.hash (如 Vue Router、React Router
中的使用),因为它们算属于在页面加载之后的路由导航,看起来虽然是页面切换了,但是切换的是文档的内容,不是整个文档,这一点还是不一样的。
同源策略下的多文档交互
Web Storage
sessionStorage & localStorage
由于多文档的方式并不适合使用 Vuex/Pinia/React Redux
等全局状态管理器,因此 Web Storage
这种应该是我们最先能想到的方式了,而 Web Storage
实际上只包含以下两种:
- sessionStorage
- 为每一个给定的源(given origin)维持一个独立的存储区域,该存储区域在页面 会话期间 可用,即只要浏览器处于打开状态,包括页面重新加载和恢复
- localStorage
- 为每一个给定的源(given origin)维持一个独立的存储区域,但是在浏览器关闭,然后重新打开后数据仍然存在,即其存储的数据是 持久化的
有些人会把 IndexedDB 也当做 Web Storage 的一种,这在规范定义上是不够准确的.
它们最基本的用法这里就不多说了,总结起来就是:在 B 页面往 Web Storage 中存入数据 X ,在 A 页面中读取数据 X 然后决定需要做什么。
这里我们可以借助 document 文档对象的 visibilitychange 事件来监听当前标签页面是否处于 可见状态,然后再决定是不是要做某些反馈操作。
核心代码:
// A 页面 document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { // do something ... } }) 复制代码
演示效果如下:
值得注意的是,sessionStorage 在不同标签页之间的数据是不能同步,但如果 A 和 B 两个页面属于 同一浏览上下文组
可以实现初始化同步(实际算是拷贝值
),后续变化不再同步。
storage 事件
当存储区域(localStorage | sessionStorage)被修改时,将会触发 storage 事件,这是 MDN 上的解释但实际是:
- 如果当前页面的
localStorage
值被修改,只会触发其他页面的storage
事件,不会触发本页面的storage
事件 window.onstorage
事件只对localStorage
的修改有效,sessionStorage
的修改不能触发localStorage
的值必须发生变化,如果设置成相同的值则不会触发
window.onstorage
事件配合 localStorage 很完美,但是唯独对 sessionStorage 无效,目前没有发现一个很好且详细的解释。
Cookies & IndexdeDB
这两种和上述的 Web Storage 的实现方式一致,但它们又不属于一类,因此在这里还是额外提出来讲,不过它们可都是有同源策略的限制的。
既然核心方案一致,这里就不多说了,来看看它们的一些区别,便于更好的进行选择:
- sessionStorage
会话级存储
,最多能够存储5MB
左右,不同浏览器限制不同- 不同标签页之间的数据不能同步,但如果 A 和 B 两个页面属于
同一浏览上下文组
可以实现初始化同步(实际算是拷贝值
),后续变化不再同步 - 不支持
结构化存储
,只能以字符串形式
进行存储
- localStorage
持久级存储
,最多能够存储5MB
左右,不同浏览器限制不同- 只要在
同源
的情况下,无论哪个页面操作数据都可以一直保持同步到其他页面 - 不支持
结构化存储
,只能以字符串形式
进行存储
- Cookie
- 默认是
会话级存储
,若想实现持久存储
可以设置Expires
的值,存储大小约4KB
左右,不同浏览器限制不同 - 只要在
同源
的情况下,无论哪个页面操作数据都可以一直保持同步到其他页面 - 不支持
结构化存储
,只能以字符串形式
进行存储
- IndexedDB
持久存储
,是一种事务型数据库系统(即非关系型),存储大小理论上没有限制,由用户的磁盘空间和操作系统来决定- 只要在
同源
的情况下,无论哪个页面操作数据都可以一直保持同步到其他页面 - 支持
结构化存储
,包括 文件/二进制大型对象(blobs)
同一浏览上下文组 可理解为:假设在 A 页面中以
window.open
或<a href="x" target="_blank">x</a>
方式 打开 B 页面,并且 A 和 B 是 同源 的,那么此时 A 和 B 就属于 同一浏览上下文组
SharedWorker — 共享 Worker
SharedWorker
接口代表一种特定类型的 worker,不同于普通的 Web Worker,它可以从 几个浏览上下文中
访问,例如 几个窗口
、iframe
或 其他 worker
。
那么 SharedWorker 的 Shared 指的是什么?
从普通的 Web Worker 的使用来看:
- 主线程要实例化
worker
实例:const worker = new Worker('work.js');
- 主线程调用
worker
实例的postMessage()
方法与worker
线程发送消息,通过onmessage
方法用来接收worker
线程响应的结果 worker
线程(即'work.js'
)中也会通过postMessage()
方法 和onmessage
方法向主线程做相同的事情
从上述流程看没有什么大问题,但是如果是不同文档去加载执行 const worker = new Worker('work.js');
就会生成一个新的 worker
实例,而 SharedWorker 区别于 普通 Worker 就在这里,如果不同的文档加载并执行 const sharedWorker = new SharedWorker('work.js');
,那么除了第一个文档会真正创建 sharedWorker
实例外,其他以相同方式去加载 work.js
的文档就会直接 复用 第一个文档创建的 sharedWorker
实例。
效果演示
核心代码
>>>>>>>>>>>>>>>>>> pubilc/worker.js <<<<<<<<<<<<<< // 保存多个 port 对象 let ports = [] // 每个页面进行连接时,就会执行一次 self.onconnect = (e) => { // 获取当前 port 对象 const port = e.ports[0] // 监听消息 port.onmessage = ({ data }) => { switch (data.type) { case 'init': // 初始化页面信息 ports.push({ port, pageId: data.pageId, }) port.postMessage({ from: 'init', data: '当前线程 port 信息初始化已完成', }) break case 'send': // 单播 || 广播 for (const target of ports) { if(target.port === port) continue target.port.postMessage({ from: target.pageId, data: data.data, }) } break case 'close': port.close() ports = ports.filter(v => data.pageId !== v.pageId) break } } } >>>>>>>>>>>>>>>>>> pubilc/worker.js <<<<<<<<<<<<<< >>>>>>>>>>>>>>>>>> initWorker.ts <<<<<<<<<<<<<< import { v4 as uuidv4 } from 'uuid' export default (store) => { const pageId = uuidv4() const sharedWorker = new SharedWorker('/worker.js', 'testShare') store.sharedWorker = sharedWorker // 初始化页面信息 sharedWorker.port.postMessage({ pageId, type: 'init' }) // 接收信息 sharedWorker.port.onmessage = ({ data }) => { if (data.from === 'init') { console.log('初始化完成', data) return } store.commit('setShareData', data) } // 页面关闭 window.onbeforeunload = (e) => { e = e || window.event if (e) { e.returnValue = '关闭提示' } // 清除操作 sharedWorker.port.postMessage({ type: 'close', pageId }) return '关闭提示' } } >>>>>>>>>>>>>>>>>> initWorker.js <<<<<<<<<<<<<< >>>>>>>>>>>>>>>>>> store/indext.js <<<<<<<<<<<<<< import { createStore } from 'vuex' import initWorker from '../initWorker' const store: any = createStore({ state: { shareData: {} }, getters: { }, mutations: { setShareData (state, payload) { state.shareData = payload console.log('收到的消息:', payload) } }, actions: { send (state, data) { store.sharedWorker.port.postMessage({ type: 'send', data }) console.log('发送的消息:', data) } }, modules: { } }) // 初始化 worker initWorker(store) export default store >>>>>>>>>>>>>>>>>> store/indext.js <<<<<<<<<<<<<< 复制代码
BroadcastChannel
BroadcastChannel
接口代理了一个命名频道,可以让指定 origin 下的任意 浏览上下文 来订阅它,并允许 同源 的不同浏览器 窗口、Tab 页、frame/iframe 下的不同文档之间相互通信,通过触发一个 message
事件,消息可以 广播 到所有监听了该频道的 BroadcastChannel
对象。
效果演示