接手一个快十年的老项目,最磨人的不是代码写得有多乱,而是你想往前走一步,却发现脚底下全是胶水。尤其是当团队决定把庞大又陈旧的单体应用,按路由一块块搬到 Next.js 的时候,真正的挑战往往不是“怎么搬”,而是“搬过去之后,怎么跟没搬的那部分好好说话”。

很多团队一开始都爱用 iframe 来过渡。思路很简单:你走你的阳关道,我走我的独木桥,老系统里塞一个 iframe,把新路由的页面嵌进去,或者反过来在新框架里嵌入老页面。这样两边代码互不干扰,看着挺美。可真一跑起来就发现,浏览器同源策略带来的通信墙,能堵得人想骂街。两个窗口之间传个消息,就跟隔着一条街喊话一样,全靠 postMessage 在喊。

可以明确的说,任何大型老项目的迁移,本质都是一场新老两套代码的“异地恋”。而 iframe 和客户端代理(Client-Side Proxy),就是两种截然不同的沟通方式。一个是用“飞鸽传书”,一个是在“同一屋檐下”装了个传声筒。今天就把这里面的坑点,技术原理,还有实际落地时的取舍,掰开了揉碎了讲清楚。技术栈咱们统一用 TypeScript + React 18,这套组合在老项目和新 Next.js 应用里都很常见,示例代码直接可以在本地跑起来。

一、为什么说 iframe 必然带来双向通信的痛楚

1.1 同源策略是绕不过去的“天堑”

在搬迁移项目时,最常见的情况就是老项目部署在 https://legacy.example.com,新项目部署在 https://next.example.com。俩域名不一样,这就是跨域。iframe 一旦跨域,父子窗口之间就彻底失去了直接访问对方 DOM 的能力。

这时候你想让老页面通知新框架“用户点了退出登录”,就得满世界找 window.postMessage。这玩意儿不是不能用,而是用起来极其繁琐,而且容易埋雷。

1.2 postMessage 的基本通信“土办法”

比如老项目里有个退出登录按钮。

// 技术栈:TypeScript + React 18(老项目里的退出按钮组件)

import React from 'react';

// 定义一组消息类型常量,务必跟新项目里的枚举保持一致
export enum LegacyMessageType {
  LOGOUT = 'legacy:logout',
  UPDATE_USER_INFO = 'legacy:updateUserInfo',
  NAVIGATE = 'legacy:navigate',
}

/**
 * 子窗口(被嵌入的 iframe)向父窗口发送消息
 * 这是最简单的一种通信,但很容易出问题
 */
export function sendMessageToParent(payload: Record<string, unknown>) {
  // 这里的 targetOrigin 千万不要写成 '*'
  // 否则你的消息可能会被任何恶意网站监听,这是安全大忌
  const targetOrigin = 'https://next.example.com';

  // 将消息对象序列化后发送给父窗口
  window.parent.postMessage(
    {
      source: 'legacy-app', // 标记消息来源
      type: LegacyMessageType.LOGOUT, // 消息类型
      payload: payload, // 实际传递的数据
    },
    targetOrigin // 指定接收方的源,防止消息泄露
  );
}

// 退出按钮点击后的逻辑
export function handleLogoutButtonClick() {
  // 发送退出登录的消息给父窗口
  sendMessageToParent({ reason: 'user_click_logout' });
}

上面这段代码看着挺规整的,可在实际操作里,坑点一个接一个。父窗口那边接收消息,需要写一个全局监听器。

// 技术栈:TypeScript + Next.js App Router(老系统里嵌入的容器页)

'use client';

import { useEffect, useState } from 'react';

// 定义父窗口接收消息的钩子函数
export function useIframeMessageListener() {
  const [userStatus, setUserStatus] = useState('online');

  useEffect(() => {
    /**
     * 处理来自子 iframe 的消息
     * 第一步:校验来源。
     * 千万别拿 event.origin 当摆设,必须要跟子窗口的真实源进行比对,
     * 否则任何网站都能伪造消息,把你的状态改得乱七八糟。
     */
    const handleMessage = (event: MessageEvent) => {
      // 严格校验消息来源
      if (event.origin !== 'https://legacy.example.com') {
        console.warn('收到来自未知源的消息,已拦截: ', event.origin);
        return;
      }

      // 尝试解析数据,防止子窗口传来非 JSON 对象
      const data = event.data as Partial<{ type: string; payload: any }>;
      if (!data || typeof data.type !== 'string') {
        return; // 不是我们需要的消息,丢之
      }

      // 根据消息类型执行不同操作
      switch (data.type) {
        case 'legacy:logout':
          // 用户退出,更新状态
          setUserStatus('offline');
          // 这里建议再做一次路由跳转,把整个新框架整体跳到登录页
          window.location.href = '/login';
          break;
        default:
          break;
      }
    };

    // 注册消息监听器
    window.addEventListener('message', handleMessage);

    // 组件卸载时移除监听器,防止内存泄漏
    return () => {
      window.removeEventListener('message', handleMessage);
    };
  }, []);

  return { userStatus };
}

