跳转到内容

来自多个数据源的 ViewModel

ViewModel 并不总是只来自一个 API Response。

真实情况往往更像这样:

articles
authors
categories
→ ArticleOverviewVm

或者更一般地说:

Hauptdaten
Referenzdaten
Zusatzdaten
→ UI-Projektion

过去,我们经常会用 forkJoin 来处理这种情况。

forkJoin({
articles: this.articleApi.loadArticles(),
authors: this.authorApi.loadAuthors(),
categories: this.categoryApi.loadCategories(),
}).pipe(
map(({ articles, authors, categories }) =>
toArticleOverviewViewModel({
articles,
authors,
categories,
}),
),
);

这并没有错。

在很多场景里,它甚至是一种非常干净的表达:

Starte mehrere Requests.
Warte, bis alle fertig sind.
Baue danach ein gemeinsames Ergebnis.

代价是:只有当所有数据都到齐之后,结果才会产生。

有时这正是业务需要的语义。

但并不总是如此。

有了 Signals 和 computed,今天可以换一种思考方式。

不再首先想着:

Requests verheiraten
→ Ergebnis bauen

而是:

Datenquellen als reaktive Inputs behandeln
→ ViewModel als Ableitung berechnen

不是把多个 Request 强行绑在一起,而是从当前 State 派生结果。

听起来只是一个很小的差别。

但在实践中,它会改变架构。


本文建立在 Retrieve Slice 之上。

周围的结构保持不变:

Infrastructure kapselt den Resource-Lifecycle
Infrastructure stellt sichere Source Signals bereit
Store orchestriert Sources und Projektion
Facade exponiert ViewModel
Component rendert

唯一的不同是:

这次 ViewModel 来自多个数据源。

本文刻意不展开完整数据流。

以下内容不是本文主题:

  • Command Flow;
  • Events;
  • Navigation;
  • 完整 ACL 实现;
  • DTO Validation 的细节;
  • Error Strategy;
  • Caching;
  • Entity Normalization;
  • 主示例里的 Loading UX。

API Boundary 只会被简单带到:

parse response
→ mapToDomain

重点放在 ViewModel Projection,以及这个问题上:

什么时候必须等待所有数据源?
什么时候可以先构建一个部分 ViewModel,再随着数据到来逐步补全?

这里需要区分两类 Guard:

technischer Resource-Guard
→ gehört in die Infrastructure
Projektions-Guard
→ entscheidet, welche Sources für ein gültiges ViewModel ausreichen

这个区别很重要。

hasValue() 保护的是技术性的 Angular Resource。

articlesauthorscategories 是否已经足以形成当前 UI 所需的 Projection,则属于 ViewModel Contract 本身。


我们要构建一个 Article Overview。

主列表来自 articles

作者来自 authors

分类来自 categories

articles
→ id, title, authorId, categoryId
authors
→ id, name
categories
→ id, label

但 UI 并不需要一个技术数据模型。

UI 需要的是 ViewModel:

ArticleOverviewVm
→ title
→ authorName
→ categoryLabel

因此,最终映射来自三个 Source:

article.authorId
→ authors.find(author.id)
article.categoryId
→ categories.find(category.id)

Infrastructure 负责加载 Source,ViewModel 将多个 Source 聚合成一个派生结果。


Infrastructure:三个被封装的 Source

Section titled “Infrastructure:三个被封装的 Source”

Infrastructure 刻意保持很薄。

它加载数据,在 API Boundary 把外部 Response 映射成 Domain Model,并封装 Angular Resource 的技术 Lifecycle。

具体的 HttpResourceRef 保持私有。

向外只提供命名明确、安全的 Signal,以及显式 Operation。

import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Article } from '../entities/article.model';
import { mapArticlesResponseToDomain } from './article.mapper';
@Injectable()
export class ArticlesResource {
private readonly resource = httpResource<readonly Article[]>(
() => '/api/articles',
{
parse: mapArticlesResponseToDomain,
},
);
readonly articles = computed<readonly 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();
}
}
import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Author } from '../entities/author.model';
import { mapAuthorsResponseToDomain } from './author.mapper';
@Injectable()
export class AuthorsResource {
private readonly resource = httpResource<readonly Author[]>(
() => '/api/authors',
{
parse: mapAuthorsResponseToDomain,
},
);
readonly authors = computed<readonly Author[] | 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();
}
}
import { computed, Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Category } from '../entities/category.model';
import { mapCategoriesResponseToDomain } from './category.mapper';
@Injectable()
export class CategoriesResource {
private readonly resource = httpResource<readonly Category[]>(
() => '/api/categories',
{
parse: mapCategoriesResponseToDomain,
},
);
readonly categories = computed<readonly Category[] | 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();
}
}

