Ivan Rossouw

一个组件可以在共享程序集中编译,但在渲染它的某个应用中却无法构造。

当浏览器和 .NET MAUI 宿主复用相同的 Blazor 页面时,这一问题很容易被忽略。该特性看似只存在一次,但每个宿主仍然拥有各自的依赖注入容器、启动路径和生命周期模型。

最近提交的一项修复让这一问题变得具体化:一个共享页面新增了一个必需的工作流依赖。原生宿主在公共引导路径中注册了它,而浏览器宿主通过另一条组合根渲染同一页面时,该依赖是未知的。页面在渲染前就失败了。

持久的教训并非“多记一次注册”,而是共享 UI 为每一个能够渲染它的宿主创建了一份契约。

共享程序集并不共享容器

编译时复用问的是多个项目能否引用一个组件;运行时组合问的是每个宿主能否构造它并满足所需行为。

原生宿主可能会调用一个包装器引导,而浏览器宿主则调用面向 Web 的启动路径。两者加载相同的 Razor 组件,却不会构建相同的服务图。

这产生了一条有用的审查规则:

共享组件上的必需注入,对每一个能渲染它的组合根而言都是一份必需契约。

宿主清单很重要。“所有原生应用都使用此引导程序”不足以覆盖浏览器、桌面、测试或预览宿主通过其他路径渲染页面的情况。

不要用可选性来弥补缺失的宿主能力

当某个宿主无法提供依赖时,将其设为可空看起来很实用。这对触觉反馈等可选增强是合理的,但当依赖强制执行工作流不变式时就很危险了。

在所审查的变更中,工作流需要知道操作进行期间运行时上下文是否保持不变。原生应用可以在运行时更改该上下文,而浏览器宿主则不能——其上下文在应用生命周期内是固定的。

可选性会将“此宿主以不同方式表示稳定性”转化为“此宿主可以跳过安全检查”。这两者并不等价。

更好的问题是:工作流真正需要的最小能力是什么?

它只需要三个事实:

  • 标识当前上下文的修订号;
  • 该上下文是否已准备好工作;
  • 上下文已更改的信号。

它不需要原生宿主完整的导航、存储或 UI 服务。一旦更小的契约明确,两个宿主都能如实提供。

固定与实时适配器可以满足同一不变式

浏览器实现可以是固定的:

  • 其修订号永不改变;
  • 应用启动后即准备就绪;
  • 其更改信号永不触发。

原生实现可以是实时的:

  • 其修订号来自运行时上下文;
  • 就绪状态反映是否存在选择;
  • 其信号跟随真实的上下文变化。

它们行为不同,却遵守同一承诺:在一个上下文中开始的工作不得在该上下文更改后静默提交。抽象命名的是每个平台都能遵守的最小不变式,而不是抹除平台差异。

在宿主实际进入的位置注册契约

最初的注册位于原生应用共享的引导程序中。它看起来是中心的,因为多个宿主调用它,但它并非每个渲染页面的宿主的中心。

修复将共享工作流契约移到了两条实际的组合路径中:

  • 浏览器路径注册固定适配器;
  • 原生路径注册实时适配器;
  • 两者都注册必需的共享工作流;
  • 原生专用的包装器不再是意外的权威。

重复的注册记录了一个真实的分裂:相同的工作流,不同的宿主事实。公共注册仍然适合相同的服务;宿主特定的适配器应在宿主选择它们的位置保持可见。

测试行为与组合

注册测试很有用,因为此故障发生在组件行为开始之前。

一个聚焦矩阵可以覆盖:

  1. 浏览器组合根包含共享工作流和固定适配器;
  2. 原生组合根包含共享工作流和实时适配器;
  3. 旧的平台专用引导程序不是隐藏的第三方权威;
  4. 当宿主具有真正固定的上下文时,工作流成功;
  5. 当实时上下文在操作中途更改时,工作流拒绝或修复完成。

描述符检查可快速捕获缺失的注册。行为测试证明适配器保留了不变式。两者都不激活完整的生产宿主。对于高价值页面,添加一个烟雾测试,构建每个宿主的真实提供程序并构造页面边界;这可以暴露描述符检查遗漏的生命周期或替换错误。

权衡

窄契约方法会带来一个接口、两个适配器、显式注册和更宽的测试矩阵。

替代方案仅在局部更廉价:

  • 宽泛的原生服务会将平台关注点泄漏到共享工作流代码中;
  • 可选依赖可能静默禁用安全不变式;
  • 一个过大的引导程序隐藏了哪个宿主拥有哪种行为;
  • 临时检查会在没有命名契约的情况下漂移。

我更喜欢在组合边界进行小而有意的重复,而不是在工作流内部隐式行为差异。

实用的审查清单

当共享 Blazor 组件获得必需依赖时,请询问:

  • 哪些浏览器、原生、桌面、测试和预览宿主可以渲染它?
  • 每个宿主通过哪个组合根进入?
  • 该依赖是工作流需要的能力,还是它恰好接收的大型平台服务?
  • 每个宿主都能提供真实、非可选的实现吗?
  • 宿主差异是否在适配器中可见,而不是在空分支中?
  • 测试是否针对每个根都练习了注册和行为?
  • 完整提供程序烟雾测试能否捕获生命周期或替换错误?

共享 UI 不是共享运行时拓扑的证据。将每个必需依赖视为跨宿主的设计决策,而组合根成为组件真实 API 的一部分。

你系统中的哪个共享页面正在悄悄假设它只有一个宿主?