Template-system 之 从Context 到 Service Registry 的迁移

自述:

在next环境的时候要把nextLink/image的实现注入到远程组件。
远程组件用本地实现mock这两个组件的注入。
最开始实现思路:用Context,主题系统和宿主分别注入value的不同
发现问题:页面的软link,点击居然硬跳转了,说明并没有使用宿主注入的value
查原因:虽然注入value不同,但是context并没有共用一个!
分析:
1. 为什么我的redux可以通过importmap加上react-redux的行为规避context问题?因为react-redux里面就相当于是构建了一个context。
2. 那么我的nextLink/image 的注入是否可以通过redux?
- 错误的,redux适合简单的状态管理,必须纯函数
3. 那么应该如何实现主题系统一套,宿主一套?
- service registry,适用于全局单例
实现:
0. service.mjs应该是host-ports/里通过script生成到public/
1. client provider里,import { getVersion, provided, register } from '@ss/services';
,加useEffect, 注册NextLinkImplApi/NextImageImplApi服务进入service.mjs的全局变量;
并且进行标记+广播
2. 在远程组件加载前await,确保重要加载顺序的稳健性!
3. importmap中 @ss/services 映射到public/
4. 编译期:turbopack 掌握dev时期,直接resolve到本地文件。webpack掌握build时期,浏览器阶段排除掉@ss/services。(使用importmap外部cdn的都应该在编译阶段external)

在 Next.js 里给远程组件“注入” Link / Image:一场从 Context 到 Service Registry 的迁移记

目标:在 Next 环境里,把 NextLink / NextImage 能力注入到远程组件(remote)中;在本地模板运行时又能用 mock 实现自测;同时兼顾主题/租户差异与构建产线的可维护性。


背景与最初设计

  • 远程组件:不直接依赖 Next,而是通过一层“注入”拿到链接跳转与图片能力。

  • 宿主(Host):在运行时提供 Next 的具体实现。

  • 模板开发:希望在 Vite 本地仓库里能用轻量的 mock 跑通 UI 与交互。

最开始的方案:用 React Context

  • 远程用 useLink()/useImage() 消费 Context;

  • 宿主与主题系统分别用 LinkProvider / ImageProvider 注入不同的 value

症状:软跳变硬跳

点击“站内链接”竟然出现 硬跳转location.assign),说明远程并没有用到宿主注入的 Next 路由,而是走了回退逻辑。

根因:不是同一个 Context 实例

Context 在 React 里是按“实例身份”比对的。
我的宿主 Provider 引用了一份 LinkContext,远程组件使用的是 另一份(不同 URL / 打包副本)——看名字一样,但不是同一个对象
于是 useContext() 得到 null,或落到兜底实现,自然“软跳不生效”。


为什么 Redux 当年没踩坑?

当我把 react-redux external 出去,并且让宿主和远程都从 同一个 URL 加载它时:

  • react-redux 自己内部就有单例 Context;

  • Provider 与 useSelector/useDispatch 来自同一份包实例,身份一致,所以“看起来一切顺利”。

这并不意味着“把 Link/Image 塞进 Redux 就对”。Redux 适合可序列化的应用状态,而 Link/Image 是“服务能力”(函数、类、DOM 相关),会破坏纯函数与 DevTools 回放。


方案转向:Service Registry(注册表)

我最终采用了 Service Registry:把 Link / Image 当“全局单例服务”注入,远程按需读取。它解决了“上下文身份不一致”的根问题,也简化了布线。

Registry 的角色分工

  • Registry(全局单例)register({ link, image })getLink()/getImage()

  • 宿主:在 Client Provider 里注册 NextLinkImplApi / NextImageImplApi

  • 远程:只依赖 @ss/services 的接口,在 mock 环境下用本地实现,在宿主环境下用宿主实现。

为什么它更适合这里

  • 不依赖 Context 身份:跨包副本也不怕“上下文不相等”的坑;

  • 无需 Provider 布线:不受 Portal 或多 root 干扰;

  • 开发与生产解耦:dev 用源码别名,prod 用 importmap 的外链产物;

  • 与 Redux 解耦:服务是服务,状态是状态。


运行时注入与就绪顺序

宿主 Client Provider:

    1. 注册服务(幂等)

register({ link: NextLinkImplApi, image: NextImageImplApi });
  1. 标记 + 广播事件

(window as any).__SS_SERVICES_READY__ = true;
window.dispatchEvent(
  new CustomEvent('ss-services:ready', {
    detail: { provided: provided(), version: getVersion(), ts: Date.now() }
  })
);

