从跨项目预览到分层架构:一次 `postMessage` 封装的深度思考

如何设计一个可靠、可扩展的跨窗口通信方案,并从中领悟分层思想

写在前面

最近在一个管理后台项目中,我需要实现一个"跨项目预览"功能:点击后台的一条数据,在一个新窗口中打开另一个独立前端项目的预览页面,并将完整的数据传递过去。两个项目部署在不同域名下,无法共享状态,也不能简单依赖 URL 参数(数据可能很大、结构复杂)。显然,浏览器的 postMessage 是唯一的选择。

然而,当我看到同事写的那套封装代码时,第一反应是:为什么拆这么多层?那个 CrossProjectPreviewBridge 组件是不是有点多余? 带着这些疑问,我深入阅读了代码,并和同事进行了一番讨论。最终,我不只理解了这套设计,更对"分层思想"有了全新的体会。

今天,我想通过这篇文章,还原我的思考过程,分享这套跨项目预览封装的核心设计,以及我从中领悟到的分层架构思维。希望能给正在苦恼"如何写出好代码"的你一些启发。


一、需求场景与基本方案

1.1 需求描述

  • 管理系统(hy-admin)有一个病例列表,点击某一行,需要打开预览项目(medexplore-web)的一个页面,展示该病例的详细报告。
  • 预览项目独立部署(不同域名),无法共享 session 或 cookie。
  • 传递的数据需要包含病例基本信息 + 后端接口返回的详情(可能很大)。

1.2 技术选型:postMessage

浏览器提供的 window.postMessage 方法可以安全地实现跨源通信。父窗口打开子窗口后,通过 postMessage 发送数据,子窗口监听 message 事件接收数据。

基本流程:

复制代码
父窗口                          子窗口
  |                              |
  |-- window.open -------------->|
  |                              |
  |-- postMessage(PREVIEW_DATA)->|
  |                              |-- 接收数据,渲染页面
  |<-- postMessage(READY) -------|

但直接使用原生 API 会遇到几个棘手的问题:

  • 时序问题:子窗口加载需要时间,可能错过父窗口发送的第一条消息。
  • 可靠性:网络延迟或页面刷新可能导致通信失败。
  • 识别问题:多个预览窗口同时打开,消息会相互串扰。
  • 用户体验:异步加载数据时,弹窗可能被浏览器拦截。

同事给出的那套封装,正是为了解决这些问题而设计的。


二、原有封装的逐层剖析

先来看一下整体代码结构(我已做简化,保留核心逻辑):

typescript 复制代码
// 1. 业务调用层
const previewClinic = createCrossProjectPreviewHandler(previewBridgeRef, {
  path: "/clinicPreview?preview=true",
  meta: { resource: "clinic", biz: "case" },
  buildPayload: async row => {
    const { data } = await getClinicCaseDetail({ id: row.id });
    return { ...row, ...data };
  }
});

// 2. 配置适配器
const createCrossProjectPreviewHandler = (previewBridgeRef, config) => {
  return async (row) => {
    const baseUrl = import.meta.env.VITE_FRONTEND_URL;
    const url = joinPreviewUrl(baseUrl, config.path);
    const meta = resolveValue(config.meta, row);
    await previewBridgeRef.value?.open({ url, meta, buildPayload: () => config.buildPayload(row) });
  };
};

// 3. 桥接组件(Vue)
const CrossProjectPreviewBridge = {
  setup() {
    const open = async (options) => {
      try {
        return await openCrossProjectPreview(options);
      } catch (e) {
        ElMessage.error(e.message);
      }
    };
    return { open };
  }
};

// 4. 核心通信函数
const openCrossProjectPreview = async (options) => {
  const { url, buildPayload, meta } = options;
  const channelId = genChannelId();
  const previewWindow = window.open(`${url}?channel=${channelId}`);
  // 监听 message,等待子窗口 ACK
  // 异步获取 payload
  const payload = await buildPayload();
  // 定时重发 PING + PAYLOAD,直到收到 ACK 或超时
  // ...
};

2.1 第一层:业务配置层 (createCrossProjectPreviewHandler)

这一层的作用是将业务特定的配置(路径、元信息、数据获取方式)转换成统一的预览函数。不同的业务模块(诊所、切片、教学)可以复用同一套逻辑,只需传入不同的配置。