这套方案能否跑通?跑得通,但只能算是“勉强能跑”。真正让人崩溃的是,发消息的过程是不可控的。子窗口加载慢半拍,你发过去的消息父窗口没监听到,就丢了。你要是想确认一遍“你听没听到?”得自己再写一套 ACK(确认)机制。要是消息携带的数据量大了,postMessage 性能也扛不住,因为每次都是深拷贝,稍微复杂一点的对象传过去,就能感觉到明显的卡顿。这还只是父子两个窗口之间的通信,如果再来一层嵌套(老系统里面又嵌一个 iframe),那个消息风暴简直能把浏览器搞死。

简单总结一下:在跨域的大背景下,postMessage 是必需品,但它太“原始”了。你在这个基础上要自己实现消息有序送达、重复消息去除、超时重发等等功能,这已经够写一个小型框架了。而这些令人头疼的问题,正是客户端代理(Client-Side Proxy)能解决的痛点。

二、客户端代理:让两个“孤立”的应用住进同一个房间

2.1 代理解决了什么问题

什么叫客户端代理?说白了,就是在新老两套代码之间,拉一条“内线”。它不再依赖于跨窗口的广播,而是在主应用的内存里建立一个消息中枢(Message Bus),所有通信都通过这个中枢转发。这就把原来的“跨窗口远程调用”变成了“同一进程内的函数调用”。

这种方式最大的好处是,你不必再为 iframe 的加载时机去操心,也不用再死磕 postMessage 那些繁琐的 origin 校验。因为通信双方根本不在一个窗口里,而是通过代理对象直接访问对方内存里的方法。

2.2 手写一个简单的代理中间层

在 Next.js 应用里,我们可以在最上层通过创建一个代理类,把老项目需要的方法挂载上去。老项目那边同样只需拿到这个代理的引用,就能像调用本地函数一样操作新项目。

// 技术栈:TypeScript + Next.js App Router(客户端代理核心模块)

'use client';

import { useEffect, useState } from 'react';

/**
 * 一个简单的同步代理客户端
 * 它的任务,是把复杂的窗口监听逻辑封装起来,
 * 让我们在业务代码里只感受“内存调用”。
 */
export class ClientBridge {
  // 保存各类回调函数
  private handlers: Record<string, Function> = {};

  // 被代理的下游方法
  private remoteMethods: Record<string, Function> = {};

  // 在代理中注册一个可被对方调用的处理函数
  on(eventName: string, handler: Function) {
    this.handlers[eventName] = handler;
  }

  // 发起对远端函数的调用
  invoke(methodName: string, ...args: any[]): Promise<any> {
    const fn = this.remoteMethods[methodName];
    if (fn) {
      return Promise.resolve(fn(...args));
    }
    return Promise.reject(new Error(`远端方法 ${methodName} 不存在`));
  }

  // 相当于旧开发模式中的 destroy
  destroy() {
    this.handlers = {};
    this.remoteMethods = {};
  }
}

// 全局唯一的代理实例
export const bridge = new ClientBridge();

// React 组件中使用代理的示例
export function useBridgeProxyConnector() {
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    // 假设这是老项目对外暴露的某个方法
    // 我们可以事先把老方法挂载到代理里
    bridge.on('legacy:getUserName', () => {
      return '嵌入式老程序的用户';
    });

    // 模拟连接成功
    setIsConnected(true);

    // 清理时移出监听
    return () => {
      bridge.destroy();
    };
  }, []);

  return { isConnected };
}

看似很简单对吧?你现在要老项目里任何一处单独调用 bridge 上的方法,顺序调通,就完成了通信。关键是,这个调用过程是同步返回结果(内部是异步 Promise 包装),不存在消息丢失问题,也不存在跨域限制。

但这里有一个很重要的思维转换:你别把它想成“淘宝和微信”之间的相互操作,而要把它想成“淘宝 App 内的商品卡片调起支付宝支付页面”。因为已经“住在”了同一个主应用进程里,所以通信代价极低,这才能实现真正意义上的“双向联动”。

