跳转到内容

战术级前端设计

架构最终还是要落实到代码里。

战略层面的设计可以帮助我们确定领域边界、职责归属,以及哪些部分应该一起演进、哪些部分最好能够相对独立。但当这些原则进入真实项目之后,还会出现另一类问题。

数据到底由哪里加载?ViewModel 在哪里创建?谁负责发起 Command?Mapping、Navigation 和 Notification 应该放在哪里?哪些职责最好一开始就不要进入 Component?

这些问题,就是这里所说的 战术级前端设计 所关注的内容。

这个名称有意借鉴了 Domain-Driven Design 中的 Tactical Design。

在 DDD 中,战略设计更关注领域、边界以及不同上下文之间的关系,而战术设计则进一步讨论边界内部的具体实现形式,例如 Entity、Value Object、Aggregate 或 Repository。

Frontend 并不需要照搬这些概念。一个 Angular 项目也不会因为目录里出现了更多 DDD 名称,就自然拥有更好的架构。

真正值得保留的是背后的思想:

架构最终需要在代码中找到具体的表达形式。

在 Frontend 中,这些形式可能完全不同。Read Flow 可以负责为界面建立一个领域导向的 Projection;ViewModel 可以明确表达 View 真正需要的数据;Command 可以描述一次状态变化以及它产生的结果;Mapping 可以避免外部数据模型直接渗透到整个 Frontend。

Notification、Navigation 和 Invalidation 也应该有一个能够解释清楚的位置,而不是恰好在哪个 HTTP 请求完成之后,就顺手写在哪里。

因此,这里的“战术级”指的是:在已经确定的领域边界之内,如何进一步划分代码中的职责。

很多 Frontend 问题并不是因为团队不知道正确的架构术语。

更常见的情况是,职责会随着需求增加,一点一点移动到当时最方便的位置。

一个 Page Component 一开始可能只负责加载数据。之后加入 Loading State,然后是 Error Handling、Mapping、Toast、保存后的 Navigation、列表 Reload,再加上一两个特殊情况。

慢慢地,这个 Component 就开始承担接近一个完整 Use Case 的编排工作。

Store 也可能经历类似的过程。原本只是管理状态,之后逐渐加入 HTTP、Mapping、Commands、Form Logic、Routing,甚至领域决策。

这种结构往往可以运行很长一段时间。也正因为如此,在问题真正变得昂贵之前讨论职责边界很有价值。

这里的示例不会只展示一个孤立的 Pattern,而是展示小而完整的 Code Slice。重点不仅是 Store、Mapper 或 Command 是否存在,而是它们之间如何协作,以及每个部分的职责应该在哪里结束。

如果想先了解单个 Pattern,可以参考 ViewModel Aggregation、Event-driven Projection 和 Anti-Corruption Layer。

这里会再往前一步:让架构概念真正成为可以运行的代码结构。

这些示例基于一个现代 Angular 技术栈,包括 Angular 21、NgRx 21、Signals 和 NgRx Signal Store。

版本决定的是我们可以使用哪些工具,并不会替我们做架构决策。

如果一个领域 Slice 会长期演进、Read Projection 较复杂,而且多个 Component 会共享相同状态,那么独立的 Read Store、Mapper、ViewModel 和 Tests 可能非常合理。

但对于一个很小而且长期稳定的 Backoffice Dialog,同样的结构可能只是额外负担。

因此,这些示例并不是在说:

“正确的实现方式就是这样。”

更准确地说,它们提供了一个足够具体的设计,让我们可以真正讨论它。

一个切分是否合适,仍然取决于领域复杂度、预期生命周期、变化频率、团队经验、测试策略,以及项目已有的 Architecture。

好的 Architecture 通常需要一定的结构。

但结构本身并不等于质量。

最初的示例从常见的 CRUD Flow 开始,因为这些场景很适合观察 Frontend 中读取和修改数据时所承担的不同职责。

Retrieve Slice:不把加载逻辑放进 Component 展示从 Infrastructure 到 ViewModel 的 Read Flow。

Create Slice:把写操作建模为 Command Flow 不把创建操作只理解为一个 POST,而是把它看作一次具有结果和后续影响的状态变化。

Update Slice:Form State 不是 DTO 讨论 Edit State、Backend Model 和 Command 之间的边界。

Delete Slice:删除也可能是一项领域决策 展示为什么一个看起来简单的 Delete Flow 也可能不只是一次 HTTP 调用。

另外,还有一些反应会跨越单个 Slice。Notification Flow、Command 成功后的 Routing 和 Success 后的 Reload 与 Invalidation 关注的是:这些结果应该在哪里被建模,而不是重新回到最初触发操作的 Component 中。

Tactical Frontend Design 位于两个常见极端之间。

一边是“我们需要清晰的边界”这样的架构原则。这当然重要,但当团队真正开始实现 Feature 时,还需要知道具体的职责应该放在哪个文件、哪个 Slice 或哪个层次中。

另一边则是“我们使用 NgRx Signal Store、withMethods 和 withHooks”这样的技术选择。它同样可能完全正确,但仍然没有回答这个 Store 到底应该负责什么。

这里希望讨论的是两者之间的部分:足够具体,可以真正写出代码;同时又保留足够的抽象,让我们理解为什么要这样切分。

这些示例不是最终答案。

它们只是足够具体的设计,让我们可以在真实项目中验证、讨论,并在需要时重新调整。