三个 Adapter 都在处理同一个技术问题:

Kann die konkrete Angular Resource sicher gelesen werden?

因此 hasValue() 就应该留在 Resource 被创建的地方。

之后 Store 只会看到:

ArticlesResource.articles
AuthorsResource.authors
CategoriesResource.categories

真正的 Aggregation 不发生在 Infrastructure。

Infrastructure 不应该知道最后要构建什么样的 ViewModel。

它只知道自己的 Source。

ArticlesResource
→ Artikel laden
→ technischen Lifecycle kapseln
→ articles-Signal bereitstellen
AuthorsResource
→ Autoren laden
→ technischen Lifecycle kapseln
→ authors-Signal bereitstellen
CategoriesResource
→ Kategorien laden
→ technischen Lifecycle kapseln
→ categories-Signal bereitstellen

而不是:

ArticlesResource
→ Artikel laden
→ Autoren laden
→ Kategorien laden
→ UI-Modell bauen

那样又会出现一个隐藏的 Page Service。

Infrastructure 只负责判断一个技术 Resource 当前是否可以被安全读取。

至于目前可用的 Source 是否足以满足具体 UI Contract,由 Projection 自己决定。


Overview 只需要一个很小的 ViewModel。

export interface ArticleOverviewVm {
readonly items: readonly ArticleOverviewItemVm[];
}
export interface ArticleOverviewItemVm {
readonly id: string;
readonly title: string;
readonly authorName: string | null;
readonly categoryLabel: string | null;
}

这里有个值得注意的地方:

readonly authorName: string | null;
readonly categoryLabel: string | null;

这是刻意的。

这里的 null 并不自动表示“Error”。

它表示:

Diese Teilinformation ist aktuell nicht verfügbar.

这一点会在第二种 Case 中变得重要。

因为 ViewModel 并不一定只能是二元状态:

fertig
oder
nicht fertig

它也可以是一个稳定的 UI Contract,明确表达“部分信息还没有到”。


Aggregation 本身继续保持 Pure。

Store 只负责收集 Source Signal。

命名明确的 Projection Function 决定:对于某个 UI Contract,哪些 Source 是必需的。

真正的 Builder 则用当前可用的数据构建 ViewModel。

import { Article } from '../entities/article.model';
import { Author } from '../entities/author.model';
import { Category } from '../entities/category.model';
import {
ArticleOverviewItemVm,
ArticleOverviewVm,
} from './article-overview.vm';
interface ArticleOverviewSources {
readonly articles: readonly Article[] | null;
readonly authors: readonly Author[] | null;
readonly categories: readonly Category[] | null;
}
interface AvailableArticleOverviewSources {
readonly articles: readonly Article[];
readonly authors: readonly Author[] | null;
readonly categories: readonly Category[] | null;
}
export const toCompleteArticleOverviewViewModel = ({
articles,
authors,
categories,
}: ArticleOverviewSources): ArticleOverviewVm | null => {
if (
articles === null ||
authors === null ||
categories === null
) {
return null;
}
return buildArticleOverviewViewModel({
articles,
authors,
categories,
});
};
export const toProgressiveArticleOverviewViewModel = ({
articles,
authors,
categories,
}: ArticleOverviewSources): ArticleOverviewVm | null => {
if (articles === null) {
return null;
}
return buildArticleOverviewViewModel({
articles,
authors,
categories,
});
};
const buildArticleOverviewViewModel = ({
articles,
authors,
categories,
}: AvailableArticleOverviewSources): ArticleOverviewVm => {
const authorsById = authors
? new Map(authors.map((author) => [author.id, author]))
: null;
const categoriesById = categories
? new Map(categories.map((category) => [category.id, category]))
: null;
return {
items: articles.map(
(article): ArticleOverviewItemVm => ({
id: article.id,
title: article.title,
authorName:
authorsById?.get(article.authorId)?.name ?? null,
categoryLabel:
categoriesById?.get(article.categoryId)?.label ?? null,
}),
),
};
};

