跳转到内容

Retrieve Slice:不在 Component 中写加载逻辑

加载数据听起来很简单。

一个 GET,一点 Loading,也许再加一个 Error State,就结束了。

直到 Component 开始做的不只是 Render:

  • 它知道 HTTP Endpoint
  • 它区分技术加载状态
  • 它映射 DTO
  • 它为 Template 构建模型
  • 它决定 Empty State
  • 它显示错误文本
  • 最后还会顺手调用 reload()

这会工作。

直到下一个 Flow 加进来。

因此,这篇文章展示一个有意识切分的 Retrieve Slice,并使用一个真实 HTTP Endpoint:

https://lorem-api.com/api/article/foo

这个 Slice 使用:

  • Infrastructure 中的 httpResource
  • +state 中的 NgRx Signal Store
  • 一个轻薄的 Application Facade
  • 一个只负责 Render Signal 的 Presentation

这不是普遍真理。

而是一种可以拿到团队里具体讨论的切分方式。


Retrieve 不只是 GET。Retrieve 是一个切分。

最重要的一点是:

httpResource 属于技术 API Boundary。

httpResource 不是普通 UI Signal。

Angular 把 httpResource 描述为 HttpClient 的响应式 Wrapper。它创建 HTTP Request,并把 Response、Request Status 与 Error 作为 Signal 暴露。由于 httpResource 基于 HttpClient,它也会经过 Angular 的 HTTP Stack,包括 Interceptor。

因此从职责上看,httpResource 更靠近外部 API,而不是 Presentation。

所以在这个 Slice 中,它属于 Infrastructure。

不是因为它“脏”或者不好。

而是因为它在建模外部访问。

如果 Presentation 直接了解 httpResource,它也会自动了解 API Boundary 的技术细节:

  • hasValue()
  • value()
  • isLoading()
  • error()
  • reload()
  • Request Status
  • Parse 行为

这就是 API Leakage。

Component 不再只是 Render ViewModel,而开始解释 HTTP Resource 的语义。

因此 Presentation 不应该了解 httpResource。Application 也不应该。

Store 也不应该需要解释具体的 HttpResourceRef

Infrastructure 把它保持为私有实现,只向外提供一组小而明确、命名清晰的 Signal API:

  • 当前可读取的 Article
  • Loading State
  • Error State
  • 一个显式 Reload Operation

Store 消费这组 API。

它知道自己的 Source。

但不再知道 Source 背后的 Framework 机制。


API 返回一篇 Article。

外部 Response 包括:

  • slug
  • title
  • subtitle
  • image
  • author
  • content

这是 API Model。

Frontend 内部使用一个 Entity。

UI Render 一个 ViewModel。

这是三件不同的东西。

API DTO
↓ infrastructure mapper
Entity
↓ state mapper
ViewModel
↓ presentation
Template

这种分离是整个 Slice 的核心。

Anti-Corruption Layer 已经解释为什么外部模型不应该未经转换就穿过整个前端。这里不再重复“为什么”,而是看它在代码里具体放在哪里。


一种可能的 Slice 结构:

article/
├── entities/
│ └── article.model.ts
├── infrastructure/
│ ├── article.dto.ts
│ ├── article.mapper.ts
│ └── article.resource.ts
├── +state/
│ ├── article.store.ts
│ ├── article.vm.ts
│ └── article-view-model.mapper.ts
├── application/
│ └── article.facade.ts
└── presentation/
├── article-page.component.ts
└── article-page.component.html

这些名称并不神奇。

重要的是职责方向:

presentation → application → +state → infrastructure

上层了解下一层提供的抽象。

但不会跨过所有 Layer 随意访问。


1. Infrastructure:httpResource 与 DTO Boundary

Section titled “1. Infrastructure:httpResource 与 DTO Boundary”

Infrastructure 了解外部 API。

它知道:

  • 从哪里加载
  • 预期哪个 DTO
  • Response 在哪里被转换为内部 Entity

生产系统里,这条边界还可以做 Runtime Validation。

例如使用 Zod、Valibot 或类似 Library。

这个第一个 Slice 暂时故意不展开这部分。

