让跨标签页通信像组件通信一样简单(webpage-channel)

140 阅读9分钟

简介

一个轻量级、类型友好的浏览器端消息通信库。仅需几行代码即可实现在不同网页上下文之间通信。

它提供统一的事件 API(ononceemitoff等),用于在不同网页上下文之间通信,例如多标签页、iframe 与 worker 场景。默认基于 BroadcastChannel,在不支持时自动降级到 localStorage,并支持通过适配器扩展到 postMessage 等通信方式。

核心代码不到 200 行(查看源码),单元测试覆盖率 100%

特性

  • 轻量易用:ononceemitoff 即可完成事件收发。
  • TypeScript 友好:通过泛型约束事件名和事件数据类型。
  • 可扩展适配器:默认 BroadcastChannel,不支持时自动降级到 localStorage,可自定义适配器。
  • 可自定义序列化:支持替换 JSON.stringify/parse
  • 错误可观察:提供消息编解码错误与底层消息错误回调。
  • 独立事件系统:抽离了 Event bus 实现,可以只引入该模块,用于组件通信等。

兼容性说明

适配器支持浏览器版本核心限制
BroadcastChannelChrome 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.contentWindowwindow.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 === targetOrigine.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.postMessageMessagePort 等)。

内置适配器

  • BroadcastChannelAdapter:默认适配器,适合同源多标签页/上下文通信。

  • LocalStorageAdapter:基于 localStorage + storage 事件的降级适配器,适用于 BroadcastChannel 不可用的环境。仅支持同源跨标签页,发送方标签页不会收到自身发出的消息(与 BroadcastChannel 行为一致)。

  • PostMessageAdapter:适合父页面与 iframe、弹窗窗口等基于 window.postMessage 的场景。

WebpageChannel 会按以下顺序自动选择适配器:BroadcastChannellocalStorage → 抛出错误。

自定义适配器示例

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 = {};
  }
}

开源信息