两个公开函数回答的是不同问题:

toCompleteArticleOverviewViewModel
→ Sind alle Sources vorhanden?
toProgressiveArticleOverviewViewModel
→ Ist die führende Source vorhanden?

共享 Builder 只回答:

Wie entsteht aus den verfügbaren Daten das ViewModel?

于是三种职责保持分离:

Infrastructure
→ technische Resource absichern
Projektionsfunktion
→ erforderliche Sources bestimmen
Builder
→ vorhandene Sources in ViewModel übersetzen

Store 不再需要包含这些 Condition。

它只选择一个命名明确的 Projection,并把它连接到 Source Signal。

Case 1:Projection Guard——所有 Source 都是必需的

Section titled “Case 1:Projection Guard——所有 Source 都是必需的”

有些页面只有在所有数据都到齐之后才有意义。

这时完整的 Projection Guard 很合适:

articles fehlt
→ kein VM
authors fehlt
→ kein VM
categories fehlt
→ kein VM
alles da
→ VM bauen

这就是现代 Signal 版本的:

warte auf alle
→ baue Ergebnis

区别在于,我们不是在聚合 Request。

我们是在聚合 Resource State。

当每个 Source 都在业务上不可缺少时,由 Guard 保护 ViewModel。

import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { ArticlesResource } from '../infrastructure/articles.resource';
import { AuthorsResource } from '../infrastructure/authors.resource';
import { CategoriesResource } from '../infrastructure/categories.resource';
import { toCompleteArticleOverviewViewModel } from './article-overview.mapper';
export const ArticleOverviewStore = signalStore(
withProps(() => ({
_articlesResource: inject(ArticlesResource),
_authorsResource: inject(AuthorsResource),
_categoriesResource: inject(CategoriesResource),
})),
withComputed(
({
_articlesResource,
_authorsResource,
_categoriesResource,
}) => ({
vm: computed(() =>
toCompleteArticleOverviewViewModel({
articles: _articlesResource.articles(),
authors: _authorsResource.authors(),
categories: _categoriesResource.categories(),
}),
),
}),
),
);

只有当三个 Source Signal 都提供了值,ViewModel 才存在。

Store 本身不会出现显式 Guard Logic。

这个决定被放进命名明确的 Projection:

articles
+ authors
+ categories
→ toCompleteArticleOverviewViewModel()
→ ArticleOverviewVm | null

在等待期间,UI 可以显示一个简单 Placeholder:

@let vm = facade.vm();
@if (vm) {
@for (item of vm.items; track item.id) {
<article>
<h2>{{ item.title }}</h2>
<p>{{ item.authorName }}</p>
<p>{{ item.categoryLabel }}</p>
</article>
}
} @else {
<app-overview-placeholder />
}

非常简单。

如果部分信息在业务上没有独立价值,这也是正确的处理方式。


computed 会记住在计算过程中读取过哪些 Signal。

在我们的例子里:

_articlesResource.articles()
_authorsResource.authors()
_categoriesResource.categories()

技术性的 Resource API 已经从这里消失了。

computed 只观察面向应用的 Source Signal。

只要其中任意一个已读取依赖发生变化,这个派生就会失效。

下一次读取 vm() 时,它会重新计算。

这是关键点。

不是 Store 手工 Push 一个新的 VM。

不是 Component 调用 rebuildVm()

也不是某个 Service 手工 Merge 三条 Subscription。

而是:

Source Signal ändert sich
→ computed wird ungültig
→ vm() wird erneut gelesen
→ neue Ableitung entsteht
→ Template rendert neuen Stand

这就是心智模型的转换。


Case 2:Projection Guard——只有主 Source 是必需的

Section titled “Case 2:Projection Guard——只有主 Source 是必需的”

现在情况更有意思了。

也许 articles 是主 Source。

没有 Article,页面什么都无法显示。

但 Author 和 Category 只是额外的 Enrichment。