不是因为 Validation 不重要。

而是因为本文重点是 Layer Cut。

Runtime Validation 很重要,但不是第一个 Retrieve Slice 的主题。

export interface ArticleAuthorDto {
readonly id: string;
readonly name: string;
readonly avatar: string;
readonly email: string;
}
export interface ArticleDto {
readonly slug: string;
readonly title: string;
readonly subtitle: string;
readonly image: string;
readonly author: ArticleAuthorDto;
readonly content: string;
}

DTO 不描述 Frontend 自己的真相。

它只描述 API Contract。

即使 API 使用 image,UI 以后也不必继续使用同一个字段名。

这个 Slice 的业务形态放在 entities/ 中。

这里有意采用接近 DDD 的 Angular 术语,例如 Manfred Steyer 也使用类似方式。但对本文来说并没有什么神秘之处:entities/ 保存 Slice 内部使用的业务模型。

Infrastructure 了解 DTO。

Slice 的其余部分使用 Entity。

export interface ArticleAuthor {
readonly id: string;
readonly name: string;
readonly avatarUrl: string;
readonly email: string;
}
export interface Article {
readonly slug: string;
readonly title: string;
readonly subtitle: string;
readonly heroImageUrl: string;
readonly author: ArticleAuthor;
readonly paragraphs: readonly string[];
}

可以讨论这个目录到底应该叫 entitiesmodel 还是 domain

但重点并不是目录名。

重点是:

Entity 不是 DTO。

Infrastructure 从外向内映射。

这里有意做两步:

  1. parseArticleResponse()
  2. toDomain()
import { Article, ArticleAuthor } from '../entities/article.model';
import { ArticleAuthorDto, ArticleDto } from './article.dto';
export const parseArticleResponse = (value: unknown): Article => toDomain(value as ArticleDto);
export const toDomain = ({ slug, title, subtitle, image, author, content }: ArticleDto): Article => ({
slug,
title,
subtitle,
heroImageUrl: image,
author: toAuthorDomain(author),
paragraphs: toParagraphs(content),
});
const toAuthorDomain = ({ id, name, avatar, email }: ArticleAuthorDto): ArticleAuthor => ({
id,
name,
avatarUrl: avatar,
email,
});
const toParagraphs = (content: string): readonly string[] =>
content
.split(/\n+/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);

parseArticleResponse() 在这里有意使用 Typecast:

value as ArticleDto;

这不是 Runtime Validation。

这个 Cast 只是在告诉 TypeScript Compiler:

从这里开始,把这个值当作 ArticleDto

它并不会验证 API 在 Runtime 是否真的返回这种结构。

这是这个第一个 Retrieve Slice 的有意简化。文章要展示的是 Layer Cut:httpResource、Infrastructure Mapping、Signal Store、ViewModel 和 Presentation。

生产系统里,我会把这个边界做得更稳健,例如使用 Zod、Valibot、io-ts 或手写 Type Guard。

但那值得单独写一篇文章。

这里的 Typecast 是一个刻意简化的位置,未来可以在这里补上真正的 Runtime Validation。

重要的是:

unknown API response → ArticleDto → Article

离开这个文件之后,API Model 就结束了。

Frontend 其余部分只使用 Article

现在轮到 httpResource

import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Article } from '../entities/article.model';
import { parseArticleResponse } from './article.mapper';
@Injectable()
export class ArticleResource {
private readonly resource = httpResource<Article>(
() => ({
url: 'https://lorem-api.com/api/article/foo',
method: 'GET',
}),
{
parse: parseArticleResponse,
},
);
readonly article = computed<Article | null>(() => {
if (!this.resource.hasValue()) {
return null;
}
return this.resource.value();
});
readonly isLoading = this.resource.isLoading;
readonly error = this.resource.error;
reload(): void {
this.resource.reload();
}
}

这里故意使用 Class。

当然也可以把这个 Wrapper 写成 Factory Function。

httpResource 需要 Angular Injection Context,并且会产生一个有 State 的 Source。Class 因此直接表达了几件事:

  • Angular 管理这个 Instance
  • Provider 决定它的 Scope
  • 具体 Resource 保持 Private
  • 对外只暴露定义好的 Signal 与 Operation