typescript 复制代码
const previewClinic = createCrossProjectPreviewHandler(...);
const previewSlice = createCrossProjectPreviewHandler(...); // 另一个业务

2.2 第二层:桥接组件 (CrossProjectPreviewBridge)

一开始我确实觉得这一层多余------它只是简单调用了 openCrossProjectPreview 并捕获错误弹窗。但仔细思考后发现,它解决了两个问题:

  • 统一 UI 反馈 :核心通信函数是纯逻辑,不应该依赖任何 UI 框架(Element Plus 的 ElMessage)。桥接组件承担了"将底层错误转换为用户可读提示"的职责。
  • 适配 Vue 组件化 :通过 ref 暴露 open 方法,让父组件可以在模板中方便地调用,同时可以利用 Vue 的生命周期管理资源。

如果没有这一层,业务代码里会到处重复 try...catchElMessage.error

2.3 第三层:核心通信函数 (openCrossProjectPreview)

这是最精彩的部分。它解决了前面提到的所有痛点:

  • 唯一会话标识 channelId:每个预览窗口生成随机 ID,通过 URL 参数传递给子窗口,所有消息携带该 ID,彻底避免串扰。
  • 数据预加载 :先打开窗口,再 await buildPayload(),确保弹窗由用户点击直接触发(避免拦截),同时拿到最新数据。
  • 定时重发 + 确认机制 :子窗口可能还在加载,监听器未就绪。父窗口每隔 300ms 发送一次 PING + PAYLOAD,直到收到子窗口的 ACK 或超时(12 秒)。这大大提高了通信成功率。
  • 窗口关闭检测 :定时器内检查 previewWindow.closed,及时清理资源。

三、我的困惑:为什么要拆这么多层?

刚开始阅读代码时,我的内心活动是:

"这个 CrossProjectPreviewBridge 明明就是调用了一下 openCrossProjectPreview,然后把错误弹了个框,至于单独做一个组件吗?直接在 createCrossProjectPreviewHandler 里调用 openCrossProjectPreviewcatch 不就行了?"

我甚至想动手重构,把这三层合并成两层。但同事的一番话点醒了我:

"如果明天我们把 Element Plus 换成 Ant Design Vue,你需要改几个文件?"

  • 如果错误处理写在 createCrossProjectPreviewHandler 里,那么所有业务模块都会受影响,得改那个文件。
  • 如果错误处理在桥接组件里,我们只需要替换桥接组件中的 ElMessage 为新的 UI 库提示方法,核心通信函数和业务配置层完全不用动。

这就是分层的核心价值:隔离变化

我这才意识到,之前我评判代码"是否多余"的标准是"代码行数最少",而真正的标准应该是"每个模块的职责单一,并且依赖方向稳定"。

  • 核心通信层:不依赖任何框架、UI,只做 postMessage 相关的事。
  • 桥接层:依赖 Vue 和 Element Plus,但只做"调用核心层 + UI 反馈"。
  • 业务配置层:依赖桥接层,但只做业务参数转换。

当需求变化时(换 UI 库、修改预览地址规则、增加缓存),只需要改动相应的层即可。


四、从困惑到领悟:如何培养分层思维?

这次经历让我意识到,我缺乏的不是写代码的能力,而是架构分层的感觉。于是我开始刻意练习,总结出以下几点方法:

4.1 练习一:提取纯函数

从业务代码中找出那些不依赖 DOM、不依赖框架、不依赖全局状态的逻辑,抽成独立的工具函数。例如,URL 拼接、时间格式化、数据校验等。

typescript 复制代码
// 坏习惯:写在组件里
const fullUrl = baseUrl + (baseUrl.endsWith('/') ? '' : '/') + path;

// 好习惯:抽成函数
function joinUrl(base: string, path: string): string { ... }

4.2 练习二:画依赖关系图

在写代码之前,用纸笔或绘图工具画出模块之间的依赖箭头。箭头应该从高层指向低层,低层绝不能知道高层的存在

复制代码
[业务组件] --> [预览Handler] --> [Bridge组件] --> [核心通信函数]

如果核心通信函数里直接调用了 ElMessage,那箭头就反了(低层依赖了高层),这就是坏味道。

