轻量级网页通信神器 webpage-channel

417 阅读5分钟

简介

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

同域多标签页通信、跨域通信、组件通信,使用统一的事件 API(ononceemitoffrequest/response)。默认基于 BroadcastChannel,在不支持时自动降级到 localStorage,并支持通过适配器扩展postMessage 等通信方式。

特性

  • 轻量无依赖:核心代码少,打包体积极小,零外部依赖

  • 强类型安全:TypeScript 泛型,事件名与载荷类型一一对应,编译期类型校验

  • 自动降级兼容:优先 BroadcastChannel,失败自动切 localStorage

  • 适配器可扩展:默认 3 种适配器,支持自定义扩展(如 WebSocket)

  • 自定义序列化:处理 Date、Blob 等特殊数据类型

  • 错误可观测:全局错误钩子,捕获各类通信异常

  • RPC 层支持:v1.2.0 新增请求/响应与单向通知语义

  • 100% 测试覆盖:单元测试覆盖所有核心模块,稳定性有保障

架构图

createRpcChannel 负责组装,WebpageChannelRpc 负责调用语义,WebpageChannel 负责消息通道抽象;

WebpageChannel 内部,EventBus 负责本地事件订阅与分发,Adapter 负责跨上下文消息传输。

webpage-channel.png

应用场景

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

兼容性说明

适配器支持浏览器版本核心限制
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 泛型支持、支持 request/response 模型
localStorage + storage 事件兼容性最好API 繁琐、易出错、性能差封装底层细节、统一错误处理、统一事件驱动
原生 postMessage支持跨域安全校验复杂、无事件模型内置 origin 和 source 双重校验、统一事件驱动
broadcast-channel(npm)功能丰富体积大(~5KB gzip)、API复杂极致轻量(<1KB gzip)、API 简单
其他事件总线库组件通信方便不支持跨标签页/iframe一套 API 同时支持跨上下文和组件通信

开源信息

核心源码

查看最新的、完整的项目源码,请跳转到 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 文档。