IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    你可能需要的多文档页面交互方案

    熊的猫发表于 2023-04-17 11:04:47
    love 0

    前言

    欢迎关注同名公众号《熊的猫》,文章会同步更新!

    在日常工作中,面对不同的需求场景,你可能会遇到需要进行多文档页面间交互的实现,例如在 A 页面跳转到 B 页面进行某些操作后,A 页面需要针对该操作做出一定的反馈等等,这个看似简单的功能,却也需要根据不同场景选择不同的方案。

    79C30119.jpg

    这里所说的场景实际上可分为两个大方向:同源策略、文档加载方式,那么本篇文章就来探讨一下这两个方面。

    同源策略 & 文档加载方式

    在正式开始之前,我们还是先简单聊一下同源策略和页面加载方式,如果你已经足够了解了,可以选择跳过阅读。

    同源策略

    基本概念

    所谓的 同源策略 实际上是 浏览器 的一个重要的 安全策略,主要是用于限制 一个源 的文档 或者 其加载的脚本 是否可以与 另一个源 的资源进行交互。

    【注意】这里的目标是浏览器,也就是只有浏览器有同源策略的限制,例如服务端就不存在什么同源策略,这里的浏览器包括 桌面端浏览器、移动端浏览器、微信内置浏览器、虚拟浏览器(虚拟环境中运行的网络浏览器) 等。

    所谓的 源 就是我们常说的 协议、主机名(域名)、端口,所以所谓的 同源 也就是指两个 URL 的 协议、主机名(域名)、端口 等信息要完全匹配。

    主要作用

    同源策略 可以用来阻隔恶意文档,减少可能被攻击的媒介,下面还是通过一个 CSRF 例子讲解一下没有同源限制会发生什么。

    CSRF 攻击

    假设你在 A 网站上进行了登录并成功登入网站后,你发现 A 网站上出现了一个广告弹窗(写着:拒绝 huang,拒绝 du,拒绝 pingpangqiu),于是放纵不羁爱自由的你(为了验证真理)点开了它,发现这个网站居然不讲武德,啥也不是...

    4488455F.gif

    表明平静如水,背地里实则已经悄悄向 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 ...
      }
    })

    演示效果如下:

    10.gif

    值得注意的是,sessionStorage 在不同标签页之间的数据是不能同步,但如果 A 和 B 两个页面属于 同一浏览上下文组 可以实现初始化同步(实际算是拷贝值),后续变化不再同步。

    storage 事件

    当存储区域(localStorage | sessionStorage)被修改时,将会触发 storage 事件,这是 MDN 上的解释但实际是:

    • 如果当前页面的 localStorage 值被修改,只会触发其他页面的 storage 事件,不会触发本页面的 storage 事件
    • window.onstorage 事件只对 localStorage 的修改有效,sessionStorage 的修改不能触发
    • localStorage 的值必须发生变化,如果设置成相同的值则不会触发

    10.gif

    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 实例。

    效果演示

    10.gif

    核心代码

    >>>>>>>>>>>>>>>>>> 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 对象。

    效果演示

    10.gif

    核心代码

    // A.html
    <body>
        <h1>A 页面</h1>
        <a href="/b" target="_blank">打开 B 页面</a>
        <br />
        <button onclick="send()">发送消息给 B 页面</button>
        <h3>
          收到 B 页面的消息:
          <small id="small"></small>
        </h3>
    
        <script>
          const bc = new BroadcastChannel('test_broadcast_hannel')
    
          // 向 B 页面发送消息
          function send() {
            console.log('A 页面已发送消息')
            bc.postMessage('你好呀!')
          }
    
          // 监听来着 A 页面的消息
          bc.onmessage = ({ data }) => {
            document.querySelector('#small').innerHTML = event.data
          }
        </script>
      </body>
    
    // B.html
    <body>
        <h1>B 页面</h1>
        <button onclick="send()">发送消息给 B 页面</button>
        <h3>
          收到 A 页面的消息:
          <small id="small"></small>
        </h3>
    
        <script>
          const bc = new BroadcastChannel('test_broadcast_hannel')
    
          // 向 A 页面发送消息
          function send() {
            console.log('B 页面已发送消息')
            bc.postMessage('还不错呦~')
          }
    
          // 监听来着 A 页面的消息
          bc.onmessage = ({ data }) => {
            document.querySelector('#small').innerHTML = event.data
          }
        </script>
      </body>

    HTTP 长轮询

    HTTP 长轮询 相信大家应该非常的熟悉了,也许你 过去/现在 正在做的 扫码登录 就是用的长轮询。

    由于 HTTP1.1 协议并不支持服务端主动向客户端发送数据消息,那么基于这种 请求-响应 模型,如果我们需要服务端的消息数据,就必须先向服务端发送对应的查询请求,因此只要每隔一段时间向服务器发起查询请求,在根据响应结果决定是继续下一步操作,还是继续发起查询。

    核心很像 Web Storage 方案,只不过中间者不同:

    • Web Storage 的中间者是 浏览器,一个页面 存/改 数据,其他页面读取再执行后续操作
    • 长轮询 的中间者是 服务器,一个页面提交请求把目标数据提交到服务端,其他页面通过轮询的方式去读取数据再决定后续操作

    由于这种方案比较常见,这里就不再额外演示。

    6397E0B3.png

    非同源的多文档交互

    window.postMessage

    通常对于两个不同页面的脚本,只有当执行它们的页面具有:

    • 相同协议(通常为 https)
    • 相同端口号(443 为 https 的默认值)
    • 相同主机 (两个页面的 Document.domain设置为相同的值)

    时,这两个脚本才能相互通信。

    而 window.postMessage() 方法可以 安全 地实现 跨源通信,这个方法提供了一种 受控机制 来规避此限制,本质就是自己注册监听事件,自己派发事件。

    window.postMessage() 允许 一个窗口 可以获得对 另一个窗口 的引用(比如 targetWindow = window.opener)的方式,然后在窗口上调用 targetWindow.postMessage() 方法分发一个 MessageEvent 消息。

    语法如下,详细解释可见 MDN:

    targetWindow.postMessage(message, targetOrigin, [transfer]);

    63719F00.jpg

    window.open() 和 window.postMessage()

    10.gif

    核心代码

    // A.html
    <body>
        <h1>A 页面</h1>
        <button onclick="openAction()">打开 B 页面</button>
        <button onclick="send()">发送消息给 B 页面</button>
    
        <script>
          let targetWin = null
          const targetOrigin = 'http://127.0.0.1:8082/'
    
          // 打开 B 页面
          function openAction() {
            targetWin = window.open(targetOrigin)
          }
    
          // 向 B 页面发送消息
          function send() {
            if (!targetWin) return
            console.log('A 页面已发送消息')
            targetWin.postMessage('你好呀!', targetOrigin)
          }
    
          // 监听来着 B 页面的消息
          window.onmessage = (event) => {
            console.log('收到 B 页面的消息:', event.data)
          }
        </script>
      </body>
      
     // B.html
     <body>
        <h1>B 页面</h1>
    
        <button onclick="send()">发送消息给 A 页面</button>
    
        <script>
          const targetWin = window.opener
          const targetOrigin = 'http://127.0.0.1:8081/'
          
          // 监听来着 A 页面的消息
          window.onmessage = (event) => {
              console.log("收到 A 页面的消息:", event.data)
          }
    
          // 向 B 页面发送消息
          function send() {
            if (!targetWin) return
            console.log('B 页面已发送消息')
            targetWin.postMessage('还不错哟~', targetOrigin)
          }
    
        </script>
      </body>

    iframe 和 window.postMessage()

    眼前的限制

    <iframe> 加载的方式有些限制,只能父页面向子页面发送消息,子页面不能向父页面发送消息,本质原因是在父页面中我们可以通过 document.querySelector('#iframe').contentWindow 的方式获取到子页面 window 对象的引用,但是子页面却不能像 window.open() 的方式通过 window.opener 的方式获取父页面 window 对象的引用。

    原本想通过 postMessage 将父页面的 window 的代理对象传递过去,但抛出如下异常:

    image.png

    主要原因是 postMessage 是不允许将 Window、Element 等对象进行复制传递,即使可以传递到了子页面中也是无法使用的,因为能传递过去说明你用了深克隆,但深克隆之后已经和原来的父页面无关了。

    window.parent 属性

    以上思考是在没完全没有想到 window.parent 时的方向,也感谢评论区掘友的提醒,完全可以使用这个 window.parent 化繁为简来获取父页面的 window 对象引用:

    • 如果一个窗口没有父窗口,则它的 parent 属性为 自身的引用
    • 如果当前窗口是一个 <iframe>、<object>、<frame> 的加载的内容,那么它的父窗口就是 <iframe>、<object>、<frame> 所在的那个窗口

    10.gif

    核心代码

     // A.html
     <body>
        <h1>A 页面</h1>
        <button onclick="send()">发送消息给 B 页面</button>
        <h3>
          收到 B 页面的消息:
          <small id="small"></small>
        </h3>
        <iframe
          id="subwin"
          height="200"
          src="http://127.0.0.1:8082/"
          onload="load()"
        ></iframe>
    
        <script>
          let targetWin = null
          const targetOrigin = 'http://127.0.0.1:8082/'
    
          // B 页面加载完成
          function load() {
            // 获取子页面 window 对象的引用
            let subwin = document.querySelector('#subwin')
            targetWin = subwin.contentWindow
          }
    
          // 向 B 页面发送消息
          function send() {
            if (!targetWin) return
            console.log('A 页面已发送消息')
            targetWin.postMessage('你好呀!', targetOrigin)
          }
    
          // 监听来着 A 页面的消息
          window.onmessage = ({ data }) => {
            document.querySelector('#small').innerHTML = event.data
          }
        </script>
      </body>
      
     // B.html
    <body>
        <h1>B 页面</h1>
        <button onclick="send()">发送消息给 B 页面</button>
        <h3>
          收到 A 页面的消息:
          <small id="small"></small>
        </h3>
    
        <script>
          const targetWin = window.parent
          const targetOrigin = 'http://127.0.0.1:8081/'
    
          // 向 A 页面发送消息
          function send() {
            if (targetWin === window) return
            console.log('B 页面已发送消息')
            targetWin.postMessage('还不错呦~', targetOrigin)
          }
          
          // 监听来着 A 页面的消息
          window.onmessage = ({ data }) => {
            document.querySelector('#small').innerHTML = event.data
          }
        </script>
      </body>

    websocket

    早期 HTTP(超文本传输协议)主要目的就是传输超文本,因为当时网络上绝大多数的资源都是纯文本,许多通信协议也都使用纯文本,因此 HTTP 在设计上不可避免地受到了时代的限制,即 HTTP 没有完全的利用 TCP 协议的 全双工通信 能力,这就是为什么 HTTP 是 半双工通信 的原因。

    由于 HTTP 存在早期设计上的限制,但随着互联网的不断发展,越来越需要这种 全双工通信 的功能,因此需要一种新的基于 TCP 实现 全双工通信 的协议,而这个协议就是 WebSocket。

    具体使用这里不再单独介绍,如果你想了解更多,可以查看往期文章《HTTP,WebSocket 和 聊天室》.

    63721876.gif

    不过这里还是简单介绍一下,实现的核心就是 不同的页面 与 同一个 websocket 服务 建立连接,多个页面间的通信在 websocket 服务 中进行转发,即页面发送消息到 websocket 服务 根据标识进行 单博 或 广播 的形式下发到其他指定页面

    image.png

    不同文档加载方式

    前面提到的不同的文档加载方式如下:

    • window.location.href
    • <a href="x" target="x">
    • window.open
    • <iframe>

    上面已经列举了最常见的方案,跨源方案最全能,这是毋庸置疑的,关于不同文档加载方式也在某些层面上与上述方案挂钩,下面主要讲一些不同文档加载方式的异同点。

    window.location.href 和 <a href="x" target="_self">

    最常的用法就是通过 window.location.href = x 将当前的文档的 url 进行替换,但其实它是有一些规则的:

    • 有效 url
    • hash 形式
    • 其他形式

    当 x = 有效 url 时,当前文档的内容会被新的 url 指向的内容替换:

    10.gif

    当 x = hash 形式 时,会将当前文档的 hash 部分直接替换为 x 指向的内容:

    10.gif

    当 x = 其他形式 时,会将 x 的内容作为当前文档 url 的 子路径 进行替换:

    19.gif

    以上三种形式与 <a href="x" target="_self"> 的表现一致。

    window.open 和 <a href="x" target="_blank">

    window.open(x) 和 <a href="x" target="_blank"> 的方式都会新打开一个标签页,然后去加载 x 指向的资源,当然其中 x 的加载形式同上。

    window.open() 的缺点

    浏览器出于安全的考虑,会拦截掉 非用户操作 打开的新页面,也就是指如果我们想在某个异步操作之后自动通过 window.open(x) 的形式打开新页面就会失败,例如:

    fetch(url,option).then(res=>{    
        window.open('http://www.test.com') // 打开失败
    })
    
    setTimeout(() => {
        window.open('http://www.test.com') // 打开失败
    }, 1000)

    image.png

    解决办法

    • 将 window.open() 方法放在用户事件中

      • 如在异步操作结束后弹窗提供按钮,让用户手动点击
    • 直接提供 <a> 标签的形式进行跳转

      • 不要妄想通过自动创建 a 标签,然后再通过 a.click() 的方式实现跳转,你能想到浏览器安全限制中也能考虑到
    • window.open() 配合 window.location.href

      • 如下的 clickHandle 本质还是需要用在 用户事件 中,直接自动执行该函数还是会失效,因为毕竟不是由用户动作产生的结果

        const clickHandle = () => {
          const newWin = window.open('about:blank')
          ajax().then(res => {
            newWin.location.href = 'http://www.baidu.com'
          }).catch(() => {
            newWin.close()
          })
        }

      <iframe>

      <iframe> 能够将另一个 HTML 页面嵌入到 当前页面 中,每个嵌入的 浏览上下文 都有自己的 会话历史记录和 DOM 树。

    包含嵌入内容的浏览上下文称为 父级浏览上下文,顶级浏览上下文(没有父级)通常是由 Window 对象表示的浏览器窗口。

    多余的东西在这也不展开了,上面我们使用过的 contentWindow 属性只在 <iframe> 元素上存在,它返回的是当前 iframe 元素(HTMLIFrameElement) 所加载文档的 Window 对象的引用。

    contentWindow 属性是 可读属性,它所指向的 Window 对象可以去访问这个 iframe 的文档和它内部的 DOM。

    10.gif

    最后

    欢迎关注同名公众号《熊的猫》,文章会同步更新!

    以上就是本文的全部内容了,纵观文中原本各个看似零散的知识点,在一个需求场景下都被联系起来了,所以有些东西确实学了不一定立刻就会用到,但是真的到需要用到的时候你会发现很多知识点其实都是联系在一起的,并且它们的表现或原理何其相似。

    希望本文对你有所帮助!!!

    63F21AE8.gif



沪ICP备19023445号-2号
友情链接