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

113 阅读6分钟

简介

一个轻量级、类型友好的浏览器端消息通信库。适合同域多窗口通信、iframe 父子页面通信等。

提供了轻量易用的事件 API(ononceemitoff等),几行代码即可实现在不同网页上下文之间通信。

默认基于 BroadcastChannel 实现同域通信,也提供了一个适用于跨域通信,基于 postMessage 的适配器。支持通过自定义适配器扩展通信方式。

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

特性

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

安装

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;
const adapter = new PostMessageAdapter(iframe.contentWindow!, 'https://child.example.com');
const channel = new WebpageChannel<Events>('iframe-channel', undefined, adapter);

channel.emit('auth:token', { token: 'abc123' });

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 ,父子页面必须一致,内部会校验,不一致则接收不到消息。

跨组件通信

该库抽离了事件系统 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:默认适配器,适合同源多标签页/上下文通信。
  • PostMessageAdapter:适合父页面与 iframe、弹窗窗口等基于 window.postMessage 的场景。

自定义适配器示例

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);
	}
});

使用建议

  • 事件名保持稳定且语义化,推荐使用 模块:动作 命名。
  • 避免传输超大对象,尽量传必要字段。
  • 跨来源通信时请在适配器内严格校验 origin
  • 作为跨系统或跨上下文协议时,事件名和消息字段建议使用 string,不要使用 Symbol
  • 在页面卸载或模块销毁时调用 close() 释放资源。

源码

查看完整项目源码 GitHub

WebpageChannel 实现

import type {
  IChannelData,
  IErrorEvent,
  IMessageErrorEvent,
  IWebpageChannelAdapter
} from '../types';

import BroadcastChannelAdapter from './broadcast-channel-adapter';
import EventBus from './event-bus';

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
  ) {
    this.channelName = channelName;
    this.eventBus = new EventBus<T>({
      onListenerError: (error) => {
        this.onError?.(error);
      }
    });
    this.adapter = adapter ?? new BroadcastChannelAdapter(channelName);
    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 && this.onError(error);
      return false;
    }

    try {
      const message = this.serializeMessage(data);
      this.adapter?.postMessage(message);
      return true;
    } catch (e: any) {
      if (!(e instanceof Error)) {
        e = new Error(e);
      }

      this.onError && this.onError(e);
      return false;
    }
  }

  private onMessage() {
    this.adapter?.onMessage((message) => {
      let res: IChannelData<Parameters<T[keyof T]>, keyof T>;
      try {
        res = this.deserializeMessage(message);
      } catch (e: any) {
        if (!(e instanceof Error)) {
          e = new Error(e);
        }

        this.onError && this.onError(e);
        return;
      }

      const key = res.event;
      if (
        res.channelName !== this.channelName ||
        key === undefined ||
        key === null
      ) {
        return;
      }

      // @ts-ignore
      this.eventBus.emit(key, res.data);
    });
  }
}

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

BroadcastChannelAdapter 实现

import type { IWebpageChannelAdapter } from '../types';

export default class BroadcastChannelAdapter implements IWebpageChannelAdapter {
  private channel: BroadcastChannel;

  constructor(channelName: string) {
    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();
  }
}

PostMessageAdapter 实现

import type { IWebpageChannelAdapter } from '../types';

export default class PostMessageAdapter implements IWebpageChannelAdapter {
  private targetWindow: Window;
  private targetOrigin: string;
  private messageHandler: ((e: MessageEvent) => void) | null = null;
  private messageErrorHandler: ((e: MessageEvent) => void) | null = null;

  constructor(targetWindow: Window, targetOrigin: string) {
    this.targetWindow = targetWindow;
    this.targetOrigin = targetOrigin;
  }

  postMessage(message: string) {
    this.targetWindow.postMessage(message, this.targetOrigin);
  }

  onMessage(callback: (message: string) => void) {
    if (this.messageHandler) {
      window.removeEventListener('message', this.messageHandler);
    }

    this.messageHandler = (e) => {
      if (e.origin === this.targetOrigin && e.source === this.targetWindow) {
        callback(e.data);
      }
    };

    window.addEventListener('message', this.messageHandler);
  }

  onMessageError(callback: (e: MessageEvent) => void) {
    if (this.messageErrorHandler) {
      window.removeEventListener('messageerror', this.messageErrorHandler);
    }

    this.messageErrorHandler = callback;
    window.addEventListener('messageerror', this.messageErrorHandler);
  }

  close() {
    if (this.messageHandler) {
      window.removeEventListener('message', this.messageHandler);
      this.messageHandler = null;
    }

    if (this.messageErrorHandler) {
      window.removeEventListener('messageerror', this.messageErrorHandler);
      this.messageErrorHandler = null;
    }
  }
}

类型定义

export interface IWebpageChannelAdapter {
  postMessage(message: string): void;
  onMessage(callback: (message: string) => void): void;
  onMessageError(callback: (e: MessageEvent) => void): void;
  close(): void;
}

export interface IChannelData<T = any, C = string> {
  channelName: string
  event?: C;
  data?: T;
}
export type IErrorEvent = (e: Error) => void;
export type IMessageErrorEvent = (e: MessageEvent) => void;