简介
一个轻量级、类型友好的浏览器端消息通信库。仅需几行代码即可实现在不同网页上下文之间通信。
它提供统一的事件 API(on、once、emit、off等),用于在不同网页上下文之间通信,例如多标签页、iframe 与 worker 场景。默认基于 BroadcastChannel,在不支持时自动降级到 localStorage,并支持通过适配器扩展到 postMessage 等通信方式。
核心代码不到 200 行(查看源码),单元测试覆盖率 100%。
特性
- 轻量易用:
on、once、emit、off即可完成事件收发。 - TypeScript 友好:通过泛型约束事件名和事件数据类型。
- 可扩展适配器:默认
BroadcastChannel,不支持时自动降级到localStorage,可自定义适配器。 - 可自定义序列化:支持替换
JSON.stringify/parse。 - 错误可观察:提供消息编解码错误与底层消息错误回调。
- 独立事件系统:抽离了
Event bus实现,可以只引入该模块,用于组件通信等。
兼容性说明
| 适配器 | 支持浏览器版本 | 核心限制 |
|---|---|---|
| BroadcastChannel | Chrome 54+, Firefox 38+, Safari 15.4+, Edge 79+ | 仅支持同源通信 |
| LocalStorage | 所有现代浏览器(IE8+) | 同源、隐私模式可能禁用、5MB大小限制 |
| PostMessage | 所有现代浏览器(IE8+) | 需配置正确的targetOrigin |
- SSR环境:不支持(依赖浏览器API),在Next.js/Nuxt.js中请在客户端挂载后再初始化
- 最低Node.js版本:v14.0.0(用于开发环境)
为什么选择 webpage-channel?
| 方案 | 优点 | 缺点 | webpage-channel 优势 |
|---|---|---|---|
| 原生 BroadcastChannel | 性能最好、原生支持 | 兼容性差、无事件系统、无类型安全 | 自动降级、统一事件API、TypeScript泛型支持 |
| localStorage + storage 事件 | 兼容性最好 | API繁琐、易出错、性能差 | 封装底层细节、自动过滤重复消息、统一错误处理 |
| 原生 postMessage | 支持跨域 | 安全校验复杂、无事件模型 | 内置origin和source双重校验、统一事件驱动 |
| broadcast-channel(npm) | 功能丰富 | 体积大(~5KB gzip)、API复杂 | 极致轻量(<1KB gzip)、核心代码不到200行 |
| 其他事件总线库 | 组件通信方便 | 不支持跨标签页/iframe | 一套API同时支持跨上下文和组件通信 |
典型应用场景
1. 多标签页登录状态同步
一个标签页登录/退出后,其他所有标签页自动更新状态:
// 登录页
type Events = {
'auth:login': (payload: string) => void;
};
const channel = new WebpageChannel<Events>('auth-channel');
function handleLogin(token: string) {
localStorage.setItem('token', token);
channel.emit('auth:login', token);
}
// 所有页面
const channel = new WebpageChannel<Events>('auth-channel');
channel.on('auth:login', (payload) => {
// 更新当前页面的登录状态
window.location.reload();
});
channel.on('auth:logout', () => {
localStorage.removeItem('token');
window.location.href = '/login';
});
2. 购物车跨标签页同步
在商品详情页加入购物车,购物车标签页自动刷新:
// 商品详情页
const channel = new WebpageChannel('cart-channel');
function addToCart(productId: string) {
// 调用接口添加购物车
api.addToCart(productId).then(() => {
channel.emit('cart:updated');
});
}
// 购物车页面
const channel = new WebpageChannel('cart-channel');
channel.on('cart:updated', () => {
// 重新获取购物车数据
fetchCartData();
});
3. 主应用与内嵌 iframe 通信
主应用将用户 token 传递给第三方 iframe 子应用:
// 主应用(父页面)
const iframe = document.getElementById('child-frame') as HTMLIFrameElement;
iframe.onload = () => {
const adapter = new PostMessageAdapter(
iframe.contentWindow!,
'https://child.example.com'
);
const channel = new WebpageChannel('iframe-auth', undefined, adapter);
channel.emit('auth:token', { token: 'user-token-123' });
};
// 子应用(iframe)
const adapter = new PostMessageAdapter(window.parent, 'https://your-app.com');
const channel = new WebpageChannel('iframe-auth', undefined, adapter);
channel.on('auth:token', (payload) => {
// 使用token初始化子应用
initApp(payload.token);
});
安装
pnpm add webpage-channel
# 或
npm i webpage-channel
# 或
yarn add webpage-channel
同域通信
该库默认基于 BroadcastChannel,同域页面通信时,可以直接使用默认配置。
1. 定义事件类型
在同域的两个不同页面,都添加以下代码:
import { WebpageChannel } from 'webpage-channel';
type Events = {
'user:update': (payload: { id: string; name: string }) => void;
'toast:show': (payload: { message: string; type: 'success' | 'error' }) => void;
};
const channel = new WebpageChannel<Events>('app-channel');
2. 监听消息
在接收消息方,添加以下代码:
channel.on('user:update', (payload) => {
console.log('收到用户更新', payload.id, payload.name);
});
3. 发送消息
在发送消息方,添加以下代码:
const ok = channel.emit('user:update', { id: 'u1', name: 'Alice' });
if (!ok) {
console.warn('消息发送失败');
}
4. 取消监听和销毁(可选)
const onToast = (payload: { message: string; type: 'success' | 'error' }) => {
console.log(payload.message);
};
channel.on('toast:show', onToast);
channel.once('toast:show', (payload) => {
console.log('仅触发一次:', payload.message);
});
channel.off('toast:show', onToast); // 移除指定监听器
channel.off('toast:show'); // 移除该事件全部监听器
channel.close(); // 清空监听并关闭底层通道
跨域通信
跨域通信可以使用内置适配器 PostMessageAdapter ,适合父页面与 iframe、弹窗窗口等基于 window.postMessage 的场景。
PostMessageAdapter 构造参数:
targetWindow: Window:目标窗口对象(如iframe.contentWindow、window.parent)。targetOrigin: string:目标来源(例如https://example.com,或开发环境*)。
1. 父页面发送消息给 iframe
import { PostMessageAdapter, WebpageChannel } from 'webpage-channel';
type Events = {
'auth:token': (payload: { token: string }) => void;
};
const iframe = document.getElementById('child-frame') as HTMLIFrameElement;
iframe.onload = () => {
const adapter = new PostMessageAdapter(
iframe.contentWindow!,
'https://child.example.com'
);
const channel = new WebpageChannel<Events>('iframe-channel', undefined, adapter);
channel.emit('auth:token', { token: 'user-token-123' });
};
2. iframe 接收消息
import { PostMessageAdapter, WebpageChannel } from 'webpage-channel';
type Events = {
'auth:token': (payload: { token: string }) => void;
};
const adapter = new PostMessageAdapter(window.parent, 'https://parent.example.com');
const channel = new WebpageChannel<Events>('iframe-channel', undefined, adapter);
channel.on('auth:token', (payload) => {
console.log('收到 token:', payload.token);
});
3. 注意事项
- 生产环境请避免使用
*作为targetOrigin。 PostMessageAdapter内部会同时校验e.origin === targetOrigin和e.source === targetWindow。WebpageChannel的第一个参数name,父子页面必须一致,内部会校验,不一致则接收不到消息。- 父页面向 iframe 发送消息时,如果 iframe 尚未加载完成,消息会直接丢失。必须等待 iframe 的
load事件触发后再初始化通道并发送消息。
跨组件通信
该库抽离了事件系统 Event Bus 的实现,作为一个单独模块提供,因此可以只引入该模块,用于组件通信。
1. 父组件
import { EventBus } from 'webpage-channel';
type Events = {
ping: (payload: { value: number }) => void;
pong: (payload: { result: string }) => void;
};
export const bus = new EventBus<Events>();
2. 兄弟组件 A
发送消息
import { bus } from 'parent'
bus.emit('ping', { value: 1 });
bus.emit('pong', { result: 'hello' })
3. 兄弟组件 B
接收消息
import { bus } from 'parent'
bus.on('ping', (payload) => {
console.log('收到:', payload.value);
// 触发后,主动移除该事件全部监听器
bus.off('ping')
});
// 只触发一次
bus.once('pong', (payload) => {
console.log('收到:', payload.result);
})
API
new WebpageChannel<T>(channelName, options?, adapter?)
创建一个频道实例。
channelName: string:频道名称。options?: { ... }:可选配置。adapter?: IWebpageChannelAdapter:可选适配器;不传时默认使用BroadcastChannelAdapter。
options 说明:
onError?: (e: Error) => void- 序列化、反序列化或事件分发过程中出现异常时触发。
onMessageError?: (e: MessageEvent) => void- 底层通道触发
messageerror时触发。
- 底层通道触发
serializeMessage?: (data) => string- 自定义序列化函数,默认
JSON.stringify。
- 自定义序列化函数,默认
deserializeMessage?: (raw) => data- 自定义反序列化函数,默认
JSON.parse。
- 自定义反序列化函数,默认
channel.on(event, callback)
注册事件监听。
channel.once(event, callback)
注册一次性监听器,首次触发后会自动移除。
channel.emit(event, payload): boolean
发送事件并返回是否发送成功:
true:序列化与发送成功。false:发送过程抛错(同时触发onError)。false:在调用close()之后再调用emit也会返回false(同时触发onError)。
channel.off(event, listener?)
- 传
listener:仅移除该函数引用。 - 不传
listener:移除该事件全部监听器。
channel.clear()
清空当前实例的所有事件监听器。
channel.close()
清空监听器并关闭底层适配器。
调用后,就不能通信了。必须重新创建新实例,才可以通信。
适配器扩展
库通过 IWebpageChannelAdapter 抽象底层通信能力,你可以按需实现自己的适配器(例如 window.postMessage、MessagePort 等)。
内置适配器
-
BroadcastChannelAdapter:默认适配器,适合同源多标签页/上下文通信。 -
LocalStorageAdapter:基于localStorage+storage事件的降级适配器,适用于BroadcastChannel不可用的环境。仅支持同源跨标签页,发送方标签页不会收到自身发出的消息(与BroadcastChannel行为一致)。 -
PostMessageAdapter:适合父页面与 iframe、弹窗窗口等基于window.postMessage的场景。
WebpageChannel会按以下顺序自动选择适配器:BroadcastChannel→localStorage→ 抛出错误。
自定义适配器示例
import { WebpageChannel, type IWebpageChannelAdapter } from 'webpage-channel';
class MyAdapter implements IWebpageChannelAdapter {
postMessage(message: string) {
// send
}
onMessage(callback: (message: string) => void) {
// receive
}
onMessageError(callback: (e: MessageEvent) => void) {
// message error
}
close() {
// cleanup
}
}
type Events = {
ping: (payload: { time: number }) => void;
};
const channel = new WebpageChannel<Events>('my-channel', undefined, new MyAdapter());
序列化定制示例
type Events = {
notify: (payload: { text: string }) => void;
};
const channel = new WebpageChannel<Events>('secure-channel', {
serializeMessage(data) {
return btoa(JSON.stringify(data));
},
deserializeMessage(raw) {
return JSON.parse(atob(raw));
},
onError(err) {
console.error('编解码或分发错误:', err);
}
});
最佳实践
使用建议
-
事件名保持稳定且语义化,推荐使用
模块:动作命名。 -
控制消息大小:单条消息不要超过100KB,大文件请使用其他方式传输。
-
避免传输敏感信息:不要在消息中传输密码、密钥等敏感数据。
-
XSS防护:如果消息内容会渲染到页面上,必须进行HTML转义。
调试技巧
调试 localStorage 降级:
- 打开DevTools → Application → Local Storage
- 可以看到所有存储的消息(键名以
__webpage-channe:开头)
源码
查看最新的、完整的项目源码,请跳转到 GitHub。
WebpageChannel 实现
import type {
IChannelData,
IErrorEvent,
IMessageErrorEvent,
IWebpageChannelAdapter
} from '../types';
import BroadcastChannelAdapter from './broadcast-channel-adapter';
import LocalStorageAdapter from './localstorage-adapter';
import EventBus from './event-bus';
import { isSupportBroadcastChannel, isSupportLocalStorage } from '../utils';
export default class WebpageChannel<
T extends Record<string, (args: any) => void>
> {
private channelName: string;
private eventBus: EventBus<T>;
private adapter: IWebpageChannelAdapter | null;
onError?: IErrorEvent;
onMessageError?: IMessageErrorEvent;
serializeMessage: (
data: IChannelData<Parameters<T[keyof T]>, keyof T>
) => string;
deserializeMessage: (
data: string
) => IChannelData<Parameters<T[keyof T]>, keyof T>;
constructor(
channelName: string,
options?: {
onError?: IErrorEvent;
onMessageError?: IMessageErrorEvent;
serializeMessage?: (
data: IChannelData<Parameters<T[keyof T]>, keyof T>
) => string;
deserializeMessage?: (
data: string
) => IChannelData<Parameters<T[keyof T]>, keyof T>;
},
adapter?: IWebpageChannelAdapter
) {
if (!adapter) {
if (isSupportBroadcastChannel()) {
this.adapter = new BroadcastChannelAdapter(channelName);
} else if (isSupportLocalStorage()) {
this.adapter = new LocalStorageAdapter(channelName);
} else {
throw new Error(
'Neither BroadcastChannel nor localStorage is supported in this environment.'
);
}
} else {
this.adapter = adapter;
}
this.channelName = channelName;
this.eventBus = new EventBus<T>({
onListenerError: (error) => {
this.onError?.(error);
}
});
this.onMessage();
this.onError = options?.onError;
this.onMessageError = options?.onMessageError;
this.adapter.onMessageError((e) => {
if (!this.onMessageError) return;
this.onMessageError(e);
});
this.serializeMessage = options?.serializeMessage ?? JSON.stringify;
this.deserializeMessage = options?.deserializeMessage ?? JSON.parse;
}
on<K extends keyof T>(event: K, callback: T[K]) {
this.eventBus.on(event, callback);
}
once<K extends keyof T>(event: K, callback: T[K]) {
this.eventBus.once(event, callback);
}
emit<K extends keyof T>(
event: K,
...[args]: Parameters<T[K]>[0] extends undefined
? []
: [Parameters<T[K]>[0]]
) {
const channelName = this.channelName;
const msg: IChannelData<Parameters<T[K]>, K> = {
channelName,
event,
data: args
};
return this.postMessage(msg);
}
off<K extends keyof T>(event: K, listener?: T[K]) {
this.eventBus.off(event, listener);
}
clear() {
this.eventBus.clear();
}
close() {
this.clear();
this.adapter?.close();
this.adapter = null;
}
private postMessage(data: IChannelData<Parameters<T[keyof T]>, keyof T>) {
if (!this.adapter) {
const error = new Error('Adapter is not initialized');
this.onError?.(error);
return false;
}
try {
const message = this.serializeMessage(data);
this.adapter?.postMessage(message);
return true;
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
this.onError?.(error);
return false;
}
}
private onMessage() {
const callback = (message: string) => {
let res: IChannelData<Parameters<T[keyof T]>, keyof T>;
try {
res = this.deserializeMessage(message);
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
this.onError?.(error);
return;
}
const key = res.event;
if (
res.channelName !== this.channelName ||
key === undefined ||
key === null
) {
return;
}
// @ts-ignore
this.eventBus.emit(key, res.data);
};
this.adapter?.onMessage(callback);
}
}
BroadcastChannelAdapter 实现
import type { IWebpageChannelAdapter } from '../types';
import { isSupportBroadcastChannel } from '../utils';
export default class BroadcastChannelAdapter implements IWebpageChannelAdapter {
private channel: BroadcastChannel;
constructor(channelName: string) {
if (!isSupportBroadcastChannel()) {
throw new Error('BroadcastChannel is not supported in this environment.');
}
this.channel = new BroadcastChannel(channelName);
}
postMessage(message: string) {
this.channel.postMessage(message);
}
onMessage(callback: (message: string) => void) {
this.channel.onmessage = (e) => {
callback(e.data);
};
}
onMessageError(callback: (e: MessageEvent) => void) {
this.channel.onmessageerror = callback;
}
close() {
this.channel.close();
}
}
Event Bus 实现
type EventMap = Record<string, (args: any) => void>;
const ORIGINAL_LISTENER = Symbol('originalListener');
type WrappedListener<F extends (...args: any[]) => any> = F & {
[ORIGINAL_LISTENER]?: F;
};
export default class EventBus<T extends EventMap> {
private listeners: Partial<{ [K in keyof T]: T[K][] }> = {};
private onListenerError?: (error: Error) => void;
constructor(options?: { onListenerError?: (error: Error) => void }) {
this.onListenerError = options?.onListenerError;
}
on<K extends keyof T>(event: K, callback: T[K]) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
}
once<K extends keyof T>(event: K, callback: T[K]) {
const onceCallback = ((args: Parameters<T[K]>[0]) => {
this.off(event, onceCallback as T[K]);
callback(args);
}) as WrappedListener<T[K]>;
onceCallback[ORIGINAL_LISTENER] = callback;
this.on(event, onceCallback as T[K]);
}
emit<K extends keyof T>(
event: K,
...[args]: Parameters<T[K]>[0] extends undefined
? []
: [Parameters<T[K]>[0]]
) {
const callbacks = this.listeners[event];
if (!callbacks?.length) {
return;
}
[...callbacks].forEach((callback) => {
try {
callback(args);
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
this.onListenerError?.(error);
}
});
}
off<K extends keyof T>(event: K, listener?: T[K]) {
if (!listener) {
delete this.listeners[event];
return;
}
const fns = this.listeners[event];
if (!fns?.length) {
return;
}
const idx = fns.findIndex((fn) => {
const wrapped = fn as WrappedListener<T[K]>;
return fn === listener || wrapped[ORIGINAL_LISTENER] === listener;
});
if (idx !== -1) {
fns.splice(idx, 1);
}
}
clear() {
this.listeners = {};
}
}
开源信息
- GitHub 仓库:github.com/wansongtao/…
- npm 包:www.npmjs.com/package/web…
- 许可证:MIT(可免费商业使用)