
关于
现代 Angular UI 模式,用于加载状态、错误处理和数据展示。适用于构建 UI 组件、处理异步数据或管理组件状态。
name: angular-ui-patterns description: "现代 Angular UI 模式,用于加载状态、错误处理和数据展示。在构建 UI 组件、处理异步数据或管理组件状态时使用。" risk: safe source: self date_added: "2026-02-27"
Angular UI 模式
核心原则
- 永远不显示过期 UI - 仅在实际加载时显示加载状态
- 始终暴露错误 - 用户必须知道何时出错
- 乐观更新 - 让 UI 感觉即时响应
- 渐进式展示 - 使用
@defer在内容可用时显示 - 优雅降级 - 部分数据优于无数据
加载状态模式
黄金法则
仅在没有数据可显示时才显示加载指示器。
@Component({
template: \`
@if (error()) {
<app-error-state [error]="error()" (retry)="load()" />
} @else if (loading() && !items().length) {
<app-skeleton-list />
} @else if (!items().length) {
<app-empty-state message="No items found" />
} @else {
<app-item-list [items]="items()" />
}
\`,
})
export class ItemListComponent {
private store = inject(ItemStore);
items = this.store.items;
loading = this.store.loading;
error = this.store.error;
}
加载状态决策树
是否有错误?
→ 是:显示错误状态并提供重试选项
→ 否:继续
是否正在加载且没有数据?
→ 是:显示加载指示器(旋转器/骨架屏)
→ 否:继续
是否有数据?
→ 是,有项目:显示数据
→ 是,但为空:显示空状态
→ 否:显示加载(兜底)
骨架屏 vs 旋转器
| 使用骨架屏的场景 | 使用旋转器的场景 | | -------------------- | --------------------- | | 已知内容形状 | 未知内容形状 | | 列表/卡片布局 | 模态操作 | | 初始页面加载 | 按钮提交 | | 内容占位符 | 内联操作 |
控制流模式
@if/@else 条件渲染
@if (user(); as user) {
<span>Welcome, {{ user.name }}</span>
} @else if (loading()) {
<app-spinner size="small" />
} @else {
<a routerLink="/login">Sign In</a>
}
@for 与 Track
@for (item of items(); track item.id) {
<app-item-card [item]="item" (delete)="remove(item.id)" />
} @empty {
<app-empty-state
icon="inbox"
message="No items yet"
actionLabel="Create Item"
(action)="create()"
/>
}
@defer 渐进式加载
<!-- 关键内容立即加载 -->
<app-header />
<app-hero-section />
<!-- 非关键内容延迟加载 -->
@defer (on viewport) {
<app-comments [postId]="postId()" />
} @placeholder {
<div class="h-32 bg-gray-100 animate-pulse"></div>
} @loading (minimum 200ms) {
<app-spinner />
} @error {
<app-error-state message="Failed to load comments" />
}
错误处理模式
错误处理层级
1. 内联错误(字段级)→ 表单验证错误
2. Toast 通知 → 可恢复错误,用户可重试
3. 错误横幅 → 页面级错误,数据仍部分可用
4. 全屏错误 → 不可恢复,需要用户操作
始终显示错误
关键:永远不要静默吞掉错误。
// CORRECT - Error always surfaced to user
@Component({...})
export class CreateItemComponent {
private store = inject(ItemStore);
private toast = inject(ToastService);
async create(data: CreateItemDto) {
try {
await this.store.create(data);
this.toast.success('Item created successfully');
this.router.navigate(['/items']);
} catch (error) {
console.error('createItem failed:', error);
this.toast.error('Failed to create item. Please try again.');
}
}
}
// WRONG - Error silently caught
async create(data: CreateItemDto) {
try {
await this.store.create(data);
} catch (error) {
console.error(error); // User sees nothing!
}
}
错误状态组件模式
@Component({
selector: "app-error-state",
standalone: true,
imports: [NgOptimizedImage],
template: \`
<div class="error-state">
<img ngSrc="/assets/error-icon.svg" width="64" height="64" alt="" />
<h3>{{ title() }}</h3>
<p>{{ message() }}</p>
@if (retry.observed) {
<button (click)="retry.emit()" class="btn-primary">Try Again</button>
}
</div>
\`,
})
export class ErrorStateComponent {
title = input("Something went wrong");
message = input("An unexpected error occurred");
retry = output<void>();
}
按钮状态模式
按钮加载状态
<button
(click)="handleSubmit()"
[disabled]="isSubmitting() || !form.valid"
class="btn-primary"
>
@if (isSubmitting()) {
<app-spinner size="small" class="mr-2" />
Saving... } @else { Save Changes }
</button>
操作期间禁用
关键:始终在异步操作期间禁用触发器。
兼容工具
Claude CodeCursor
标签
前端开发