浏览器跨标签页通信

该笔记的内容主要来自本人在学习渡一课程的配套资料和个人总结而成

浏览器跨标签页通信

本文主要包含以下内容:

  • 什么是跨标签页通信
  • 跨标签页通信常见方案
    • BroadCast Channel
    • Service Worker
    • LocalStorage window.onstorage 监听
    • Shared Worker 定时器轮询( setInterval
    • IndexedDB 定时器轮询( setInterval
    • cookie 定时器轮询( setInterval
    • window.open、window.postMessage
    • Websocket

什么是跨标签页通信

面试的时候经常会被问到的一个关于浏览器的问题:

浏览器中如何实现跨标签页通信?

要回答这个问题,首先需要搞懂什么叫做跨标签通信。

其实这个概念也不难理解,现在几乎所有的浏览器都支持多标签页的,我们可以在一个浏览器中打开多个标签页,每个标签页访问不同的网站内容。

image-20211204132442156

因此,跨标签页通信也就非常好理解了,简单来讲就是一个标签页能够发送信息给另一个标签页

常见的跨标签页方案如下:

  • BroadCast Channel

  • Service Worker

  • LocalStorage window.onstorage 监听

  • Shared Worker 定时器轮询( setInterval

  • IndexedDB 定时器轮询( setInterval

  • cookie 定时器轮询( setInterval

  • window.open、window.postMessage

  • Websocket

跨标签页通信常见方案

下面我们将针对每一种跨标签页通信的方案进行介绍。

注:本文并不会对每一种方案的知识点本身进行详细介绍,只会介绍如何通过该方案实现跨标签页通信。

BroadCast Channel

BroadCast Channel 可以帮我们创建一个用于广播的通信频道。当所有页面都监听同一频道的消息时,其中某一个页面通过它发送的消息就会被其他所有页面收到。但是前提是同源页面

index1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<body>
<input type="text" name="" id="content" />
<button id="btn">发送数据</button>
<script>
const content = document.querySelector("#content");
const btn = document.querySelector("#btn");
// 创建一个名字为ak的广播频道
const bc = new BroadcastChannel("ak");
btn.addEventListener("click", () => {
// 发送数据
bc.postMessage({
value: content.value,
});
});
</script>
</body>
index2.htm
1
2
3
4
5
6
7
8
9
<body>
<script>
// 创建相同频道的广播
const bc = new BroadcastChannel("ak");
bc.onmessage = function (e) {
console.log(e.data);
};
</script>
</body>

在上面的代码中,我们在页面一注册了一个名为 akBroadcastChannel 对象,之后所有的页面也创建同名的 BroadcastChannel 对象,然后就可以通过 postMessageonmessage 方法进行相互通信了。

Service Worker

Service Worker 实际上是浏览器和服务器之间的代理服务器,它最大的特点是在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求

Service Worker 的目的在于离线缓存,转发请求和网络代理

index1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<input type="text" name="" id="content" />
<button id="btn">发送数据</button>
<script>
const content = document.querySelector("#content");
const btn = document.querySelector("#btn");

// 注册serviceWorker
navigator.serviceWorker.register("sw.js").then(() => {
console.log("service worker 注册成功");
});
btn.addEventListener("click", () => {
// 发送数据到sw,即sw.js
navigator.serviceWorker.ready.then((registration) => {
registration.active.postMessage({
value: content.value,
});
});
});
</script>
</body>
index2.html
1
2
3
4
5
6
7
8
9
10
11
12
<body>
<script>
// 注册serviceWorker
navigator.serviceWorker.register("sw.js").then(() => {
console.log("service worker 注册成功");
});
// 监听message事件
navigator.serviceWorker.addEventListener("message", (e) => {
console.log(e.data);
});
</script>
</body>
sw.js
1
2
3
4
5
6
7
8
// 消息会发送到这里
self.addEventListener("message", async (e) => {
// 获取所有注册了 servie worker 的客户端
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage(e.data.value);
});
});

为什么在 Service Worker 中使用的是 self

  • Service Worker 运行在独立的线程中,与网页的主线程分离

  • Service Worker 没有 DOM 访问权限,也没有 window 对象

  • self 提供了访问 Service Worker API 的入口点

LocalStorage window.onstorage 监听

Web Storage 中,每次将一个值存储到本地存储时,就会触发一个 storage 事件。

由事件监听器发送给回调函数的事件对象有几个自动填充的属性如下:

  • key:告诉我们被修改的条目的键。

  • newValue:告诉我们被修改后的新值。

  • oldValue:告诉我们修改前的值。

  • storageArea:指向事件监听对应的 Storage 对象。

  • url:原始触发 storage 事件的那个网页的地址。

注意:这个事件只在同一域下的任何窗口或者标签上触发,并且只在被存储的条目改变时触发。

示例如下:这里我们需要打开服务器进行演示,本地文件无法触发 storage 事件

index1.html
1
2
3
4
5
6
7
<body>
<script>
localStorage.setItem("name", "AK");
localStorage.setItem("age", 20);
console.log("信息已经设置!");
</script>
</body>

在上面的代码中,我们在该页面下设置了两个 localStorage 本地数据。

index2.html
1
2
3
4
5
6
7
8
9
10
11
<body>
<script>
window.addEventListener("storage", (e) => {
console.log("修改的键为:", e.key);
console.log("修改前的值为:", e.oldValue);
console.log("修改后的值为:", e.newValue);
console.log("修改的网址为:", e.url);
console.log("事件监听对应的Storage对象:", e.storageArea);
});
</script>
</body>

在该页面中我们安装了一个 storage 的事件监听器,安装之后只要是同一域下面的其他 storage 值发生改变,该页面下面的 storage 事件就会被触发

Shared Worker 定时器轮询( setInterval

下面是 MDN 关于 SharedWorker 的说明:

SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域,如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)

index1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<body>
<input type="text" name="" id="content" />
<button id="btn">发送数据</button>
<script>
const content = document.querySelector("#content");
const btn = document.querySelector("#btn");

// 创建一个worker
const worker = new SharedWorker("worker.js");
// 点击向worker发送消息
btn.onclick = function () {
worker.port.postMessage(content.value);
};
</script>
</body>
index2.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<body>
<script>
const worker = new SharedWorker("worker.js");
worker.port.start();

// 监听worker返回的数据
worker.port.onmessage = function (e) {
if (e.data) {
console.log(e.data);
}
};

// 轮询向worker发送消息查询数据
setInterval(function () {
worker.port.postMessage("get");
}, 1000);
</script>
</body>
worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let data = ""; // 存储用户发送的信息
self.onconnect = function (e) {
console.log("页面连接上了");
let port = e.ports[0];
port.onmessage = function (e) {
// 说明要将接收到的数据返回给客户端
if (e.data === "get") {
port.postMessage(data);
data = "";
} else {
data = e.data;
}
};
};

IndexedDB 定时器轮询( setInterval

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。

通过对 IndexedDB 进行定时器轮询的方式,我们也能够实现跨标签页的通信。

本节需要利用在浏览器离线存储之IndexedDB中封装的 db.js 文件。

db.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
* 创建数据库
* @param {string} dbName 数据库名称
* @param {number} version 数据库版本
*/
function openDB(dbName, version = 1) {
return new Promise((resolve, reject) => {
let db; // 存储数据库对象
// 打开数据库,如果没有该数据库,则创建该数据库
const request = indexedDB.open(dbName, version);

// 数据库打开或者创建成功的时候触发
request.onsuccess = function (event) {
// 存储数据库对象
db = event.target.result;
console.log("数据库打开成功");
resolve(db);
};
// 数据库打开失败的时候触发
request.onerror = function (event) {
console.log("数据库打开失败");
reject(event);
};

// 数据库发生更新的时候触发: 1. 版本号更新 2. 添加或删除对象仓库(表)3. 第一次调用open方法
request.onupgradeneeded = function (event) {
console.log("数据库更新");
db = event.target.result;
// 创建对象仓库(表)
let objectStore = db.createObjectStore("stu", {
keyPath: "stuId", // 主键(唯一值)
autoIncrement: true, // 自增
});
// 创建索引
objectStore.createIndex("stuId", "stuId", { unique: true });
objectStore.createIndex("stuName", "stuName", { unique: false });
objectStore.createIndex("stuAge", "stuAge", { unique: false });
};
});
}

/**
* 新增数据
* @param {object} db 数据库实例
* @param {string} storeName 对象仓库名称
* @param {string} data 数据
*/
function addData(db, storeName, data) {
let request = db
.transaction([storeName], "readwrite") // 事务对象 指定表格名称和操作模式("只读"或"读写")
.objectStore(storeName) // 对象仓库
.add(data);

request.onsuccess = function (event) {
console.log("数据写入成功");
};

request.onerror = function (event) {
console.log("数据写入失败");
};
}

/**
* 读取所有数据
* @param {object} db 数据库实例
* @param {string} storeName 对象仓库
*/
function getAllData(db, storeName) {
// 参数验证
if (!db || !storeName) {
return Promise.reject(new Error("参数无效"));
}

return new Promise((resolve, reject) => {
let transaction = db.transaction([storeName], "readonly"); // 只读事务
// 只读事务完成事件处理
transaction.oncomplete = function () {
console.log("事务完成");
};

// 事务失败事件处理
transaction.onerror = function (event) {
console.error("事务失败:", event.target.error);
reject(event.target.error);
};

// 主键查询事件处理
let objectStore = transaction.objectStore(storeName); // 获取对象仓库
let request = objectStore.getAll();
// 监听事件
request.onsuccess = function (event) {
resolve(request.result);
};

request.onerror = function (event) {
reject(event.target.error);
};
});
}
index1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<body>
<h1>新增学生</h1>
<div>
<span>学生学号:</span>
<input type="text" name="stuId" id="stuId" />
</div>
<div>
<span>学生姓名:</span>
<input type="text" name="stuName" id="stuName" />
</div>
<div>
<span>学生年龄:</span>
<input type="text" name="stuAge" id="stuAge" />
</div>
<button id="addBtn">新增学生</button>
<script src="./db.js"></script>
<script>
const dom = {
stuId: document.getElementById("stuId"),
stuName: document.getElementById("stuName"),
stuAge: document.getElementById("stuAge"),
addBtn: document.getElementById("addBtn"),
};

openDB("stuDB", 1).then((db) => {
dom.addBtn.onclick = function () {
addData(db, "stu", {
stuId: dom.stuId.value,
stuName: dom.stuName.value,
stuAge: dom.stuAge.value,
});
dom.stuId.value = dom.stuName.value = dom.stuAge.value = "";
};
});
</script>
</body>
index2.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid #000;
padding: 5px 10px;
}
</style>
</head>
<body>
<h1>学生表</h1>
<table id="tab"></table>
<script src="./db.js"></script>
<script>
// 渲染函数
function render(data) {
const table = document.querySelector("#tab");
table.innerHTML = `
<tr>
<th>学号</th>
<th>姓名</th>
<th>年龄</th>
</tr>
`;

let str = data
.map((stu) => {
return `
<tr>
<td>${stu.stuId}</td>
<td>${stu.stuName}</td>
<td>${stu.stuAge}</td>
</tr>
`;
})
.join("");
table.innerHTML += str;
}
// 渲染表格
async function renderTable() {
let db = await openDB("stuDB", 1); // 数据库实例
let stuInfo = await getAllData(db, "stu"); // 获取数据列表
render(stuInfo); // 首次渲染表格

// 轮询查找
setInterval(async function () {
let stuInfoNew = await getAllData(db, "stu");
if (stuInfo.length !== stuInfoNew.length) {
stuInfo = stuInfoNew;
render(stuInfo);
}
}, 1000);
}
// 渲染表格
renderTable();
</script>
</body>
</html>
db.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* 打开数据库
* @param {object} dbName 数据库的名字
* @param {string} storeName 仓库名称
* @param {string} version 数据库的版本
* @return {object} 该函数会返回一个数据库实例
*/
function openDB(dbName, version = 1) {
return new Promise((resolve, reject) => {
var db; // 存储创建的数据库
// 打开数据库,若没有则会创建
const request = indexedDB.open(dbName, version);

// 数据库打开成功回调
request.onsuccess = function (event) {
db = event.target.result; // 存储数据库对象
console.log("数据库打开成功");
resolve(db);
};

// 数据库打开失败的回调
request.onerror = function (event) {
console.log("数据库打开报错");
};

// 数据库有更新时候的回调
request.onupgradeneeded = function (event) {
// 数据库创建或升级的时候会触发
console.log("onupgradeneeded");
db = event.target.result; // 存储数据库对象
var objectStore;
// 创建存储库
objectStore = db.createObjectStore("stu", {
keyPath: "stuId", // 这是主键
autoIncrement: true, // 实现自增
});
// 创建索引,在后面查询数据的时候可以根据索引查
objectStore.createIndex("stuId", "stuId", { unique: true });
objectStore.createIndex("stuName", "stuName", { unique: false });
objectStore.createIndex("stuAge", "stuAge", { unique: false });
};
});
}

/**
* 新增数据
* @param {object} db 数据库实例
* @param {string} storeName 仓库名称
* @param {string} data 数据
*/
function addData(db, storeName, data) {
var request = db
.transaction([storeName], "readwrite") // 事务对象 指定表格名称和操作模式("只读"或"读写")
.objectStore(storeName) // 仓库对象
.add(data);

request.onsuccess = function (event) {
console.log("数据写入成功");
};

request.onerror = function (event) {
console.log("数据写入失败");
};
}

/**
* 通过主键读取数据
* @param {object} db 数据库实例
* @param {string} storeName 仓库名称
* @param {string} key 主键值
*/
function getDataByKey(db, storeName, key) {
return new Promise((resolve, reject) => {
var transaction = db.transaction([storeName]); // 事务
var objectStore = transaction.objectStore(storeName); // 仓库对象
var request = objectStore.getAll(); // 通过主键获取数据

request.onerror = function (event) {
console.log("事务失败");
};

request.onsuccess = function (event) {
// console.log("主键查询结果: ", request.result);
resolve(request.result);
};
});
}

我们同样可以通过定时器轮询的方式来监听 Cookie 的变化,从而达到一个多标签页通信的目的。

index1.html
1
2
3
4
5
6
7
<body>
<script>
// 设置 cookie
document.cookie = "name=zhangsan";
console.log("cookie 已经设置");
</script>
</body>
index2.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<body>
<script>
// 获取当前的 cookie
let cookie = document.cookie;
console.log(`当前的 cookie 值为 ${document.cookie}`);
setInterval(function () {
if (cookie !== document.cookie) {
console.log(
`cookie 信息已经改变,最新的 cookie 值为${document.cookie}`
);
cookie = document.cookie;
console.log("最新的 cookie 值已经保存");
}
}, 1000);
</script>
</body>

在上面的代码中,我们为 index2.html 设置了一个定时器,之后每过一秒钟都会重新去读取本地的 Cookie 信息,并比较和之前获取到的 Cookie 信息有没有变化,如果有变化就进行更新操作。

window.open、window.postMessage

MDN 上是这样介绍 window.postMessage 的:

window.postMessage( ) 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain 设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage( ) 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage( ) 方法分发一个 MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件。传递给 window.postMessage( ) 的参数(比如 message )将通过消息事件对象暴露给接收消息的窗口。

index1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<body>
<button id="popWindowBtn">打开新窗口</button>
<input type="text" name="" id="content" />
<button id="sendBtn">发送数据</button>
<script>
const dom = {
popWindowBtn: document.querySelector("#popWindowBtn"),
content: document.querySelector("#content"),
sendBtn: document.querySelector("#sendBtn"),
};
let popWindow; // 用于保存新窗口的应用
dom.popWindowBtn.onclick = function () {
popWindow = window.open(
"index2.html",
"页面二",
"width=300,height=300,resizable=yes,top=10"
);
};

dom.sendBtn.onclick = function () {
let data = {
value: dom.content.value,
};
// 向窗口发送数据
// 第一个参数:发送的数据
// 第二个参数:origin代表目标窗口的源,*代表所有窗口
popWindow.postMessage(data, "*");
};
</script>
</body>
index2.html
1
2
3
4
5
6
7
8
9
<body>
<p>这是弹出页面</p>
<script>
// 监听窗口消息
window.addEventListener("message", (event) => {
console.log(event.data);
});
</script>
</body>

在上面的代码中,我们在页面一通过 open 方法打开页面二,然后通过 postMessage 的方式向页面二传递信息。页面二通过监听 message 事件来接收信息。

WebSocket

WebSocket 协议在 2008 年诞生,2011 年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 初始化一个 node 项目 npm init -y
// 安装依赖 npm i -save ws

// 获取到 WebSocketServer 类
const WebSocketServer = require("ws").Server;
// 实例化 WebSocketServer 类,创建WebSocket服务器
const wss = new WebSocketServer({ port: 3000 });

// 保存所有的客户端连接实例
const clients = [];

// 客户端连接上 WebSocket 服务器的时候,就会触发 connection 事件
// 同时客户端的实例就会传入回调函数
wss.on("connection", function (client) {
// 保存客户端实例
clients.push(client);
console.log(`当前有${clients.length}个客户端连接上服务器`);

// 给客户端实例绑定 message 事件
// 当客户端发送消息到服务器的时候,就会触发 message 事件
client.on("message", function (msg) {
// 将收到的消息推送给其他所有客户端
clients.forEach((c) => {
// 排除当前客户端
if (c !== client) {
c.send(msg.toString());
}
});
});

// 当客户端关闭连接的时候,就会触发 close 事件
client.on("close", function () {
// 从 clients 数组中移除当前客户端
let index = clients.indexOf(client);
clients.splice(index, 1);
console.log(`当前有${clients.length}个客户端连接上服务器`);
});
});

console.log("Web Socket 服务器启动成功!!!");

在上面的代码中,我们创建了一个 Websocket 服务器,监听 8080 端口。每一个连接到该服务器的客户端,都会触发服务器的 connection 事件,并且会将此客户端连接实例作为回调函数的参数传入。

我们将所有的客户端连接实例保存到一个数组里面。为该实例绑定了 messageclose 事件,当某个客户端发来消息时,自动触发 message 事件,然后遍历 clients 数组中每个其他客户端对象,并发送消息给其他客户端。

close 事件在客户端断开连接时会触发,我们要做的事情就是从数组中删除该连接。

index1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<body>
<input type="text" name="msg" id="msg" />
<button id="send">发送信息</button>
<script>
const doms = {
msg: document.querySelector("#msg"),
send: document.querySelector("#send"),
};

// 创建客户端和 WebSocket 服务器的连接
const ws = new WebSocket("ws://localhost:3000");

doms.send.onclick = function () {
// 客户端向 WebSocket 服务器发送内容,触发服务器端的 message 事件
if (doms.msg.value.trim() !== "") {
ws.send(doms.msg.value.trim());
}
};
// 当页面关闭或刷新时,关闭 WebSocket 连接
window.onbeforeunload = function () {
// 关闭 WebSocket 连接
ws.close();
};
</script>
</body>
index2.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<body>
<script>
// 创建客户端和 WebSocket 服务器的连接
const ws = new WebSocket("ws://localhost:3000");

let content = 1; // 计数
// 当建立连接之后就会触发 open 事件
ws.onopen = function () {
// 当服务器端向客户端发送内容时,该 message 事件就会触发
ws.onmessage = function (event) {
const pTag = document.createElement("p");
pTag.innerHTML = `这是第${content++}条消息:${event.data}`;
document.body.appendChild(pTag);
};
};

// 当页面关闭或刷新时,关闭 WebSocket 连接
window.onbeforeunload = function () {
// 关闭 WebSocket 连接
ws.close();
};
</script>
</body>