2.3 改造一个真实的调用场景

来看一个更实际的例子。老系统里有一个用户列表,点击某个用户时,需要调用新系统里的一个“用户详情抽屉”组件。传统的 iframe 方案需要通知父窗口打开弹窗,再把用户 ID 传过去。用代理方式,就是直接调用。

// 技术栈:TypeScript + React 18(老项目内直接调用新框架的能力)

import { bridge } from './bridgeClient';

// 老系统用户在表格中点击某一行
function handleUserRowClick(userId: string) {

  // 直接调用代理上挂载的“显示用户详情抽屉”方法
  // 这里返回的是一个 Promise,但用法上跟调用本地函数完全一致
  bridge
    .invoke('showNewUserDrawer', { id: userId })
    .then((res) => {
      console.log('新系统抽屉反馈:', res);
    })
    .catch((err) => {
      // 万一新系统那边还没准备就绪,就做个降级处理
      console.error('打开新系统详情失败,回退到老系统详情页', err);
      window.location.href = `/legacyUserDetail?id=${userId}`;
    });
}

再看新系统里如何响应这个“调用”。

// 技术栈:TypeScript + Next.js App Router(新系统暴露接口给老系统调用)

'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { bridge } from '@/core/bridgeClient';

export default function NewSystemProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const router = useRouter();

  useEffect(() => {
    /**
     * 关键点:注册一个可被老系统调用的方法。
     * 它相当于在企业微信里注册一个“回调网关”,
     * 所有来自外部的调用最终都会匹配到这儿。
     */
    bridge.on('showNewUserDrawer', async ({ id }: { id: string }) => {
      // 这里可以做任何新系统的逻辑,比如打开弹窗
      console.log('[NewSystem] 准备打开用户抽屉: ', id);

      // 实际开发里,这里会触发一个 Antd Drawer 的 state 展示
      // 为了简化示例,这里直接跳转到新路由
      router.push(`/user/${id}`);
      return { status: 'opened', success: true };
    });
  }, [router]);

  return <div>{children}</div>;
}

这个例子一下就清晰了:老代码里的一个函数调用,通过代理,直接变成新系统里的一个执行动作,而且还能原样接收返回值。这两个模块在物理上不在同一个项目里,但在“业务用的视图”里,他们是无缝衔接的。

三、两种方案的实际应用场景和优缺点对比

3.1 常见应用场景

先说说 iframe 最典型的应用场景,就是“老系统内容本质上属于外部系统,完全没有改造意愿”。比如你要在老系统里嵌入一个第三方报表,这个报表是用纯 jQuery 写的,你不可能对它做技术栈改造,那就直接 <iframe> 展示。这里的通信需求极少,最多就是传一个用户ID进去。

再比如,你们团队在新系统还未完全开发完的时候,用 iframe 临时把“还没搬过来的老页面”全部包在一个壳里。这种情况下,iframe 就是一个“过渡帐篷”,里面塞满了旧世界的东西,通信需求几乎为零。

客户端代理的应用场景就不一样了。它是为了“有真正的业务交互需求”而生的。比如在老系统的一个订单处理页面里,需要实时读取新系统的“库存动态信息”,并同步更新到当前列表里。或者反过来,新系统的导航栏上要显示老系统那边的最新“未处理工单数”。这种场景下,你用 postMessage 来做,光是针对“数据刷新频率”都能把你调崩溃,只有内存级的代理才能保证流畅、及时、安全。

3.2 各自的优缺点分析

  • iframe 通信的优缺点:

    • 优点:简单粗暴,不需要改造老代码的架构,只需要一个容器外壳。作为一个临时的页面隔离方案,它能“快速止血”。
    • 缺点:通信成本高,消息机制不稳定,UI 样式互相隔离很难统一,iframe 自身页面渲染很重,每嵌入一个 iframe 都相当于多开了一个完整浏览器页面,内存占用直接翻几倍。
    • 隐藏深坑:在移动端,iframe 的滚动和触摸事件会有一堆兼容性怪癖,经常出现页面卡住、滚动条消失的诡异问题,排查起来特别痛苦。
  • 客户端代理的优缺点:

    • 优点:调用速度快,代码语义清晰,可以实现同步的请求/响应模式,适合高频次的业务操作和复杂的数据交互。
    • 缺点:需要两边同时配合改造,一旦代理丢失或者加载顺序有问题,就全盘报错。而且对老项目的前端代码有侵入性,你免不了要在老项目里引入一套公共依赖包,这对某些老项目管理来说,可能是一种“技术债”。