4.3 练习三:为每个模块写一句职责描述

  • 核心通信函数:负责跨窗口消息的发送、重试、超时、资源管理(不涉及 UI)。
  • 桥接组件:适配 Vue 的调用方式,并提供用户友好的错误提示
  • 业务配置层:将业务参数转换为统一格式,调用桥接层

如果一句话里出现了"并且"、"同时",说明这个模块可能承担了多个职责,应该拆分。

4.4 练习四:模拟需求变更

定期问自己:"如果老板要求......,我需要改哪些代码?"

  • "把错误弹窗改成通知条?" → 只改桥接组件。
  • "预览地址从环境变量改成接口获取?" → 只改业务配置层。
  • "增加消息加密?" → 只改核心通信层。

如果一个需求变更导致你修改了很多层,说明分层还不够合理。


五、企业级改进:让 postMessage 通信更健壮

在理解了原有封装的基础上,我和同事进一步探讨了如何把它打造成真正可用于生产环境的企业级方案。最终我们设计了一个更完整的 PostMessageClient 类,下面分享几个关键改进点。

5.1 请求-响应模式

原有的设计是父窗口主动推送,子窗口被动接收。但实际业务中,子窗口也可能需要向父窗口请求数据(例如获取用户权限)。我们增加了 request 方法,支持自动匹配请求和响应。

typescript 复制代码
// 发送请求并等待响应
const result = await client.request('GET_USER_INFO', { userId: 123 });
// 接收端注册处理器
client.onRequest('GET_USER_INFO', async (payload) => {
  return await fetchUser(payload.userId);
});

5.2 超时与重试

每个请求可以单独配置超时时间和重试次数,避免网络问题导致界面卡死。

typescript 复制代码
client.request('PREVIEW_DATA', data, { timeout: 8000, retries: 2 });

5.3 心跳保活

对于需要长时间保持连接的场景(比如协同编辑),增加心跳机制。父窗口定期发送 PING,子窗口回复 PONG,一方长时间未收到心跳则主动清理连接。

5.4 源安全校验

生产环境必须限制 allowedOrigins,防止恶意网站伪造消息。

typescript 复制代码
const client = new PostMessageClient('admin', {
  allowedOrigins: ['https://preview.mycompany.com']
});

5.5 完善的资源清理

destroy 方法移除所有事件监听、清除定时器、清空待处理队列,避免内存泄漏。

5.6 框架适配器

核心类与框架无关,但提供 React Hook 和 Vue Composable 的封装,方便在组件中使用。

抱歉,是我疏忽了!我在博客中写了"完整代码已在文章中给出",但实际上只写了部分片段,没有附上完整的可运行代码。现在我把完整的企业级 postMessage 封装代码(包含详细注释)重新整理如下,你可以直接复制使用。


完整代码文件

1. 消息协议定义 types.ts

typescript 复制代码
/**
 * 跨窗口通信消息协议定义
 * 保证通信双方数据结构一致
 */

/** 基础消息结构 */
export interface BaseMessage {
  id: string;           // 唯一消息 ID,用于请求-响应匹配
  type: string;         // 消息类型,如 'PREVIEW_DATA', 'PING', 'PONG'
  timestamp: number;    // 发送时间戳
  source: string;       // 发送方标识,如 'admin', 'preview'
  target?: string;      // 目标应用(可选,为空表示广播)
  payload?: any;        // 携带的数据
}

/** 请求消息(需要响应) */
export interface RequestMessage<T = any> extends BaseMessage {
  expectResponse: true;
  payload: T;
}

/** 响应消息 */
export interface ResponseMessage<T = any> extends BaseMessage {
  inReplyTo: string;    // 对应的请求消息 ID
  success: boolean;
  payload: T;
  error?: string;
}

/** 心跳消息 */
export interface PingMessage extends BaseMessage {
  type: 'PING';
}
export interface PongMessage extends BaseMessage {
  type: 'PONG';
}

/** 消息类型常量 */
export const MessageTypes = {
  PING: 'PING',
  PONG: 'PONG',
  PREVIEW_DATA: 'PREVIEW_DATA',
  READY: 'READY',
  ACK: 'ACK',
} as const;

2. 核心通信类 PostMessageClient.ts

typescript 复制代码
/**
 * PostMessageClient
 * 封装所有跨窗口通信细节:请求-响应、超时重试、心跳、安全校验、资源清理
 */

