跳转到内容

Presentation——UI 与 UX 所在的地方

View Contract 的另一侧,就是 Presentation。

一个 View 可能从自己的 Facade 得到:

customer
canOrder
canDelete
loading
deleteCustomer()
placeOrder()

Presentation 决定这些信息如何变得可见、可操作:它们如何排列,Loading State 如何显示,哪个 Button 需要 disabled,什么时候打开 Dialog,Form 如何工作,Keyboard 或 Touch Interaction 又应该怎样表现。

这些值的业务含义在更早的 Layer 已经决定。到这里之前,架构对用户来说是不可见的。到了 Presentation,它才真正变成界面。

“Presentation 必须很笨”这个说法太粗糙。

好的 UI 在技术上完全可以很复杂。

Presentation 可以承担复杂的 Form、Wizard、Modal Dialog、Table、Drag & Drop、Focus Management、Keyboard Navigation、Accessibility、Touch Interaction、Responsive Layout、Animation 和 UI Composition。一个复杂 Form 完全可能包含几百行 UI Code,而其中没有任何 Business Logic。

例如一个 Autocomplete 需要处理 Input、展示 Suggestion、响应方向键和 Escape、在输入框与列表之间管理 Focus,还要让 Screen Reader 正确理解这些交互。这些职责都不会决定“选中的 Customer 是否可以下单”——但 Component 本身依然远非简单。

边界不由代码行数决定,而由复杂度的类型决定:Presentation 可以拥有 UI 复杂度。业务复杂度不属于它。

+State 已经给出结果,Application 再通过 View Facade 暴露这些值:

canOrder;
canDelete;
selectedCustomer;
visibleCustomers;

Presentation 可以展示这些值:

canOrder = false
Order Button 以 disabled 状态显示

但它不应该重新推导已经做出的决定:

<button
[disabled]="
facade.customer().status !== 'ACTIVE' ||
facade.customer().creditHold !== 'NONE'
"
>
Order
</button>

一旦 Template 自己去检查哪种 Status 或 Flag 允许某项 Action,同一条 Business Rule 就出现了第二份实现——而且更难找到,因为它现在藏在 Markup 而不是 +State 中。

Presentation 可以展示业务决策,但不应该重新构造业务决策。

Presentation 只 Render 已经派生出的 canOrder、canDelete 和 selectedCustomer 等业务状态,而不重新计算这些决定。

Application 文章中的边界在这里继续成立。一个 View 只消费一个面向该 View 的 Contract:

CustomerListFacade
CustomerListView

它不会直接访问多个 Domain 内部区域:

CustomerListView
├── CustomerState
├── PermissionState
├── OrderState
└── SelectionState

通常也不应该同时消费多个 Facade:

CustomerListView
├── CustomerFacade
├── PermissionFacade
├── OrderFacade
└── SelectionFacade

否则 Presentation 又开始编排 Domain 的内部结构——只是比之前高了一层,用 Facade 代替了 Store。

一个 View 只消费一个 Facade。这个 Facade 内部可以访问多个 Domain 区域。View 不需要知道这些结构。

Form 属于 Presentation。包括 FormControlFormGroupdirtytouchedfocusedsubmitted、Field Visibility、Input Formatting、Masking 和局部 Validation Feedback 等 Form State 与机制。

无论 Framework 使用 Reactive Forms、Template Forms、Hook 还是自定义 Form Library,这条职责都成立。

不过 Form 内部还值得进一步区分。

Presentation 可以判断:

  • Required Field 是否为空;
  • 输入是否符合预期格式;
  • Email Address 在语法上是否有效;
  • 两个本地输入的 Password 是否一致。

但像下面这样的规则:

当前 Contract Status 下,这个 Customer 不允许修改 Email Address。

就不属于 Form。这项决定应该已经从 Domain 得出,例如通过 View Contract 中的 canEditEmail 提供。

即使是技术 Validator Function,也不一定必须直接写在 Component File 里。决定 Layer Boundary 的是职责,而不是每个 Helper Function 的物理位置。

Form 属于 Presentation。通过 Form 被修改的业务真相不属于 Presentation。

Dialog 同样属于 Presentation:

用户点击 Delete
打开 Confirmation Dialog
用户确认
facade.deleteCustomer()

业务上到底能不能删除,应该在更早之前已经回答——在 +State 中决定,并通过 canDelete 暴露出来。

Presentation 决定是否使用 Dialog、Dialog 长什么样、有哪些 Button、Focus 放在哪里、Escape 如何处理,以及 Cancel 后发生什么。它不会重新决定“业务上是否允许删除”。

进行中的 Delete Operation 同样如此。View Contract 可能提供 loading。Presentation 决定 UI 在这段时间怎样表现:Dialog 是否保持打开、是否显示 Spinner、是否禁用 Button、成功后是否关闭。

State 到达这里。

UI 决定用户如何感知它。

除了 Form,纯 UI State 也是 Presentation 的重要组成部分:

isDialogOpen;
activeAccordion;
hoveredRow;
focusedField;
selectedTab;
menuExpanded;
tooltipVisible;

只要这些值描述的是当前界面如何呈现或交互,它们通常就属于 Presentation。

+State 文章一样,这里也可以使用同一个判断问题:

这个 State 脱离当前表现形式之后,仍然具有业务含义吗?

如果没有,Presentation 很可能就是它自然的归属。

这也不是绝对规则。某个 selectedTab 如果实际表达 Domain Selection,也可能逐渐获得业务意义。变量名本身无法决定它属于哪一层,真正重要的是含义。

让 UI Component 聚焦于 Presentation,并不是 Signals 或现代 Store Architecture 才出现的新思想。

