浏览器多窗口之间的通信

浏览器多窗口之间的通信


javascript 浏览器 通信 前端

Q:从页面 A 打开一个新页面 B,B 页面关闭后,如何通知 A 页面

本题是 html 页面通信题,可以拆分成:

  • A 页面打开 B 页面,A、B 页面通信方式?
  • B 页面正常关闭,如何通知 A 页面?
  • B 页面意外崩溃,又该如何通知 A 页面?

A、B 页面通信方式有:

  • url 传参
  • postmessage
  • localStorage
  • WebSocket
  • SharedWorker
  • Service Worker

#####url 传参

<!-- A.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>A</title>
  </head>
  <body>
    <h1>A 页面</h1>
    <button type="button" onclick="openB()">B</button>
    <script>
      window.name = "A";
      function openB() {
        window.open("B.html", "B");
      }

      window.addEventListener(
        "hashchange",
        function () {
          // 监听 hash
          alert(window.location.hash);
        },
        false
      );
      //这里写另外一个无关的点 addEventListener的第三个参数表示是否事件捕获,为true则为捕获,false(默认)则为冒泡。
    </script>
  </body>
</html>
<!-- B.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>B</title>
  </head>
  <body>
    <h1>B 页面</h1>
    <span></span>
    <script>
      window.name = "B";
      window.onbeforeunload = function (e) {
        window.open("A.html#close", "A");
        return "确定离开此页吗?";
      };
    </script>
  </body>
</html>
这里涉及到window.open方法的第二个参数,这一个可选的字符串,该字符串是一个由逗号分隔的特征列表,其中包括数字、字母和下划线,该字符声明了新窗口的名称。这个名称可以用作标记
<a>

  <form>
    的属性 target 的值。如果该参数指定了一个已经存在的窗口,那么 open()
    方法就不再创建一个新窗口,而只是返回对指定窗口的引用。
  </form></a
>
postmessage
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>A</title>
  </head>
  <body>
    <h1>A 页面</h1>
    <button type="button" onclick="openB()">B</button>
    <script>
      window.name = "A";
      function openB() {
        window.open("B.html?code=123", "B");
      }
      window.addEventListener("message", receiveMessage, false);
      function receiveMessage(event) {
        console.log("收到消息:", event.data);
      }
    </script>
  </body>
</html>
<!-- B.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>B</title>
    <button type="button" onclick="sendA()">发送A页面消息</button>
  </head>
  <body>
    <h1>B 页面</h1>
    <span></span>
    <script>
      window.name = "B";
      function sendA() {
        let targetWindow = window.opener;
        //targetWindow.postMessage('Hello A', "http://localhost:3000");
        targetWindow.postMessage("Hello A", "*");
      }
    </script>
  </body>
</html>
window.postMessage()
方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机
(两个页面的模数 Document.domain设置为相同的值)
时,这两个脚本才能相互通信。window.postMessage()
方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。
从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow =
window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个
MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件。传递给
window.postMessage() 的参数(比如 message
)将通过消息事件对象暴露给接收消息的窗口。
语法:otherWindow.postMessage(message, targetOrigin, [transfer]); otherWindow
其他窗口的一个引用,比如iframe的contentWindow属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames。
message 将要发送到其他
window的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。
targetOrigin
通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;例如,当用postMessage传送密码时,这个参数就显得尤为重要,必须保证它的值与这条包含密码的信息的预期接受者的origin属性完全一致,来防止密码被恶意的第三方截获。如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的targetOrigin,而不是*。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。
transfer 可选 是一串和message 同时传递的 Transferable 对象.
这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
localStorage
// A localStorage.setItem('testB', 'sisterAn'); // B let testB =
localStorage.getItem('testB'); console.log(testB) // sisterAn 注意:
localStorage 仅允许你访问一个Document 源(origin)的对象
Storage;存储的数据将保存在浏览器会话中。如果 A 打开的 B 页面和 A
是不同源,则无法访问同一 Storage
WebSocket
基于服务端的页面通信方式,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种
SharedWorker
SharedWorker 接口代表一种特定类型的
worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他
worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域,
SharedWorkerGlobalScope 。

注意:如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。

//worker.js
const ports = [];
onconnect = (e) => {
  const port = e.ports[0];
  ports.push(port);
  port.onmessage = (evt) => {
    ports
      .filter((v) => v !== port) // 此处为了贴近其他方案的实现,剔除自己
      .forEach((p) => p.postMessage(evt.data));
  };
};
<!--A.html-->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>A</title>
  </head>
  <body>
    <h1>A 页面</h1>
    <script>
      var sharedworker = new SharedWorker("worker.js");
      sharedworker.port.start();
      sharedworker.port.onmessage = (evt) => {
        // evt.data
        console.log(evt.data); // hello A
      };
    </script>
  </body>
</html>
<!-- B.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>B</title>
    <button type="button" onclick="sendA()">发送A页面消息</button>
  </head>
  <body>
    <h1>B 页面</h1>
    <span></span>
    <script>
      var sharedworker = new SharedWorker("worker.js");

      function sendA() {
        sharedworker.port.start();
        sharedworker.port.postMessage("hello A");
      }
    </script>
  </body>