import { BaseMessage, RequestMessage, ResponseMessage, PingMessage, PongMessage, MessageTypes } from './types';

export interface PostMessageClientOptions {
  allowedOrigins?: string | string[];    // 允许的源,生产环境必填
  timeoutMs?: number;                    // 默认超时,10000ms
  retryCount?: number;                   // 重试次数(不含首次),默认2
  enableHeartbeat?: boolean;             // 是否开启心跳,默认false
  heartbeatIntervalMs?: number;          // 心跳间隔,默认30000ms
  logLevel?: 'none' | 'error' | 'warn' | 'info' | 'debug';
  targetWindow?: Window | null;          // 预设目标窗口
  targetOrigin?: string;                 // 预设目标源
}

interface PendingRequest {
  resolve: (value: any) => void;
  reject: (reason?: any) => void;
  timeoutId: number;
  retriesLeft: number;
}

export class PostMessageClient {
  private targetWindow: Window | null = null;
  private targetOrigin = '*';
  private allowedOrigins: Set<string>;
  private timeoutMs: number;
  private retryCount: number;
  private enableHeartbeat: boolean;
  private heartbeatIntervalMs: number;
  private logLevel: PostMessageClientOptions['logLevel'];
  private pendingRequests = new Map<string, PendingRequest>();
  private heartbeatTimer: number | null = null;
  private messageHandler: ((event: MessageEvent) => void) | null = null;
  private isDestroyed = false;
  private sourceName: string;

  constructor(sourceName: string, options: PostMessageClientOptions = {}) {
    this.sourceName = sourceName;
    const {
      allowedOrigins = '*',
      timeoutMs = 10000,
      retryCount = 2,
      enableHeartbeat = false,
      heartbeatIntervalMs = 30000,
      logLevel = 'error',
      targetWindow = null,
      targetOrigin = '*',
    } = options;

    this.targetWindow = targetWindow;
    this.targetOrigin = targetOrigin;
    this.allowedOrigins = typeof allowedOrigins === 'string' ? new Set([allowedOrigins]) : new Set(allowedOrigins);
    this.timeoutMs = timeoutMs;
    this.retryCount = retryCount;
    this.enableHeartbeat = enableHeartbeat;
    this.heartbeatIntervalMs = heartbeatIntervalMs;
    this.logLevel = logLevel;
    this.init();
  }

  private log(level: 'error' | 'warn' | 'info' | 'debug', ...args: any[]) {
    if (this.logLevel === 'none') return;
    const levels = { error: 1, warn: 2, info: 3, debug: 4 };
    if (levels[level] <= levels[this.logLevel!]) {
      console[level](`[PostMessageClient:${this.sourceName}]`, ...args);
    }
  }

  private init() {
    this.messageHandler = this.handleMessage.bind(this);
    window.addEventListener('message', this.messageHandler);
    if (this.enableHeartbeat && this.targetWindow) {
      this.startHeartbeat();
    }
  }

  private handleMessage(event: MessageEvent) {
    if (this.isDestroyed) return;

    // 源白名单校验
    if (!this.allowedOrigins.has('*') && !this.allowedOrigins.has(event.origin)) {
      this.log('warn', `Ignored message from ${event.origin}`);
      return;
    }

    const msg = event.data as BaseMessage;
    if (!msg || typeof msg !== 'object') return;
    if (msg.source === this.sourceName) return; // 自己的消息忽略

    // 响应消息处理
    if (this.isResponseMessage(msg)) {
      const pending = this.pendingRequests.get(msg.inReplyTo);
      if (pending) {
        clearTimeout(pending.timeoutId);
        this.pendingRequests.delete(msg.inReplyTo);
        if (msg.success) {
          pending.resolve(msg.payload);
        } else {
          pending.reject(new Error(msg.error || 'Request failed'));
        }
      }
      return;
    }

    // 心跳处理
    if (msg.type === MessageTypes.PING) {
      this.sendPong(event.source as Window, event.origin);
      return;
    }
    if (msg.type === MessageTypes.PONG) {
      this.log('debug', 'Received PONG');
      return;
    }

    // 其他消息派发给业务监听器
    this.emit('message', msg, event);
  }

  private isResponseMessage(msg: any): msg is ResponseMessage {
    return msg && typeof msg.inReplyTo === 'string' && typeof msg.success === 'boolean';
  }