对这个 Slice 来说,这比一个必须从调用位置推断 Injection Context 与 Lifecycle 的 Factory 更容易读。

真正的 Angular Resource 留在实现细节里:

private HttpResourceRef
ArticleResource
article | isLoading | error | reload

这不只是给一个 Method 包一层 Wrapper。

HttpResourceRef 自己带有技术语义:

  • value() 不是任何状态下都可以不加保护地读取
  • hasValue() 负责保护这个访问
  • Loading 和 Error 属于异步 Lifecycle
  • reload() 是这个具体技术 Source 的能力

这些语义只需要在 Infrastructure Boundary 处理一次。

Store 之后不必再次解释它们。

公开的 article Signal 因此返回:

Article | null

这里的 null 不表示:

API 返回了一篇空 Article。

它表示:

技术 Source 当前没有可读取的 Article。

一旦 Article 存在,它就必须是有效的内部模型。这是 Infrastructure Boundary 的职责。

对单个 Entity 来说,人为造一个 EMPTY_ARTICLE 往往不是更好的方案。它虽然能满足 TypeScript Type,却很容易产生空 ID、虚构 Required Value 或无效 Invariant 的 Entity。

对于 Collection,一个 Empty Array 可以是自然的中性值。

对于单个 Entity,明确表达“缺失”通常比伪造一个 Domain Model 更诚实。

reload() 也有意保留在 Infrastructure API 中:

reload(): void {
this.resource.reload();
}

这个 Wrapper 没有发明新的业务 Use Case。

它只是避免 Store 为了重新加载同一个 Source,就不得不了解具体的 HttpResourceRef

Store 不需要知道 URL。

不需要知道怎样安全读取 value()

Presentation 不需要知道 Resource。

Application 不需要知道 HTTP Detail。

这里的 httpResource 不是一个在 Frontend 各处传来传去的全局魔法对象。

它只是一个清晰 Infrastructure API 背后的私有技术实现。


2. +state:编排 Source Signal,派生 ViewModel

Section titled “2. +state:编排 Source Signal,派生 ViewModel”

Store 现在不再直接消费 Angular Resource。

它消费 Infrastructure Adapter 的公开 API。

这才是真正的切分。

如果直接把 HttpResourceRef 对外暴露,就会很糟糕:

readonly articleResource = httpResource<Article>(...);

这样每个消费层都必须重新解释它的 Lifecycle。

更好的方式是:

private httpResource
安全的 Infrastructure Signal
Store Orchestration
ViewModel

Store 不暴露 httpResource

它也不包含针对技术 Resource State 的分支判断。

ViewModel 描述 Page 真正需要什么。

export interface ArticleAuthorVm {
readonly name: string;
readonly avatarUrl: string;
readonly emailLabel: string;
}
export interface ArticleVm {
readonly title: string;
readonly subtitle: string;
readonly heroImageUrl: string;
readonly author: ArticleAuthorVm;
readonly paragraphs: readonly string[];
}

这个模型以 UI 为中心。

emailLabel 不是 Backend Language。

ViewModel 是有意识的 UI 决策。ViewModel Aggregation 会更详细解释这一点。这里我们只关心:它在代码里在哪里产生?

这个 Slice 中,loadingerror 有意不放进 ViewModel。

它们来自 Resource,并由 Store 作为独立 Signal 暴露。ViewModel 则保持简单投影:

Article | null → ArticleVm | null

在其他场景中,构建复杂 Page State 完全可能有意义:更细的 Error State、Partial Loading、Empty State、Retry Model、Skeleton Configuration 或业务加载阶段。

但这不是第一个 Retrieve Slice 的重点。

这里要展示的是清晰分离:

  • Infrastructure 把外部 DTO 转换为 Entity。
  • ArticleResource 封装技术 Resource Lifecycle。
  • Store 编排命名明确的 Source Signal,并派生 Read Model。
  • ViewModel Mapping 保持为简单投影。
  • Presentation 响应 Facade Signal。