3.3 注意事项:安全与边界问题

在深度对比时,一个特别容易被忽略的坑就是“信任边界”。

postMessage 里面,你每次都要校验 event.origin,证明“你是你”,这其实是一种防御性编程。虽然繁琐,但它是浏览器提供的安全底线。而如果你的代理方案是写在一个公共 npm 包里的,那你必须保证这个包不会被恶意网站加载。一旦你的代理挂在 window 上,被其他人直接调用,那就相当于给外部开了一扇大门。

所以做好以下三点保护措施:

  1. 代理对象不要直接挂载到 window 的全局命名空间下,尽量通过模块化引入,以免被外部脚本直接访问。
  2. 每次 invoke 调用都要带上来源标识,类似于一个动态生成的 token,对方校验通过后才执行。
  3. 控制暴露的方法权限,不要一股脑把所有方法都通过 bridge.on 暴露出去,而是要列一个权限白名单。

配套的示例代码是这样:

// 技术栈:TypeScript(带简单鉴权的代理实现)

// 定义一个简易的鉴权 Token 生成器
function generateAccessToken(): string {
  return Date.now().toString(36) + Math.random().toString(36).slice(2);
}

// 在代理中增加权限校验
class SecureBridge {
  private tokenMap: Map<string, string> = new Map();

  // 老项目连接进来时,先注册一个临时令牌
  registerClient(clientId: string): string {
    const token = generateAccessToken();
    this.tokenMap.set(clientId, token);
    return token;
  }

  // 执行方法前先检查令牌是否有效
  invoke(clientId: string, token: string, method: string, ...args: any[]) {
    if (this.tokenMap.get(clientId) !== token) {
      throw new Error('权限校验失败,调用非法');
    }
    // 通过校验后,再执行真正的逻辑
    const fn = this.remoteMethods[method];
    if (fn) return fn(...args);
    return null;
  }

  private remoteMethods: Record<string, Function> = {};
}

export const secureBridge = new SecureBridge();

四、迁移过程中的技术选型策略

这个环节没什么玄学,要按需求阶段走。

第一阶段——老项目完全未动,新项目刚开始做。这时候别想太多,没有业务交互,直接用 iframe 包住老域名,新页面在新系统里玩出花来都没关系。这里注意,在 Next.js 里写一个带 iframe 的页面时,一定优先用 useEffect 挂载阶段去创建 iframe 元素,不要用 dangerouslySetInnerHTML 之类的方法,容易触发 XSS 漏洞。

第二阶段——老项目和新项目有了“数据联动”,比如某个列表操作完需要刷新另一个系统数据。此时赶紧把 iframe 的通信层拆出来,换成注入到单独模块的客户端代理。因为你一旦使用 window.addEventListener('message', ...),就有可能出现多个监听器在同一个窗口上打仗,调试的时候非常痛苦。代理的方式可以让你在方法内部统一处理错误,代码更容易理解。

第三阶段——大部分模块已经搬到了 Next.js 上了,此时老项目只剩下一个“壳”。一般到这个阶段,你基本已经不需要 iframe 了,直接把老项目最终未迁移的路由重新嵌入到主应用的客户端代理下面,同步把 postMessage 代码全部清理干净,以便将来彻底下线老项目。

五、文章总结

写到这里,咱们可以回过头来重新捋一遍。大型老项目向 Next.js 迁移,本质上就是在处理“不确定”(老系统的未知行为)和“新基建”(新框架的能力)之间的协调问题。

iframe 是一个“物理隔离”方案,是不准备沟通时的遮羞布;而客户端代理则是一个“逻辑融合”方案,是打算长期共存的沟通纽带。

我的个人建议是:尽量少的依赖 postMessage 来处理复杂的双向同步,因为任何基于事件驱动的消息传递,一旦链路长了,都必然陷入“调试地狱”。框架级的代理,哪怕是手写一个很简陋的 EventEmitter,在可维护性和代码可读性上也比 iframe 事件监听高出一个维度。

当然,技术选型没有银弹,最终都得回到你的实际业务里。如果你只是临时过渡,忍一忍用 iframe 没毛病。如果这个“过渡期”会持续一年以上,并且业务上有高频的交互和联动,那么毫无疑问,你需要花大力气把客户端代理机制的路子铺好,这才是能让新老项目“无缝交流”的真正护城河。

无论大家选择哪条路,都要把“通信边界”和“失败兜底”放在首位。毕竟再华丽的技术,也扛不住凌晨两点被电话叫醒排查 postMessage 消息丢失,那滋味是真的酸爽。