  private sendPong(targetWindow: Window, targetOrigin: string) {
    const pong: PongMessage = {
      id: this.generateId(),
      type: MessageTypes.PONG,
      timestamp: Date.now(),
      source: this.sourceName,
    };
    targetWindow.postMessage(pong, targetOrigin);
  }

  private startHeartbeat() {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
    this.heartbeatTimer = window.setInterval(() => {
      if (!this.targetWindow || this.targetWindow.closed) {
        this.log('warn', 'Target window closed, stopping heartbeat');
        this.stopHeartbeat();
        return;
      }
      const ping: PingMessage = {
        id: this.generateId(),
        type: MessageTypes.PING,
        timestamp: Date.now(),
        source: this.sourceName,
      };
      this.targetWindow!.postMessage(ping, this.targetOrigin);
    }, this.heartbeatIntervalMs);
  }

  private stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
  }

  /** 设置目标窗口 */
  public setTargetWindow(win: Window | null, targetOrigin?: string) {
    this.targetWindow = win;
    if (targetOrigin) this.targetOrigin = targetOrigin;
    if (this.enableHeartbeat) {
      this.stopHeartbeat();
      this.startHeartbeat();
    }
  }

  /** 发送请求(期待响应),支持超时和重试 */
  async request<TReq, TRes>(
    type: string,
    payload: TReq,
    options?: { timeout?: number; retries?: number }
  ): Promise<TRes> {
    if (this.isDestroyed) throw new Error('Client destroyed');
    if (!this.targetWindow || this.targetWindow.closed) {
      throw new Error('Target window not available');
    }

    const id = this.generateId();
    const timeout = options?.timeout ?? this.timeoutMs;
    let retriesLeft = options?.retries ?? this.retryCount;

    const attempt = (): Promise<TRes> => {
      return new Promise((resolve, reject) => {
        const timeoutId = window.setTimeout(() => {
          if (this.pendingRequests.has(id)) {
            this.pendingRequests.delete(id);
            if (retriesLeft > 0) {
              retriesLeft--;
              this.log('warn', `Request ${type} timeout, retries left: ${retriesLeft}`);
              attempt().then(resolve).catch(reject);
            } else {
              reject(new Error(`Request ${type} timeout after retries`));
            }
          }
        }, timeout);

        this.pendingRequests.set(id, { resolve, reject, timeoutId, retriesLeft });
        const requestMsg: RequestMessage<TReq> = {
          id,
          type,
          timestamp: Date.now(),
          source: this.sourceName,
          expectResponse: true,
          payload,
        };
        this.targetWindow!.postMessage(requestMsg, this.targetOrigin);
        this.log('debug', `Sent request ${type} id=${id}`);
      });
    };
    return attempt();
  }

  /** 发送单向通知(不期待响应) */
  notify<T>(type: string, payload?: T) {
    if (this.isDestroyed) return;
    if (!this.targetWindow || this.targetWindow.closed) return;
    const msg: BaseMessage = {
      id: this.generateId(),
      type,
      timestamp: Date.now(),
      source: this.sourceName,
      payload,
    };
    this.targetWindow.postMessage(msg, this.targetOrigin);
    this.log('debug', `Sent notification ${type}`);
  }

  // 事件系统
  private listeners = new Map<string, Set<Function>>();
  private on(event: 'message', handler: (msg: BaseMessage, event: MessageEvent) => void) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(handler);
  }
  private off(event: 'message', handler: Function) {
    this.listeners.get(event)?.delete(handler);
  }
  private emit(event: 'message', msg: BaseMessage, rawEvent: MessageEvent) {
    this.listeners.get(event)?.forEach(handler => handler(msg, rawEvent));
  }

  /** 监听特定类型的消息(接收端使用) */
  onMessage(type: string, handler: (payload: any, event: MessageEvent) => void) {
    const wrapped = (msg: BaseMessage, event: MessageEvent) => {
      if (msg.type === type) handler(msg.payload, event);
    };
    this.on('message', wrapped);
    return () => this.off('message', wrapped);
  }

  /** 处理请求(接收端注册处理器,自动响应) */
  onRequest<TReq, TRes>(
    type: string,
    handler: (payload: TReq, event: MessageEvent) => Promise<TRes> | TRes
  ) {
    const wrapped = async (msg: BaseMessage, event: MessageEvent) => {
      if (msg.type === type && (msg as RequestMessage).expectResponse === true) {
        try {
          const result = await handler(msg.payload, event);
          const response: ResponseMessage = {
            id: this.generateId(),
            type: `${type}_RESPONSE`,
            timestamp: Date.now(),
            source: this.sourceName,
            inReplyTo: msg.id,
            success: true,
            payload: result,
          };
          (event.source as Window).postMessage(response, event.origin);
        } catch (err: any) {
          const response: ResponseMessage = {
            id: this.generateId(),
            type: `${type}_RESPONSE`,
            timestamp: Date.now(),
            source: this.sourceName,
            inReplyTo: msg.id,
            success: false,
            payload: null,
            error: err.message,
          };
          (event.source as Window).postMessage(response, event.origin);
        }
      }
    };
    this.on('message', wrapped);
    return () => this.off('message', wrapped);
  }

  /** 销毁客户端,清理所有资源 */
  destroy() {
    this.isDestroyed = true;
    if (this.messageHandler) {
      window.removeEventListener('message', this.messageHandler);
      this.messageHandler = null;
    }
    this.stopHeartbeat();
    this.pendingRequests.forEach(({ timeoutId }) => clearTimeout(timeoutId));
    this.pendingRequests.clear();
    this.listeners.clear();
  }
}