Entity 到 ViewModel 的 Mapping 放在 State 层。

不是 Infrastructure。

也不是 Component。

import { Article } from '../entities/article.model';
import { ArticleVm } from './article.vm';
export const toArticleViewModel = (article: Article | null): ArticleVm | null => {
if (article === null) {
return null;
}
const { title, subtitle, heroImageUrl, author, paragraphs } = article;
return {
title,
subtitle,
heroImageUrl,
author: {
name: author.name,
avatarUrl: author.avatarUrl,
emailLabel: author.email,
},
paragraphs,
};
};

Mapper 把一个可能缺失的 Article 投影成一个可能缺失的 ViewModel。

它不解释 HTTP Status。

也不判断为什么当前没有 Article。

它只收到已经标准化过的 Source:

Article | null
ArticleVm | null

Domain Entity 自身仍然严格有效。

Article 内部没有 null、没有人为制造的空 Required Field,也没有技术 Loading State。

这里能看到第二条边界:

Infrastructure:
DTO → Entity
HttpResourceRef → 安全 Source Signal
+state:
Entity | null → ViewModel | null

这不是同一个 Mapper。

它们回答的是不同问题。

Infrastructure 问:

API 返回了什么?如何转换成我们的内部语言?技术 Lifecycle 怎样以安全方式暴露?

State 问:

UI 需要什么,才能正确 Render 这篇 Article?

Store 编排 Source Signal。

import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, withProps } from '@ngrx/signals';
import { ArticleResource } from '../infrastructure/article.resource';
import { toArticleViewModel } from './article-view-model.mapper';
export const ArticleStore = signalStore(
withProps(() => ({
_articleResource: inject(ArticleResource),
})),
withComputed(({ _articleResource }) => ({
isLoading: _articleResource.isLoading,
error: _articleResource.error,
vm: computed(() => toArticleViewModel(_articleResource.article())),
})),
withMethods(({ _articleResource }) => ({
reload: (): void => _articleResource.reload(),
})),
);

Read Flow 现在刻意保持安静。

可以直接看到:

article → ViewModel
isLoading → Store API
error → Store API
reload → Infrastructure

中间没有任何技术 Condition。

不是因为 Lifecycle 消失了。

而是因为它已经在产生它的地方完成标准化。

所以 Store 不需要了解:

  • hasValue()
  • value() 的抛错语义
  • ResourceStatus
  • 具体 HttpResourceRef
  • Parse 或 Request Detail

它消费命名清晰的 Signal 和一个显式 Operation。

这就是这个 Slice 中 Store 应该承担的角色:

Store 不解释技术 Resource Lifecycle。它编排 Source、Projection 与 Intent。

这并不意味着它完全“不做决定”。

它仍然决定:

  • Slice 对外暴露哪些 Signal
  • 派生哪个 ViewModel
  • 哪些 Operation 作为 Slice Intent 被提供

但它不会判断某个 Angular Resource 当前是否可以被安全读取。

这种区分能显著改善代码阅读路径。

阅读 Store 时,我们关心的是 Read Flow 的架构:

Source Signal → ViewModel Projection

而不是底层 Framework API 的使用说明书。

Store 对外只暴露:

  • isLoading
  • error
  • vm
  • reload()

Entity 只是 ViewModel Mapping 的内部输入。

Presentation 不应该再决定“这次直接用 Entity 还是用 ViewModel”。在这个 Retrieve Slice 中,ViewModel 就是公开 Read Access。

这个例子中的 ACL 不返回 null

如果外部 Response 无法转换成 Article,那不是一个空 Article,而是 System Boundary 上的 Error。生产系统中会通过 Runtime Validation 保护这里。一旦 Validation 失败,Resource 进入 Error State。

Store 在这里有意不构建复杂 Page State Object。

它分别暴露 isLoadingerrorvm。对这个 Slice 来说,这更容易读:技术 Lifecycle 已经在 Infrastructure 标准化,而 ViewModel 只是对“可能存在的 Article”的简单投影。

Presentation 之后通过 Facade 获得这些 Signal。

