实现“乞丐版”的BroadcastChannel通讯机制

1,364 阅读5分钟

概述

BroadcastChannel接口代理了一个命名频道,可以实现同源下浏览器的不同窗口,标签页,frame或者iframe下的浏览器上下文(通常是同一个网站下不同的页面)之间的简单通信。

通过创建一个监听某个频道下的BroadcastChannel对象,你可以接收发送给该频道的所有消息。不同页面可以通过构造BroadcastChannel来订阅相同的频道,然后相互之间便可以进行全双工(双向)通信。

image

简单示例

我们可以通过创建两个页面,然后在浏览器的不同标签页分别访问这两个页面,来演示如何使用BroadcastChannel通信。

sender.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Receiver 1</title>
    <style>
        body {
            border: 1px solid black;
            padding: .5rem;
            height: 150px;
            font-family: "Fira Sans", sans-serif;
        }

        h1 {
            font: 1.6em "Fira Sans", sans-serif;
            margin-bottom: 1rem;
        }

        textarea {
            padding: .2rem;
        }

        label, br {
            margin: .5rem 0;
        }

        button {
            vertical-align: top;
            height: 1.5rem;
        }
    </style>
</head>
<body>
<div>
    <h1>发送者</h1>
    <label for="message">输入要广播的信息:</label><br/>
    <textarea id="message" name="message" rows="1" cols="40">Hello</textarea>
    <button id="broadcast-message" type="button">开始广播</button>
</div>
<script>
    const channel = new BroadcastChannel('example-channel');
    const messageControl = document.querySelector('#message');
    const broadcastMessageButton = document.querySelector('#broadcast-message');

    broadcastMessageButton.addEventListener('click', () => {
        channel.postMessage(messageControl.value);
    });
</script>
</body>
</html>

receiver.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Receiver</title>
    <style>
        h1 {
            margin-bottom: 1rem;
        }
    </style>
</head>
<body>
<div>
    <h1>接收者</h1>
    <div id="received"></div>
</div>
<script>
    const channel = new BroadcastChannel('example-channel');
    channel.addEventListener('message', (event) => {
        received.textContent = event.data;
    });
</script>
</body>
</html>

点击发送页面的“开始广播”按钮,接收页面将收到消息并展示到div上。

BroadcastChannel接口

BroadcastChannel继承自EventTarget,是基于标准的事件模型实现的。

创建或加入某个频道

BroadcastChannel接口非常简单。通过创建一个BroadcastChannel对象,一个客户端就加入了某个指定的频道。只需要向构造函数传入一个参数:频道名称。如果这是首次连接到该广播频道,相应资源会自动被创建。

// 连接到广播频道
var bc = new BroadcastChannel('test_channel');

发送消息

现在发送消息就很简单了,只需要调用BroadcastChannel对象上的postMessage()方法即可。该方法的参数可以是任意对象。最简单的例子就是发送字符串文本消息:

// 发送简单消息的示例
bc.postMessage('This is a test message.');

接收消息

当消息被发送之后,所有连接到该频道的BroadcastChannel对象上都会触发message事件。该事件没有默认的行为,但是可以使用onmessage定义一个函数来处理消息。

// 简单示例,用于将事件打印到控制台
bc.onmessage = function (ev) { console.log(ev); }

与频道断开连接

通过调用BroadcastChannel对象的close()方法,可以离开频道。这将断开该对象和其关联的频道之间的联系,并允许它被垃圾回收。

// 断开频道连接
bc.close();

源码实现

EventTarget

既然BroadcastChannel继承自EventTarget,那么我们就先实现EventTarget,这里直接使用MDN上的简单实现

class EventTarget {
    private readonly listeners: {
        [index: string]: Array<TListener>,
    };

    constructor() {
        this.listeners = {};
    }

    addEventListener(type: string, callback: TListener): void {
        if (!(type in this.listeners)) {
            this.listeners[type] = [];
        }
        this.listeners[type].push(callback);
    }

    removeEventListener(type: string, callback: TListener): void {
        if (!(type in this.listeners)) {
            return;
        }
        var stack = this.listeners[type];
        for (var i = 0, l = stack.length; i < l; i++) {
            if (stack[i] === callback) {
                stack.splice(i, 1);
                return this.removeEventListener(type, callback);
            }
        }
    }

    dispatchEvent(event: TEvent): void {
        if (!(event.type in this.listeners)) {
            return;
        }
        var stack = this.listeners[event.type];
        event.target = this;
        for (var i = 0, l = stack.length; i < l; i++) {
            stack[i].call(this, event);
        }
    };
}

BroadcastChannel

  1. 首先,我们需要定义一个频道中心,用于存储所有订阅了指定频道的BroadcastChannel对象。
const channels: {
    [index: string]: Set<BroadcastChannel>,
} = {};

为了简化操作,我们直接使用了Set代替Array来存储BroadcastChannel对象。

  1. 然后,定义一个BroadcastChannel类,继承自EventTarget类。
class BroadcastChannel extends EventTarget{
    public readonly channel: string;
    public onmessage?: (message: TMessage) => any;
    
    private readonly onMessageEventHandler: (event: TEvent) => void;
}

注意,这里除了channelonmessage这两个公共属性之外,还额外定义了一个onMessageEventHandler私有属性,接下来我们便会用到它们。

  1. 接下来,实现构造函数。
constructor(channel: string) {
    super();

    const that = this;

    this.channel = channel;
    this.onMessageEventHandler = function onMessageEventHandler(e: TEvent) {
        if (that.onmessage) {
            that.onmessage({
                type: 'message',
                data: e.detail,
            });
        }
    };

    this.addEventListener('message', this.onMessageEventHandler);

    if (!channels[channel]) channels[channel] = new Set();
    channels[channel].add(this);
}

在构建函数中,监听了'message'事件,并在事件回调中执行onmessage注册的函数。同时将BroadcastChannel实例对象注册到频道中心,以便后续广播消息到该BroadcastChannel实例。

  1. 接下来是用于发送消息的postMessage方法。
postMessage(message: any) {
    for (let broadcastChannel of channels[this.channel]) {
        if (broadcastChannel === this) continue; // 不要发给自己,以免造成广播风暴
        broadcastChannel.dispatchEvent({
            type: 'message',
            detail: message,
        });
    }
}

从频道中心遍历订阅了指定channel的所有BroadcastChannel对象,依次调用其dispatchEvent方法,达到广播消息的目的。

  1. 最后是close方法,移除对message事件的监听并从频道中心删除。
close() {
    this.removeEventListener('message', this.onMessageEventHandler);
    channels[this.channel].delete(this);
    if (channels[this.channel].size === 0) {
        delete channels[this.channel];
    }
}

补充说明

  1. 如果完整按照BroadcastChannel的规范来实现的话,消息是要序列化和反序列化的,因为不同的浏览器上下文之间无法共享内存引用,只能序列化之后才能传输,本文的实现省略了这一步;
  2. 真正的BroadcastChannel是基于浏览器上下文进行隔离的,同一个上下文内部的不同BroadcastChannel对象相互之间是不通信的,本文的实现简化成了实例之间的隔离;

扩展阅读