3. React Hook 适配器 useCrossWindowChannel.tsx

tsx 复制代码
import { useRef, useEffect, useCallback } from 'react';
import { PostMessageClient } from './PostMessageClient';
import type { PostMessageClientOptions } from './PostMessageClient';

export function useCrossWindowChannel(sourceName: string, options?: PostMessageClientOptions) {
  const clientRef = useRef<PostMessageClient | null>(null);

  useEffect(() => {
    const client = new PostMessageClient(sourceName, options);
    clientRef.current = client;
    return () => {
      client.destroy();
      clientRef.current = null;
    };
  }, [sourceName]);

  const setTargetWindow = useCallback((win: Window | null, targetOrigin?: string) => {
    clientRef.current?.setTargetWindow(win, targetOrigin);
  }, []);

  const request = useCallback(<TReq, TRes>(type: string, payload: TReq, opts?: any) => {
    if (!clientRef.current) throw new Error('Client not ready');
    return clientRef.current.request<TReq, TRes>(type, payload, opts);
  }, []);

  const notify = useCallback(<T>(type: string, payload?: T) => {
    clientRef.current?.notify(type, payload);
  }, []);

  const onMessage = useCallback((type: string, handler: (payload: any, event: MessageEvent) => void) => {
    if (!clientRef.current) return () => {};
    return clientRef.current.onMessage(type, handler);
  }, []);

  const onRequest = useCallback(<TReq, TRes>(
    type: string,
    handler: (payload: TReq, event: MessageEvent) => Promise<TRes> | TRes
  ) => {
    if (!clientRef.current) return () => {};
    return clientRef.current.onRequest(type, handler);
  }, []);

  return { setTargetWindow, request, notify, onMessage, onRequest };
}

4. Vue Composable 适配器 useCrossWindowChannel.ts (Vue 3)

typescript 复制代码
import { ref, onMounted, onUnmounted } from 'vue';
import { PostMessageClient } from './PostMessageClient';
import type { PostMessageClientOptions } from './PostMessageClient';

export function useCrossWindowChannel(sourceName: string, options?: PostMessageClientOptions) {
  const client = ref<PostMessageClient | null>(null);
  const isReady = ref(false);

  onMounted(() => {
    client.value = new PostMessageClient(sourceName, options);
    isReady.value = true;
  });

  onUnmounted(() => {
    client.value?.destroy();
    client.value = null;
    isReady.value = false;
  });

  const setTargetWindow = (win: Window | null, targetOrigin?: string) => {
    client.value?.setTargetWindow(win, targetOrigin);
  };

  const request = async <TReq, TRes>(type: string, payload: TReq, opts?: any): Promise<TRes> => {
    if (!client.value) throw new Error('Client not ready');
    return client.value.request<TReq, TRes>(type, payload, opts);
  };

  const notify = <T>(type: string, payload?: T) => {
    client.value?.notify(type, payload);
  };

  const onMessage = (type: string, handler: (payload: any, event: MessageEvent) => void) => {
    if (!client.value) return () => {};
    return client.value.onMessage(type, handler);
  };

  const onRequest = <TReq, TRes>(
    type: string,
    handler: (payload: TReq, event: MessageEvent) => Promise<TRes> | TRes
  ) => {
    if (!client.value) return () => {};
    return client.value.onRequest(type, handler);
  };

  return { isReady, setTargetWindow, request, notify, onMessage, onRequest };
}