Event-driven Projection 把 State 描述为投影。在这个 Slice 中,withComputed 正是这类投影变得可见的位置。


严格按照 DDD,也许这里会期待一个 Use Case。

例如:

LoadArticleUseCase

Use Case 加载数据、做业务处理,再返回结果。

但在这个 Read Slice 里,这往往会显得人为。

为什么?

因为 NgRx Signal Store 配合 withComputed 已经承担了很大一部分组合:

  • 消费命名明确的 Source Signal
  • 作为 Slice API 暴露 Loading 和 Error State
  • 投影 ViewModel
  • 把 Reload 作为 Slice Intent 提供

额外再加一个 Application Use Case,多半只是把 Store 已经清楚表达的东西再转发一次。

所以这个例子的 Application Layer 有意保持很薄。

不是因为 Application 不重要。

而是因为这个具体 Read Flow 不需要一个厚重 Use Case。

import { Injectable, inject } from '@angular/core';
import { ArticleStore } from '../+state/article.store';
@Injectable()
export class ArticleFacade {
private readonly store = inject(ArticleStore);
readonly vm = this.store.vm;
readonly isLoading = this.store.isLoading;
readonly error = this.store.error;
readonly reload = (): void => this.store.reload();
}

这个 Facade 没有做很多事情。

这正是重点。

它定义 Page 的公开 API:

vm()
isLoading()
error()
reload()

Facade 只转发 Presentation 真正需要的 Signal 与 Operation。

它不把 Entity 暴露出去。

对这个 Retrieve Slice 来说,ViewModel 是公开 Read Access。Entity 只是 Infrastructure 与 +state 之间的内部中间模型。

Presentation 不需要知道后面有 NgRx Signal Store、Infrastructure Adapter 和 Private httpResource

它只知道 Facade。


4. Presentation:Render Signal,而不是编排

Section titled “4. Presentation:Render Signal,而不是编排”

现在 Presentation 很无聊。

这是好事。

它 Inject Facade,为 Slice 提供 Provider,然后 Render ViewModel。

import { Component, inject } from '@angular/core';
import { ArticleStore } from '../+state/article.store';
import { ArticleFacade } from '../application/article.facade';
import { ArticleResource } from '../infrastructure/article.resource';
@Component({
selector: 'app-article-page',
templateUrl: './article-page.component.html',
providers: [ArticleResource, ArticleStore, ArticleFacade],
})
export class ArticlePageComponent {
protected readonly facade = inject(ArticleFacade);
}

Provider Boundary 在这里有意放在 Page 本地展示。

这个 Page Slice 拥有自己的 Resource、Store 和 Facade。

真实应用中,我经常更愿意把这些 Provider 放到 Route。对文章来说,放在 Component 本地更容易直接看出:Slice 自己是封闭的。

重要的不是 Provider 最终放在 Route 还是 Page。

重要的是:

ArticleResource
ArticleStore
ArticleFacade

共同形成这个 Retrieve Slice 的 Scope。

Component 自己保持很薄。它只 Inject Facade,并在 Template 中 Render Facade Signal。

@let vm = facade.vm();
<article class="article-page">
@if (facade.error()) {
<p role="alert">文章无法加载。</p>
<button type="button" (click)="facade.reload()">
重试
</button>
}
@if (facade.isLoading()) {
<p class="article-page__loading">
正在更新文章……
</p>
}
@if (vm) {
<header class="article-page__header">
<img
class="article-page__image"
[src]="vm.heroImageUrl"
[alt]="vm.title"
/>
<h1>{{ vm.title }}</h1>
<p class="article-page__subtitle">
{{ vm.subtitle }}
</p>
<div class="article-page__author">
<img
class="article-page__author-avatar"
[src]="vm.author.avatarUrl"
[alt]="vm.author.name"
/>
<div>
<p>{{ vm.author.name }}</p>
<p>{{ vm.author.emailLabel }}</p>
</div>
</div>
</header>
<div class="article-page__content">
@for (paragraph of vm.paragraphs; track paragraph) {
<p>{{ paragraph }}</p>
}
</div>
}
</article>

Template 使用 @let 绑定 ViewModel。

