前端基于 postMessage 的跨标签页通信

212 阅读2分钟

前端基于 postMessage 的跨标签页通信

需求:

  • 页面1打开页面2,页面2打开页面3,页面2和页面3通信。
  • 页面2和页面3只要有1个关闭那么也关闭另一个
  • 在页面2支持激活页面3窗口
  • 由于页面2和页面3是在同域下前端基于 postMessage 的跨标签页通信,所以不用考虑跨域情况

选型:

  • postMessage:用于一对一的窗口通信,优点:可实现父页面激活子页面窗口
  • Broadcast API:用于一对多的窗口通信
/**
 * 打开与被打开标签页之间的通信封装,使用 window.onmessage、window.postMessage
 */

import { isDev } from '@utils/index';
import { Subscription, fromEvent } from 'rxjs';
import { EChanelMessageType } from '.';
import { isWindowReloaded } from '@utils/window';

enum EInternalMessageType {
  RequestSource = 'RequestSource',
  Source = 'Source'
}
type ITypeToData = Record<ISafeAny, ISafeAny>;
export class ChanelPoint<TypeToData extends ITypeToData> {
  public readonly target: ChanelPoint<TypeToData> | null = null;
  private readonly _typeToListeners = new Map<keyof TypeToData, Set<(data: ISafeAny, e: MessageEvent) => void>>();
  private _subs: Subscription;
  protected _targetWindow: Window | null = null;
  protected onWindowRefresh?: Function;
  protected onWindowClose?: Function;
  private dataStackWaitingPost: ISafeAny[] = [];

  constructor(targetWindow: Window | null) {
    this._subs = fromEvent(window, 'message').subscribe((ev) => {
      const {
        data: { type, data }
      } = ev as MessageEvent;
      if (isDev()) {
        console.log(`收到消息,类型:${EChanelMessageType[type] ?? type}, 数据:`, data);
      }

      this._typeToListeners.get(type)?.forEach((x) => x(data, ev as MessageEvent));
    });

    let beforeTime = 0;
    window.addEventListener('beforeunload', () => {
      beforeTime = new Date().getTime();
    });
    window.addEventListener('unload', () => {
      const interval = new Date().getTime() - beforeTime;
      if (interval < 5) {
        // close
        this.onWindowClose?.();
      } else {
        this.onWindowRefresh?.();
      }
    });

    this.addMessageListener(EInternalMessageType.RequestSource, () => {
      setTimeout(() => {
        this.postMessage(EInternalMessageType.Source, null as ISafeAny);
      }, 200);
    });

    this.addMessageListener(EInternalMessageType.Source, (data: ISafeAny, ev) => {
      if (isDev()) {
        console.log('收到 Source', ev.source);
      }
      this._targetWindow = ev.source as Window;
      this.dataStackWaitingPost.forEach((x) => this._targetWindow!.postMessage(x));
      this.dataStackWaitingPost.length = 0;
    });

    if (targetWindow) {
      this._targetWindow = targetWindow;
    } else {
      this.postMessage(EInternalMessageType.RequestSource, null as ISafeAny);
    }
  }

  /**
   * 向对方窗口发送消息
   * @param type
   * @param data
   */
  public postMessage<T extends keyof TypeToData>(type: T | EInternalMessageType, data: TypeToData[T]) {
    if (!this._targetWindow) {
      // 进来的时候有可能还没获取对方 window 的引用,先将要发送的数据存放起来
      this.dataStackWaitingPost.push({ type, data });
    } else {
      this._targetWindow.postMessage({ type, data });
    }
  }

  public addMessageListener<Type extends keyof TypeToData>(type: Type, listener: (data: TypeToData[Type], e: MessageEvent) => void) {
    let listeners = this._typeToListeners.get(type);
    if (!listeners) {
      this._typeToListeners.set(type, (listeners = new Set()));
    }

    listeners.add(listener);
    return () => {
      this._typeToListeners.get(type)?.delete(listener);
    };
  }

  public destroy() {
    return this._subs.unsubscribe();
  }

  /**
   * 激活对方窗口(有些浏览器不行,有的浏览器父窗口能激活子窗口),不能保证成功
   */
  public focusTarget() {
    this._targetWindow?.focus();
    return this;
  }
}

export class ChanelOpener<TypeToData extends ITypeToData> extends ChanelPoint<TypeToData> {
  constructor(childUrl: string) {
    let childWindow: Window | null = null;
    if (!isWindowReloaded()) {
      // 如果不是刷新后的页面那么直接打开就可以了,如果是刷新的话需要发送一段请求子页面的引用消息
      childWindow = window.open(childUrl);
      if (childWindow === null) {
        throw new Error('打开子窗口失败,可能是没有赋予浏览器权限');
      }
    }

    super(childWindow);

    this.onWindowClose = () => {
      if (!this._targetWindow?.closed) {
        this._targetWindow?.close();
      }
    };

    this.onWindowRefresh = () => {
      this.postMessage(EInternalMessageType.RequestSource, null as ISafeAny);
    };
  }
}

export class ChanelChild<TypeToData extends ITypeToData> extends ChanelPoint<TypeToData> {
  static instance: ChanelChild<ITypeToData> | null = null;
  static get<T extends ITypeToData>() {
    if (!ChanelChild.instance) {
      ChanelChild.instance = new ChanelChild<T>();
    }

    return ChanelChild.instance as ChanelChild<T>;
  }

  private constructor() {
    const openerWindow = window.opener;
    if (!openerWindow) {
      throw new Error('实例化 ChanelChild 失败,找不到父窗口引用');
    }

    super(openerWindow);

    this.onWindowClose = () => {
      if (!openerWindow.closed) {
        openerWindow.close();
      }
    };
  }
}

/**
 * 获取页面导航类型,可以用于判断页面是否是刷新的
 * @returns
 */
export function getNavigationType() {
  const entries = performance.getEntriesByType('navigation');
  if (entries.length > 0) {
    const navigationEntry = entries[0] as PerformanceNavigationTiming;
    return navigationEntry.type;
  }
  console.log('无法获取页面导航信息');
  return null;
}

/**
 * 判断页面是否是刷新的
 * @returns
 */
export function isWindowReloaded() {
  return getNavigationType() === 'reload';
}