简介
webpage-channel 是一个轻量级、零依赖、类型友好的浏览器端消息通信库。仅需几行代码即可实现在不同网页上下文之间通信。
同域多标签页通信、跨域通信、组件通信,使用统一的事件 API(on、once、emit、off、request/response)。默认基于 BroadcastChannel,在不支持时自动降级到 localStorage,并支持通过适配器扩展到 postMessage 等通信方式。
特性
-
轻量无依赖:核心代码少,打包体积极小,零外部依赖
-
强类型安全:TypeScript 泛型,事件名与载荷类型一一对应,编译期类型校验
-
自动降级兼容:优先 BroadcastChannel,失败自动切 localStorage
-
适配器可扩展:默认 3 种适配器,支持自定义扩展(如 WebSocket)
-
自定义序列化:处理 Date、Blob 等特殊数据类型
-
错误可观测:全局错误钩子,捕获各类通信异常
-
RPC 层支持:v1.2.0 新增请求/响应与单向通知语义
-
100% 测试覆盖:单元测试覆盖所有核心模块,稳定性有保障
架构图
createRpcChannel 负责组装,WebpageChannelRpc 负责调用语义,WebpageChannel 负责消息通道抽象;
在 WebpageChannel 内部,EventBus 负责本地事件订阅与分发,Adapter 负责跨上下文消息传输。
应用场景
1. 同域不同网站广播消息
向同浏览器的其他同域网站标签页广播一些非敏感信息:
// A ... 网站
import { WebpageChannel } from 'webpage-channel';
type Events = {
'get:user': (payload: { name: string; age: number }[]) => void;
};
export const channel = new WebpageChannel<Events>('auth-channel');
// A网站某页面
import { channel } from '...'
// 广播消息
channel.emit('get:user', [{ name: 'xxx', age: 18 }])
// 其他同域网站 - 接收消息
channel.on('get:user', (users) => {
console.log('users', users)
});
2. 购物车跨标签页同步
在商品详情页加入购物车,购物车标签页自动刷新:
// 公共模块声明
import { createRpcChannel } from 'webpage-channel';
type Events = {
'cart:updated': () => void;
};
export const rpcChannel = createRpcChannel<Events>('cart-channel');
// 商品详情页
import { rpcChannel } from '...'
// 添加商品到购物车时,通知其他标签页
rpcChannel.notify('cart:updated');
// 购物车页面
import { rpcChannel } from '...'
// 接受通知,重新获取购物车数据
rpcChannel.onNotify('cart:updated', () => {
fetchCartData();
});
3.点对点通信 (request / response)
在某些仅需要主动获取同浏览器其他同域标签页的数据时:
// A/B 网站分别声明
import { createRpcChannel } from 'webpage-channel';
type Api = {
add: (payload: { a: number; b: number }) => number;
log: (payload: { text: string }) => void;
};
export const rpcChannel = createRpcChannel<Api>('my-channel');
// A 网站某页面
import { rpcChannel } from '...'
// 请求一些数据,request 方法支持使用 AbortSignal 中断请求
const [err, result] = await rpcChannel.request('add', { a: 3, b: 4 });
// B 网站
import { rpcChannel } from '...'
// 返回一些数据给 A网站,response 方法返回一个取消函数,可用于取消事件侦听
const cancel = rpcChannel.response('add', ({ a, b }) => a + b)
跨域通信
跨域通信只需将 WebpageChannel 的第三个参数,设置为该库提供的内置适配器 PostMessageAdapter 即可,事件 API 与同域通信一致。
适用于父页面与 iframe、弹窗窗口等基于 window.postMessage 的场景。
import { PostMessageAdapter, WebpageChannel } from 'webpage-channel';
type Events = {
'auth:token': (payload: { token: string }) => void;
};
const iframe = document.getElementById('child-frame') as HTMLIFrameElement;
const adapter = new PostMessageAdapter(iframe.contentWindow!, '*');
const channel = new WebpageChannel<Events>('iframe-channel', undefined, adapter);
兼容性说明
| 适配器 | 支持浏览器版本 | 核心限制 |
|---|---|---|
| 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 泛型支持、支持 request/response 模型 |
| localStorage + storage 事件 | 兼容性最好 | API 繁琐、易出错、性能差 | 封装底层细节、统一错误处理、统一事件驱动 |
| 原生 postMessage | 支持跨域 | 安全校验复杂、无事件模型 | 内置 origin 和 source 双重校验、统一事件驱动 |
| broadcast-channel(npm) | 功能丰富 | 体积大(~5KB gzip)、API复杂 | 极致轻量(<1KB gzip)、API 简单 |
| 其他事件总线库 | 组件通信方便 | 不支持跨标签页/iframe | 一套 API 同时支持跨上下文和组件通信 |
开源信息
- GitHub 仓库:github.com/wansongtao/…
- npm 包:www.npmjs.com/package/web…
- 许可证:MIT(可免费商业使用)
核心源码
查看最新的、完整的项目源码,请跳转到 GitHub。
WebpageChannel 实现
import ... from '...';
type Message<T extends Record<string, any>> = IChannelData<
Parameters<T[keyof T]>,
keyof T
>;
interface Options<T extends Record<string, any>> {
onError?: IErrorEvent;
onMessageError?: IMessageErrorEvent;
serializeMessage: (data: Message<T>) => string;
deserializeMessage: (data: string) => Message<T>;
}
export class WebpageChannel<T extends Record<string, (args: any) => void>> {
private channelName: string;
private eventBus: EventBus<T>;
private adapter: IWebpageChannelAdapter | null;
options: Options<T> = {
serializeMessage: JSON.stringify,
deserializeMessage: JSON.parse
};
constructor(
channelName: string,
options?: Partial<Options<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;
if (options?.onError) {
this.options.onError = options.onError;
this.eventBus = new EventBus<T>({
onListenerError: (error) => {
this.options.onError?.(error);
}
});
} else {
this.eventBus = new EventBus<T>();
}
if (options?.onMessageError) {
this.options.onMessageError = options?.onMessageError;
this.adapter.onMessageError((e) => {
this.options.onMessageError?.(e);
});
}
if (options?.serializeMessage) {
this.options.serializeMessage = options.serializeMessage;
}
if (options?.deserializeMessage) {
this.options.deserializeMessage = options.deserializeMessage;
}
this.onMessage();
}
on<K extends keyof T>(event: K, callback: T[K]): () => void {
if (!this.adapter) {
this.options.onError?.(new Error('Channel is closed'));
return () => {};
}
return this.eventBus.on(event, callback);
}
once<K extends keyof T>(event: K, callback: T[K]): () => void {
if (!this.adapter) {
this.options.onError?.(new Error('Channel is closed'));
return () => {};
}
return 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: Message<T> = {
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: Message<T>) {
if (!this.adapter) {
const error = new Error('Adapter is not initialized');
this.options.onError?.(error);
return false;
}
try {
const message = this.options.serializeMessage(data);
this.adapter.postMessage(message);
return true;
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
this.options.onError?.(error);
return false;
}
}
private onMessage() {
const callback = (message: string) => {
let res: Message<T>;
try {
res = this.options.deserializeMessage(message);
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
this.options.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);
}
get isClosed(): boolean {
return this.adapter === null;
}
}
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]): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
return () => this.off(event, callback);
}
once<K extends keyof T>(event: K, callback: T[K]): () => void {
const onceCallback = ((args: Parameters<T[K]>[0]) => {
this.off(event, onceCallback as T[K]);
callback(args);
}) as WrappedListener<T[K]>;
onceCallback[ORIGINAL_LISTENER] = callback;
return 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);
}
if (fns.length === 0) {
delete this.listeners[event];
}
}
clear() {
this.listeners = {};
}
}
了解更多
请查看 npm 文档。