来自多个数据源的 ViewModel
ViewModel 并不总是只来自一个 API Response。
真实情况往往更像这样:
articlesauthorscategories → ArticleOverviewVm或者更一般地说:
HauptdatenReferenzdatenZusatzdaten → 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
听起来只是一个很小的差别。
但在实践中,它会改变架构。
这篇文章讨论什么
Section titled “这篇文章讨论什么”本文建立在 Retrieve Slice 之上。
周围的结构保持不变:
Infrastructure kapselt den Resource-LifecycleInfrastructure stellt sichere Source Signals bereitStore orchestriert Sources und ProjektionFacade exponiert ViewModelComponent 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。
而 articles、authors、categories 是否已经足以形成当前 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
Section titled “Infrastructure:三个被封装的 Source”Infrastructure 刻意保持很薄。
它加载数据,在 API Boundary 把外部 Response 映射成 Domain Model,并封装 Angular Resource 的技术 Lifecycle。
具体的 HttpResourceRef 保持私有。
向外只提供命名明确、安全的 Signal,以及显式 Operation。
articles.resource.ts
Section titled “articles.resource.ts”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(); }}authors.resource.ts
Section titled “authors.resource.ts”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(); }}categories.resource.ts
Section titled “categories.resource.ts”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.articlesAuthorsResource.authorsCategoriesResource.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 自己决定。
ViewModel
Section titled “ViewModel”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 并不一定只能是二元状态:
fertigodernicht fertig它也可以是一个稳定的 UI Contract,明确表达“部分信息还没有到”。
Mapper 与 Projection Rule
Section titled “Mapper 与 Projection Rule”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 übersetzenStore 不再需要包含这些 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。

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 到底发生了什么?
Section titled “computed 到底发生了什么?”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.
当 authorsResource 之后获得值时,vm 会重新计算。
于是:
authorName: null会自动变成:
authorName: "Ada Lovelace"不需要手工 Patch。
不需要第二条 Subscription。
不需要在 Component 里写 combineLatest。
也不需要命令式同步。
为什么这样能工作
Section titled “为什么这样能工作”第二种 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。

支持部分数据的 Template
Section titled “支持部分数据的 Template”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 会保持扁平。
为什么不干脆到处使用 []?
Section titled “为什么不干脆到处使用 []?”这里有一个很小、但重要的陷阱:
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ägeUI 会因此更诚实。
测试也会更好。
这种区分继续属于 Source Contract:
AuthorsResource.authors() → null | readonly Author[]Store 不需要知道 Infrastructure 如何从 httpResource 中派生出这个 Contract。
forkJoin 并没有过时
Section titled “forkJoin 并没有过时”本文并不是反对 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 ASource Signal BSource Signal C → computed → ViewModel当某个 Source 变化时,不应该重新“编排”一次 ViewModel。
它应该重新被派生。
展望:部分 Skeleton
Section titled “展望:部分 Skeleton”本文有意没有把 Loading 放进 ViewModel。
这是下一步可以继续做的事情。
从同一个 Pattern 出发,可以进一步非常精确地派生 Loading State:
authorName fehltund AuthorsResource.isLoading() ist true → lokaler Author-Skeleton
categoryLabel fehltund CategoriesResource.isLoading() ist true → lokaler Category-Skeleton这样,Loading 就不再是整个页面的一个全局开关。
它会成为 ViewModel Contract 的一部分。
UI 可以先显示 Article,同时只在仍缺少辅助 Source 的区域显示 Skeleton。
这是一个很有价值的扩展。
但理解基本 Pattern 并不需要它。
基本 Pattern 是:
technische Resources in der Infrastructure kapselnSource Signals lesenProjektions-Guards anwendenfehlende Nebendaten bewusst als null modellierenViewModel 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 ladendann alles anzeigen而可以是:
Hauptdaten anzeigenNebendaten ergänzenfehlende Teile explizit modellierenUI-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 eintreffencomputed 是这里最关键的构件。
它读取命名明确的 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 ViewModelsflache Komponententestbare Mappersichtbare Ableitungenspäter auch lokale Skeletons价值就在这里。
不在于更多 Framework Magic。
而在于更清晰的建模。