远程加载前等待(合并等待 React Bridge 与 Services):

  • 先判定(flag/探针),再监听事件;

  • 支持超时/Abort;

  • 并发等待只挂一次监听(promise 缓存)。

这套“注册后广播 + 加载前等待”的模式,把“先注册、后使用”的竞态一次性消杀。


构建与运行:Turbopack、Webpack、Importmap 的职责边界

关键共识importmap 只在浏览器运行时生效,对打包器(Turbopack/webpack)不起作用

Dev(next dev,默认 Turbopack)

  • turbopack.resolveAlias@ss/services 指到工程内源码(例如 ./host-ports/services.ts)。

  • 不要别名到 public/externals/*.mjs 的磁盘路径,更不要 Windows 盘符。(会触发 “windows imports are not implemented yet”。)

  • 引用 @ss/services 的文件要标注 'use client',确保只在客户端链路被解析。

Build(生产构建,默认 Webpack)

  • 客户端产物:把 @ss/services 标记为 module external,让浏览器运行时通过 importmap 去加载公共 URL(/externals/services.v1.mjs 或 CDN)。

  • 服务端产物:给 @ss/services 配一个 server stub(alias 到 stubs/ss-services.server.ts),避免 Node/Edge 侧去解析 client-only 模块。

Runtime(浏览器)

  • <head> 注入 importmap,把 @ss/services 映射到 public/CDN 的那份 mjs

  • 可配 modulepreload 预热,减少首屏等待。


目录与产物

/host-ports/                 # 宿主“端口/适配层”源码(脚本也可生成)
  ├─ services.ts             # @ss/services 的注册表实现(单一源码)
  └─ …其它 proxy

/stubs/
  └─ ss-services.server.ts   # server 假实现(防止 node 侧误用)

/public/externals/
  └─ services.v1.(m)js       # 由 /host-ports/services.ts 构建出的浏览器产物

构建产物生成(任选其一):

  • tsuptsup host-ports/services.ts --format esm --platform=browser --target=es2022 --minify --sourcemap --out-dir public/externals

  • esbuildesbuild host-ports/services.ts --bundle --format=esm --platform=browser --target=es2022 --outfile=public/externals/services.v1.mjs --minify --sourcemap

常见坑 & 速修

  1. 软跳变硬跳

    • 根因:Provider 和 Consumer 用的是不同实例的 Context;

    • 方案:用 Registry;或确保宿主与远程从同一 URL导入 Context(更麻烦)。

  2. Module not found: '@ss/services'

    • 根因:打包期解析不到;

    • Dev:turbopack.resolveAlias['@ss/services'] = './host-ports/services.ts'

    • Build:client → module external;server → alias 到 stub。

  3. windows imports are not implemented yet

    • 根因:把 alias 指到了 D:\...public\externals\*.mjs 这样的 Windows 绝对路径;

    • 修复:dev 下 别指 public;用源码相对路径(posix),或改用动态导入。

  4. 事件错过

    • 先判定再监听:flag/provided() 快速路径 + 事件兜底;必要时再加 rAF 轮询。

  5. 把服务塞进 Redux

    • 不要。Redux 面向可序列化状态;服务(函数/类/DOM)会破坏纯度与工具链。

最终方案

  • 注入方式Service Registry(全局单例)。

  • 宿主职责:在 Client Providerregister(NextLinkImplApi, NextImageImplApi);设置 __SS_SERVICES_READY__ 并广播 ss-services:ready

  • 远程职责:依赖 @ss/services,加载前 await waitForReactGlobal({ alsoServices: true })

  • 构建流程

    • Dev / Turbopack:别名到源码(./host-ports/services.ts);

    • Build / Webpack:client → module external;server → alias 到 stub;

    • Runtime:importmap 映射到 public/externals/services.v1.mjs(或 CDN)。

  • 多主题/多租户:默认全局 Registry;必要时可加 Context 做局部覆写(Registry 作兜底)。


心得与收获

  • 服务 ≠ 状态。Link/Image 是“能力”,不该放在 Redux/Context 里硬扛全局一致性;Registry 更合适。

  • 唯一真身是分布式前端的首要原则:同一个 URL、同一个实例,Context/单例/缓存才会工作。

  • 构建与运行解耦:importmap 是运行时机制;打包器需要 alias / externals 的协同配置。

  • 稳健优先:注册后广播、加载前等待,是前端平台级能力注入的“黄金组合”。

posted @ 2025-09-06 03:17  PEAR2020  阅读(7)  评论(0)    收藏  举报