5. 使用示例

发送端(管理后台 React)
tsx 复制代码
import React, { useCallback, useRef } from 'react';
import { useCrossWindowChannel } from './useCrossWindowChannel';

export const AdminPanel = () => {
  const previewWindowRef = useRef<Window | null>(null);
  const { setTargetWindow, request, onMessage } = useCrossWindowChannel('admin', {
    allowedOrigins: ['https://preview.example.com'],
    timeoutMs: 15000,
    retryCount: 2,
    enableHeartbeat: true,
    logLevel: 'info',
  });

  const openPreview = useCallback(async (row: any) => {
    const win = window.open('https://preview.example.com/preview', '_blank', 'width=1200,height=800');
    if (!win) return alert('请允许弹窗');
    previewWindowRef.current = win;
    setTargetWindow(win, 'https://preview.example.com');

    // 等待子窗口 ready
    await new Promise<void>((resolve) => {
      const unsub = onMessage('READY', () => {
        unsub();
        resolve();
      });
      setTimeout(() => resolve(), 5000);
    });

    const detail = await fetchDetail(row.id);
    const response = await request('PREVIEW_DATA', detail, { timeout: 10000 });
    console.log('Preview confirmed', response);
  }, [setTargetWindow, request, onMessage]);

  return <button onClick={() => openPreview({ id: 123 })}>预览</button>;
};
接收端(预览页面 React)
tsx 复制代码
import React, { useEffect, useState } from 'react';
import { useCrossWindowChannel } from './useCrossWindowChannel';

export const PreviewPage = () => {
  const [data, setData] = useState(null);
  const { onRequest, notify } = useCrossWindowChannel('preview', {
    allowedOrigins: ['https://admin.example.com'],
    enableHeartbeat: true,
  });

  useEffect(() => {
    notify('READY');
    const unsub = onRequest('PREVIEW_DATA', async (payload) => {
      setData(payload);
      return { received: true, timestamp: Date.now() };
    });
    return unsub;
  }, [onRequest, notify]);

  if (!data) return <div>Loading...</div>;
  return <div>{/* 渲染 preview 内容 */}</div>;
};

以上是完整的可运行代码,你可以根据项目需求选择 React 或 Vue 适配器。


六、总结:分层不是炫技,而是为了应对变化

这次从"觉得多余"到"理解并欣赏"的经历,让我明白了一个道理:

好的分层设计,不是为了好看,而是为了在未来需求变化时,能够用最小的代价、最小的风险去响应。

如果你现在也觉得自己"不会分层",不妨从今天开始,尝试用上面的方法练习。你不需要一下子就设计出完美的架构,只需要每次比上次好一点点。慢慢地,你会发现自己写出的代码越来越清晰、越来越容易维护。

最后,感谢那位写出这套封装的同事,也感谢我自己愿意停下来思考"为什么"。希望这篇博客也能帮助你迈出分层思维的第一步。

相关推荐
问征夫以前路6 小时前
Promise知识点回顾
前端·javascript
hadeas6 小时前
Spring 核心概念深入:IoC 与 AOP
架构
拓荒牛儿7 小时前
前端内存可观测实践
前端
yqcoder7 小时前
异步的魔法:深入解析 async/await 原理与编译本质
前端·javascript
iiiiyu7 小时前
面向对象和集合编程题
java·开发语言·前端·数据结构·算法·编程语言
taocarts_bidfans7 小时前
2026跨境SaaS工具选型指南:Taoify与Shopify/Shopyy/Ueeshop深度对比
java·前端·javascript·跨境电商·独立站
环信7 小时前
环信Flutter UIKit适配鸿蒙实战指南
前端
秋秋20237 小时前
做了个 AI 对话页面才发现,流式渲染没想象中那么简单
前端·aigc
环信7 小时前
HarmonyOS Flutter 键盘高度监听插件开发完全指南
前端