那 UI 就没有必要等待全部数据。

articles fehlt
→ kein VM
articles da
→ VM bauen
authors fehlen
→ authorName: null
categories fehlen
→ categoryLabel: null
authors kommen später
→ VM aktualisiert sich
categories kommen später
→ VM aktualisiert sich

这不是 Hack。

这是一个刻意设计的部分 ViewModel。

import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withProps } from '@ngrx/signals';
import { ArticlesResource } from '../infrastructure/articles.resource';
import { AuthorsResource } from '../infrastructure/authors.resource';
import { CategoriesResource } from '../infrastructure/categories.resource';
import { toProgressiveArticleOverviewViewModel } from './article-overview.mapper';
export const ArticleOverviewStore = signalStore(
withProps(() => ({
_articlesResource: inject(ArticlesResource),
_authorsResource: inject(AuthorsResource),
_categoriesResource: inject(CategoriesResource),
})),
withComputed(
({
_articlesResource,
_authorsResource,
_categoriesResource,
}) => ({
vm: computed(() =>
toProgressiveArticleOverviewViewModel({
articles: _articlesResource.articles(),
authors: _authorsResource.authors(),
categories: _categoriesResource.categories(),
}),
),
}),
),
);

Store Code 与第一种 Case 的区别只在于选择了不同 Projection。

但效果差异很大。

第一种 Case 表达的是:

Ich baue das VM erst,
wenn alle Quellen da sind.

第二种 Case 表达的是:

Ich brauche die Hauptquelle.
Alles andere ist progressive Anreicherung.

主数据负责让页面成立,辅助数据随后逐步丰富 ViewModel。

authorsResource 之后获得值时,vm 会重新计算。

于是:

authorName: null

会自动变成:

authorName: "Ada Lovelace"

不需要手工 Patch。

不需要第二条 Subscription。

不需要在 Component 里写 combineLatest

也不需要命令式同步。


第二种 Case 里的 computed 并不只读取 articles

它也会读取两个辅助 Source Signal:

_articlesResource.articles()
_authorsResource.authors()
_categoriesResource.categories()

因此三个 Signal 都成为这次派生的依赖。

AuthorsResource.authors() 之后从 null 变成一个具体值时,computed 会失效。

下一次读取时就会生成新的 ViewModel。

AuthorsResource.authors()
null → Author[]
computed invalidiert
→ vm() wird neu gelesen
→ progressive Projektion wird erneut ausgeführt
→ authorName wird befüllt

这就是核心。

UI 不需要知道刚刚是哪一个 Source 提供了新值。

它只需要再次读取 vm()

vm() 是当前 State 的新 Projection。

任何一个 Source 到达都会使 computed 失效,ViewModel 随后重新派生。


Template 可以有意识地处理 null

@let vm = facade.vm();
@if (vm) {
@for (item of vm.items; track item.id) {
<article>
<h2>{{ item.title }}</h2>
@if (item.authorName) {
<p>{{ item.authorName }}</p>
} @else {
<p>Autor noch nicht verfügbar</p>
}
@if (item.categoryLabel) {
<p>{{ item.categoryLabel }}</p>
} @else {
<p>Kategorie noch nicht verfügbar</p>
}
</article>
}
} @else {
<app-overview-placeholder />
}

Component 不负责判断缺了哪个 API。

它只 Rendering Contract:

vm === null
→ Hauptdaten fehlen
authorName === null
→ Autor nicht verfügbar
categoryLabel === null
→ Kategorie nicht verfügbar

这样 Component 会保持扁平。


这里有一个很小、但重要的陷阱:

const authors = _authorsResource.authors() ?? [];

看起来很方便。

但它会把两个状态混在一起。

[]

可能表示:

noch nicht geladen

也可能表示:

geladen, aber leer

这两句话的语义不同。

因此,对于依赖 Source,通常更清晰的是:

const authors = _authorsResource.authors();

这样 Contract 就很明确:

null
→ Quelle ist noch nicht verfügbar
[]
→ Quelle ist verfügbar, enthält aber keine Einträge

UI 会因此更诚实。

测试也会更好。

这种区分继续属于 Source Contract:

AuthorsResource.authors()
→ null | readonly Author[]