</html>
Service Worker(暂未实现效果,先记着)

Service Worker 是一个可以长期运行在后台的 Worker,能够实现与页面的双向通信。多页面共享间的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现广播效果。

// 注册
navigator.serviceWorker.register("./sw.js").then(function () {
  console.log("Service Worker 注册成功");
});

// A
navigator.serviceWorker.addEventListener("message", function (e) {
  console.log(e.data);
});

// B
navigator.serviceWorker.controller.postMessage("Hello A");
B 页面正常关闭,如何通知 A 页面?
页面正常关闭时,会先执行 window.onbeforeunload ,然后执行 window.onunload ,我们可以在这两个方法里向 A 页面通信

B 页面意外崩溃,又该如何通知 A 页面?
页面正常关闭,我们有相关的 API,崩溃就不一样了,页面看不见了,JS 都不运行了,那还有什么办法可以获取B页面的崩溃?

我们可以利用 window 对象的 load 和 beforeunload 事件,通过心跳监控来获取 B 页面的崩溃
window.addEventListener("load", function () {
  sessionStorage.setItem("good_exit", "pending");
  setInterval(function () {
    sessionStorage.setItem("time_before_crash", new Date().toString());
  }, 1000);
});

window.addEventListener("beforeunload", function () {
  sessionStorage.setItem("good_exit", "true");
});

if (
  sessionStorage.getItem("good_exit") &&
  sessionStorage.getItem("good_exit") !== "true"
) {
  /*
         insert crash logging code here
     */
  alert(
    "Hey, welcome back from your crash, looks like you crashed on: " +
      sessionStorage.getItem("time_before_crash")
  );
}
这个方案巧妙的利用了页面崩溃无法触发 beforeunload 事件来实现的。
在页面加载时(load 事件)在 sessionStorage 记录 good_exit 状态为
pending,如果用户正常退出(beforeunload 事件)状态改为 true,如果 crash
了,状态依然为 pending,在用户第2次访问网页的时候(第2个load事件),查看
good_exit 的状态,如果仍然是 pending 就是可以断定上次访问网页崩溃了!
但有一个问题,本例中用 sessionStorage
保存状态,在用户关闭了B页面,sessionStorage 值就会丢失,所以换种方式,使用
Service Worker 来实现: Service Worker
有自己独立的工作线程,与网页区分开,网页崩溃了,Service Worker
一般情况下不会崩溃; Service Worker
生命周期一般要比网页还要长,可以用来监控网页的状态; 网页可以通过
navigator.serviceWorker.controller.postMessage API 向掌管自己的 SW 发送消息
基于以上几点优势,完整设计一套流程如下: B 页面加载后,通过 postMessage API 每
5s 给 sw 发送一个心跳,表示自己的在线,sw 将在线的网页登记下来,更新登记时间; B
页面在 beforeunload 时,通过 postMessage API 告知自己已经正常关闭,sw
将登记的网页清除; 如果 B页面在运行的过程中 crash 了,sw 中的 running
状态将不会被清除,更新时间停留在奔溃前的最后一次心跳; A 页面 Service Worker 每
10s 查看一遍登记中的网页,发现登记时间已经超出了一定时间(比如
15s)即可判定该网页 crash 了。
代码如下:
// B
if (navigator.serviceWorker.controller !== null) {
  let HEARTBEAT_INTERVAL = 5 * 1000 // 每五秒发一次心跳
  let sessionId = uuid() // B页面会话的唯一 id
  let heartbeat = function () {
    navigator.serviceWorker.controller.postMessage({
      type: 'heartbeat',
      id: sessionId,
      data: {} // 附加信息,如果页面 crash,上报的附加数据
    })
  }
  window.addEventListener("beforeunload", function() {
    navigator.serviceWorker.controller.postMessage({
      type: 'unload',
      id: sessionId
    })
  })
  setInterval(heartbeat, HEARTBEAT_INTERVAL);
  heartbeat();
}

// 每 10s 检查一次,超过15s没有心跳则认为已经 crash
const CHECK_CRASH_INTERVAL = 10 * 1000
const CRASH_THRESHOLD = 15 * 1000
const pages = {}
let timer
function checkCrash() {
  const now = Date.now()
  for (var id in pages) {
    let page = pages[id]
    if ((now - page.t) > CRASH_THRESHOLD) {
      // 上报 crash
      delete pages[id]
    }
  }
  if (Object.keys(pages).length == 0) {
    clearInterval(timer)
    timer = null
  }
}

worker.addEventListener('message', (e) => {
  const data = e.data;
  if (data.type === 'heartbeat') {
    pages[data.id] = {
      t: Date.now()
    }
    if (!timer) {
      timer = setInterval(function () {
        checkCrash()
      }, CHECK_CRASH_INTERVAL)
    }
  } else if (data.type === 'unload') {
    delete pages[data.id]
  }
})
© 2025 Niko Xie