前端基于 localstorage 的跨标签通信

214 阅读2分钟

即上一次分享的前端基于 postMessage 跨标签页通信以后业务又有了变化,现在业务要求弹窗到副屏,但是搜了很多资料以目前的前端水平可能还无法实现。于是就提出弹窗只弹一个,然后由客户手动拉弹窗到副屏,当点开不同的详情页以后,弹窗内容跟着发生变化。技术上原本的基于 window.postMessage 无法实现这样的效果(postMessage适用于1对1,这次是多对1),于是借助 localstorage 重新封装了一个,实现接口与之前的差不多:

/* eslint-disable @typescript-eslint/no-explicit-any */
export type ITypeToData = Record<any, any>;

export interface IChanelPoint<TypeToData extends ITypeToData> {
  postMessage<T extends keyof TypeToData>(type: T, data: TypeToData[T]): void;
  addMessageListener<Type extends keyof TypeToData>(type: Type, listener: (data: TypeToData[Type]) => void): () => void;
  destroy(): void;
  // focusTarget(): void;
}

export enum EChanelType {
  Id = 'Id',
  List = 'List'
}

export interface IChanelTypeToData {
  [EChanelType.Id]: number;
  [EChanelType.List]: {
    id: number;
    data: number[];
  };
}
import { isDev } from '@utils/index';
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { fromEvent } from 'rxjs';
/* eslint-disable @typescript-eslint/no-unused-vars */
import { IChanelPoint, ITypeToData } from './types';

interface IChanelPointOptions {
  /**
   * @default false
   */
  rePostOnWindowFocus?: boolean;
}

export class ChanelPoint<TypeToData extends ITypeToData> implements IChanelPoint<TypeToData> {
  private typeToListeners = new Map<keyof TypeToData, Set<(data: any) => void>>();
  destroy: () => void;
  private history: ITypeToData;

  constructor(options: IChanelPointOptions = {}) {
    const { rePostOnWindowFocus = false } = options;

    const subs = fromEvent<StorageEvent>(window, 'storage').subscribe((ev) => {
      const { key: normalizedType, newValue: normalizedData } = ev;
      const type = this.deNormalizeType(String(normalizedType));
      const data = this.deNormalizeData(String(normalizedData));

      if (isDev()) {
        console.log('<--- 接收', type, data);
      }

      const listeners = this.typeToListeners.get(type);
      if (listeners) {
        listeners.forEach((x) => x(data));
      }

      if (!listeners) {
        if (isDev()) {
          console.log('没有处理函数', type);
        }
      }
    });
    this.destroy = () => subs.unsubscribe();

    this.history = {};

    if (rePostOnWindowFocus) {
      window.addEventListener('focus', this.rePost);
      this.destroy = compose(this.destroy, () => {
        window.removeEventListener('focus', this.rePost);
      });
    }
  }

  private rePost = () => {
    Object.entries(this.history).forEach(([type, data]) => {
      this.postMessage(type, data);
    });
  };

  postMessage<T extends keyof TypeToData>(type: T, data: TypeToData[T]): void {
    if (isDev()) {
      console.log('---> 发送', type, data);
    }

    this.history[type] = data;
    localStorage.setItem(this.normalizeType(type), this.normalizeData(data));
  }
  addMessageListener<Type extends keyof TypeToData>(type: Type, listener: (data: TypeToData[Type]) => void): () => void {
    let listeners = this.typeToListeners.get(type);
    if (!listeners) {
      this.typeToListeners.set(type, (listeners = new Set()));
    }

    listeners.add(listener);

    // 如果已经存有数据,那么读取已经存在的数据
    // const existingJsonString = localStorage.getItem(this.#normalizeType(type));
    // if (existingJsonString) {
    //   Promise.resolve().then(() =>
    //     listener(this.#deNormalizeData(existingJsonString))
    //   );
    // }
    return () => {
      this.typeToListeners.get(type)?.delete(listener);
    };
  }

  private normalizeType<T extends keyof TypeToData>(type: T) {
    return `${ChanelPoint.name}_${String(type)}`;
  }

  private deNormalizeType(normalizedType: string) {
    return normalizedType.replace(`${ChanelPoint.name}_`, '');
  }

  private normalizeData<T>(data: T): string {
    return JSON.stringify({
      key: Math.random().toString(36).slice(0, 10),
      data
    });
  }

  private deNormalizeData(data: string) {
    return JSON.parse(data)?.data ?? null;
  }
}

enum InternalType {
  HelloChild = 'HelloChild',
  HelloOpener = 'HelloOpener'
}

export class OpenerChanel<TypeToData extends ITypeToData> extends ChanelPoint<TypeToData> {
  constructor() {
    super({
      rePostOnWindowFocus: true
    });
  }

  async connect(timeout: number) {
    return new Promise((resolve, reject) => {
      // @ts-ignore
      this.postMessage(InternalType.HelloChild, null);
      const unListen = this.addMessageListener(InternalType.HelloOpener, () => {
        unListen();
        resolve(null);
      });
      setTimeout(() => reject('连接超时'), timeout);
    });
  }
}

export class ChildChanel<TypeToData extends ITypeToData> extends ChanelPoint<TypeToData> {
  constructor() {
    super();

    // @ts-ignore
    this.postMessage(InternalType.HelloOpener, null);

    this.addMessageListener(InternalType.HelloChild, () => {
      // @ts-ignore
      this.postMessage(InternalType.HelloOpener, null);
    });
  }
}

function compose(...fns: ((...args: any[]) => any)[]) {
  if (fns.length <= 1) {
    return fns[0];
  }

  return fns.reduce(
    (a, b) =>
      (...args: any[]) =>
        a(b(...args))
  );
}