Store 不需要知道 Infrastructure 如何从 httpResource 中派生出这个 Contract。


本文并不是反对 forkJoin

当业务过程本身表达的就是下面这种语义时,forkJoin 仍然非常合适:

Starte mehrere einmalige Operationen.
Warte auf alle Ergebnisse.
Fahre danach fort.

例如:

  • 准备 Export;
  • 所有 Master Data 加载完成后才打开 Dialog;
  • Wizard 初始时必须完整填充;
  • 启动一次性计算;
  • 多个 Command 全部完成后再继续。

但对于 Page ViewModel 来说,forkJoin 经常不是最合适的心智模型。

Page ViewModel 很少只是一个一次性的结果。

它通常是响应式 Projection。

Source Signal A
Source Signal B
Source Signal C
→ computed
→ ViewModel

当某个 Source 变化时,不应该重新“编排”一次 ViewModel。

它应该重新被派生。


本文有意没有把 Loading 放进 ViewModel。

这是下一步可以继续做的事情。

从同一个 Pattern 出发,可以进一步非常精确地派生 Loading State:

authorName fehlt
und AuthorsResource.isLoading() ist true
→ lokaler Author-Skeleton
categoryLabel fehlt
und CategoriesResource.isLoading() ist true
→ lokaler Category-Skeleton

这样,Loading 就不再是整个页面的一个全局开关。

它会成为 ViewModel Contract 的一部分。

UI 可以先显示 Article,同时只在仍缺少辅助 Source 的区域显示 Skeleton。

这是一个很有价值的扩展。

但理解基本 Pattern 并不需要它。

基本 Pattern 是:

technische Resources in der Infrastructure kapseln
Source Signals lesen
Projektions-Guards anwenden
fehlende Nebendaten bewusst als null modellieren
ViewModel reaktiv ableiten

真正的重点不是“用 forkJoin 写三个 HTTP Call”还是“用 computed 读三个 Resource”。

真正的问题是:

我把 ViewModel 看成一次 Request 的结果?
还是把它看成响应式 State 的派生?

这是一个根本性的差异。

使用 forkJoin 时,我首先思考的是完成:

alle Requests fertig
→ Ergebnis bauen

使用 computed 时,我首先思考的是有效性:

welche Source Signals liefern gerade Werte?
→ daraus gültiges VM ableiten

这会打开新的可能性。

不再只有:

alles laden
dann alles anzeigen

而可以是:

Hauptdaten anzeigen
Nebendaten ergänzen
fehlende Teile explizit modellieren
UI-Vertrag stabil halten

这正是前端架构开始变得有趣的地方。

不是因为代码更复杂了。

而是因为代码可以表达更多真实语义。


来自多个数据源的 ViewModel 并不是什么特殊情况。

它是很常见的前端切分。

真正的问题只是:我们是否有意识地对它建模。

Case 1:
Alle Quellen sind erforderlich.
→ vollständige Projektion
→ VM erst bauen, wenn alle Source Signals Werte liefern
Case 2:
Eine Quelle ist führend.
Andere Quellen reichern an.
→ progressive Projektion
→ nur Hauptquelle ist erforderlich
→ fehlende Teile als null modellieren
→ VM aktualisiert sich, sobald Nebendaten eintreffen

computed 是这里最关键的构件。

它读取命名明确的 Source Signal。

它记住读取过的依赖。

某个依赖变化后,它会失效。

下一次读取时,再生成新的派生。

Source Signal ändert sich
→ computed invalidiert
→ ViewModel wird neu abgeleitet
→ UI rendert neuen Stand

这就是与过去相比真正不同的地方。

不是因为过去的一切都错了。

而是因为今天我们能更精确地控制 UI Contract。

中心边界依旧很清楚:

Infrastructure
→ entscheidet, ob eine technische Resource sicher gelesen werden kann
ViewModel-Projektion
→ entscheidet, welche verfügbaren Sources für einen gültigen UI-Vertrag ausreichen

只要这条边界切得干净,就会自然出现更多可能性:

partielle ViewModels
flache Komponenten
testbare Mapper
sichtbare Ableitungen
später auch lokale Skeletons

价值就在这里。

不在于更多 Framework Magic。

而在于更清晰的建模。