跳到主要内容

零停机前端迁移

重写前端。170 个地点仍在使用。零停机。

一次在生产环境中从 CRA 迁移至 Next.js 的过程,采用 V2 应用外壳、iframe 桥接、共享身份验证、同步路由和分阶段发布。

阅读时间:5分钟作者

产品不能暂停

CRA V1 仍为 170 个地点和约 765 名并发用户提供服务。我们不能冻结功能开发、隔离构建替代系统,再让所有客户一次性迁移。迁移必须在运行中的产品内进行。

我们采用绞杀者模式(Strangler Fig):让新系统包围旧系统,每次迁移一项生产职责,并在没有依赖方后删除兼容层。

采用绞杀者模式,而不是另做一次发布

Next.js V2 成为应用外壳,负责导航栏、顶部栏、身份验证和全局界面框架。尚未迁移的路由继续在一个 CRA iframe 中运行,因此即使两个前端运行时同时工作,客户看到的仍是一个产品。

iframe 始终加载稳定的仪表板基础 URL,并在父路由变化时保持挂载;重新挂载会重置旧版状态并重复身份验证。V2 只在 iframe 的 onLoad 回调中发送初始路由,此时 V1 已安装消息侦听器;之后仅在映射路由真正变化时发送更新。

iframe.contentWindow?.postMessage(
  {
    action: 'NAVIGATE',
    route: iframeRoute,
    location_id: locationId,
    migratedLocationIds: [...MIGRATED_LOCATION_IDS]
  },
  dashboardUrl
)

让两个前端表现得像一个

我们明确定义了两个消息方向。V2 发送导航、SSO 令牌、用户数据和地点数据;V1 返回路由变化、用户数据请求、令牌刷新请求和 Click2Call 操作。

每个接收方都会拒绝意外来源,每个发送方都以配置的应用来源为目标,而不是使用通配符。来源检查是第一道边界,但不能替代完整的负载验证。含令牌的消息还需要已知消息类型和经过验证的字段。

if (event.origin !== dashboardUrl) return
if (event.data?.source !== 'dispo-iframe') return
if (event.data?.type !== 'ROUTE_CHANGE') return

const route = mapV1RouteToV2(event.data.route)
if (route) router.replace(route)

一次登录必须同时验证两个应用

用户只在 V2 中进行一次身份验证。V1 加载后会请求当前用户上下文;V2 返回 SSO 令牌及用户和地点数据。V1 为现有 API 客户端保存令牌,并在令牌过期后通过同一桥接请求替换令牌。

在诊断 iframe 启动竞态时,我们引入了一个主动认证钩子。它尝试在 V1 请求前推送认证信息,却增加了另一条时序路径,并且会过早触发。后续更改删除了该钩子,保留响应式的请求—响应认证,使令牌时序只有一个明确负责人。

让地址栏和浏览器历史准确反映当前状态

V2 将自身路由映射到 /dashboard、/buyer-genie、/deals 和 /my-buyers 等 V1 路径。反向映射同时处理精确路由和嵌套前缀。当导航从 V1 内部开始时,V2 使用 router.replace 更新地址栏和活动导航,避免为一次用户操作添加第二条历史记录。

这样可以让刷新、深层链接和浏览器前进/后退保持一致。浏览器的“后退”操作会更改父路由,父页面发送映射后的子路由,已挂载的 iframe 则在不重新加载外壳的情况下导航。

以受控批次迁移客户地点和功能

MIGRATED_LOCATION_IDS 是发布开关。我们先从内部地点和预发布地点开始,再扩展到八个客户地点。列表之外的所有用户继续使用现有 V1 路径。

  • 原生 V2:Navbar、Topbar、Settings、Investor Intake、SSO 和 Twilio Dialer。
  • 在所记录的迁移中期仍位于 V1:Home、Genius Mode、Deals 和 Buyers。
  • 迁移单元:一项功能和选定的客户地点,而不是一次单独发布。

替换过程在逐步发布期间保持不可见

兼容层最终被移除,而没有成为永久架构。切换过程删除 iframe 系统和旧版生成的 API 客户端,将仍在使用的 API 客户端迁入 V2,添加原生登录路径,随后清理路由、测试、共享拨号器代码、认证守卫和组件结构。

有一段时间,我们维护两套路由器、两种会话模型、跨窗口事件、路由映射和启动竞态。这项成本只有在每条已迁移路由都让我们更接近删除桥接时才值得承担。