John Papa 的 Angular Style Guide 很早就在 Defer Logic to Services 中建议把 Logic 从 Component 委托出去,以保持 Component “slim, trim, and focused”。在 Keep Components Focused 中,也建议让 Component 聚焦自己的 View。

当前的 官方 Angular Style Guide 仍然表达同样的基本思想:“Keep components and directives focused on presentation”。Component 和 Directive 中的代码通常应该与它们所呈现的 UI 有关;那些脱离该 UI 仍然有独立意义的代码,则应该进入独立 Function 或 Class。

这并不意味着 Angular Style Guide 定义了本系列的 Layering。例如一个独立 Validator Function,仍然可能属于 Presentation Responsibility。

这些来源只说明:让 UI Component 保持聚焦,本身就是一个长期存在的工程思想。

同样的问题也存在于 React、Vue 和 Svelte Component。一旦一个 UI Component 同时负责 Data Fetching、Business Rule、业务 Aggregation、State Management 和 UI Interaction,它就失去了焦点——与使用哪个 Framework 无关。Component 不是 Use Case 会更详细讨论这个模式。

清晰 Layer Boundary 可以让不同 Presentation Technology 共享同一业务基础:

共同 Domain
Application
View Contract
/ \
/ \
Material View Ionic View

具体启用哪一种 Presentation,可以在 Feature 之外的 App / Composition Level 决定:

App Composition
├── Desktop → Material Presentation
└── Mobile → Ionic Presentation

Domain 不需要知道为什么当前启用了某个 Presentation。

在一个长期项目中,Angular Material 和 Ionic 曾多年并行运行在同一业务基础上。这之所以可行,是因为两个 Presentation 都消费同一套业务决定,不会重新实现 Business Rule,并把 UI 特有差异留在本地。

Material 与 Ionic 可以在 Navigation、Layout、Control、Dialog、Touch Behavior 和 Responsive Design 上完全不同。但它们不应该对同一个业务操作“是否允许”给出不同答案:

canOrder = false
Material:
Button disabled
Ionic:
Action 灰掉或不可用

UX 可以不同。

业务真相必须相同。

Angular Material 与 Ionic 只是实践案例。这个架构原则不依赖这两个 Framework。

共同 Domain 和同一个 View Contract 可以被 Angular Material 与 Ionic Presentation 以不同方式展示,但两边消费的是同一业务决策。

只要两种技术是同一个逻辑 View、同一个 View Contract 的替代实现,这并不违反 Application 文章中的 1:1 规则。

CustomerDetailFacade
/ \
Material Ionic
View View

但如果 Desktop 与 Mobile 逐渐需要不同的 Application Contract,它们就已经变成不同的 View Responsibility。这时就应该拥有自己的 Facade。

核心边界仍然一样:一个具体 Presentation 只消费一个清晰定义的 View Contract。

Presentation 可以做什么,不该做什么

Section titled “Presentation 可以做什么,不该做什么”

Presentation 可以并且应该:

  • 只消费一个 View Contract / Facade
  • Render Value 与 Derived State
  • 接收用户交互
  • 把 User Action 委托给 Facade
  • 管理 Form
  • 打开 Modal Dialog
  • 拥有本地 UI State
  • 管理 Focus、Keyboard 与 Touch Interaction
  • 实现 Accessibility
  • 控制 Animation 与 Responsive Presentation
  • 组合 UI Component
  • 展示 UI 特有 Error Message
  • 用视觉方式表达业务决策

Presentation 不应该:

  • 实现 Business Rule
  • 重新构造业务决策
  • 解释 DTO
  • 发 HTTP Request
  • 直接把外部 SDK 当作 Domain 入口
  • 直接消费 +State
  • 编排多个 Domain Store
  • 在业务上协调多个 Facade
  • 组合业务 Stream 来生成新含义
  • 生成业务 Derived State
  • 解释技术 API Error Code
  • 手动同步 Domain State

打开 Presentation File 时,应该看到 UI 与 Interaction Code。Business Rule 不需要在 Event Handler、Form State 和 CSS Class 之间寻找——它们应该在 +State 中。

UI 与 UX 可以独立于业务逻辑演进。Dialog 可以改成 Drawer,Table 可以变成 Card,Desktop 与 Mobile 可以使用不同交互模式,而 Domain 不必因此改变。

反过来,canOrdercanDeletevisibleCustomers 在多个 Presentation 中保持一致。每个 UI 消费的是同一业务结论,只决定怎样把它变得可见、可操作。

新的 Accessibility Requirement、Layout 调整或不同 Interaction Pattern,也不会自动修改业务 State。

UI 工作仍然只是 UI 工作。

好的 Presentation Component 并不需要只有五行代码。

复杂 Form Handling、Drag & Drop、Accessibility 或 Keyboard Navigation 完全可能合理地产生几百行 Presentation Code。

真正重要的是职责类型:

复杂 UI Interaction → Presentation
复杂业务决策 → +State

所以一个大的 Component 不一定是架构问题。问题出现在它之所以变大,是因为多种职责类型混在一起。

技术外部世界
Infrastructure
+State
Application
Presentation
用户

Infrastructure 保护并转换技术外部世界。+State 为 State 赋予业务意义,并拥有 Business Rule。Application 从中切出一个面向具体 View 的 Contract。Presentation 再让这个 Contract 变得可见、可操作。

每一层都可以很复杂,只要它的复杂度来自自己真正负责的事情。

Layering 的价值就在这种清晰度里:每个决定都有自然归属,相邻 Layer 不需要再做一次同样的决定。