这样 facade.vm() 只读取一次并成为 Template Local Variable,Markup 会更安静。

errorisLoading 被有意单独处理。它们是 Resource State,不是 ArticleVm 的属性。

isLoading 也不会把已有 Content 清空。如果 ViewModel 已经存在,只是正在 Reload,Template 可以继续显示 Article,同时展示一个小提示、Spinner 或 Progress Indicator。

这样可以避免不必要的闪烁。

Presentation Logic 不知道:

  • DTO
  • URL
  • HttpResourceRef
  • hasValue()
  • parse
  • toDomain
  • toViewModel
  • 技术 Loading State Logic

它只绑定 Facade Signal 并 Render。

这不是巧合。

这就是切分本身。


再从下到上走一遍:

1. API 返回 ArticleDto
2. infrastructure 执行 parseArticleResponse()
3. infrastructure 映射 DTO → Entity
4. private httpResource 持有技术 Resource Lifecycle
5. ArticleResource 把它标准化为 article、isLoading、error 与 reload
6. +state 投影 Article | null → ArticleVm | null
7. +state 把 isLoading、error、vm 与 reload 作为 Slice API 暴露
8. application 转发公开 Page API
9. presentation Render facade.vm()

关键并不是每个文件都很复杂。

恰恰相反。

这些文件都很小。

但每个文件回答不同问题。

article.resource.ts
加载哪个外部 Source?Infrastructure 提供怎样安全的 Signal API?
article.mapper.ts
外部结构如何转换为内部语言?
article.store.ts
Source Signal、Projection 与 Intent 如何被编排?
article-view-model.mapper.ts
UI 最终需要怎样的 State?
article.facade.ts
Page 被允许消费什么?
article-page.component.html
ViewModel 怎样被展示?

可以把整个 Slice 都写进 Store。

HTTP Access、DTO Mapping、Resource Lifecycle、ViewModel Mapping、Error State、Reload。

这样文件更少。

但文件更少,不等于复杂度更低。

只是在一个地方把复杂度藏起来。

这个切分中,Store 不应该成为新的 God Class。

它有清晰职责:

编排命名明确的 Source、Projection 与 Intent。

Infrastructure 自己封装技术实现。

这样阅读 Store 时,看到的是 Read Flow——而不是 httpResource 的机械细节。


也可以这样论证:

DDD 中 Use Case 属于 Application。所以 Application 应该在这里构建 Read Flow。

理论上可能更“正统”。

但对这个具体 Angular Slice 来说不一定更好。

纯 Retrieve Flow 配合 httpResource、Signals 和 NgRx Signal Store,本身已经有很强的响应式组合。如果 Application 里再放一个 Use Case,只是从 Store 读出一个 Signal,然后原样返回一个 Signal,那不会增加架构价值。

只会增加表演。

所以这里 Application 很薄。

更复杂的 Flow 可能不同,例如:

  • 协调多个 Store
  • 检查 Permission
  • 组合 Routing Parameter 与 User Context
  • 构建真正的业务 Read Use Case
  • 把多个 Resource 合成一个业务视图

到了这些场景,Application Layer 可以承担更多职责。

本文有意展示一个小型 Read Slice。

不是最大化架构装置。


Retrieve 不只是 GET 然后展示。

在这个切分中,httpResource 属于 Infrastructure,因为它建模外部访问和技术异步 Lifecycle。

具体 HttpResourceRef 保持在 Infrastructure 内部。

parse 是转换边界。Runtime Validation 可以以后补上,但它不是这个 Slice 的核心。

一个存在的 Domain Model 必须有效。对单个 Entity 来说,人为制造 Empty Model 不能替代清晰建模“缺失”。

Infrastructure 提供安全、命名明确的 Signal 和显式 Operation。

Store 不解释技术 Resource Lifecycle。

它编排 Source Signal、ViewModel Projection 与 Intent。

parse -> toDomain()toViewModel() 是两条不同边界。

简单 Read Flow 中,Application Layer 可以有意识地保持轻薄。

Presentation 负责 Render Signal,而不是编排 Loading、Mapping 和 Infrastructure Logic。