Angular UI 模式
Angular UI 模式
核心原则
1. 绝不显示过时 UI - 仅在实际加载时显示加载状态
2. 始终呈现错误 - 用户必须知道何时发生了失败
3. 乐观更新 - 让 UI 响应感觉瞬间完成
4. 渐进式披露 - 使用 @defer 在内容可用时显示
5. 优雅降级 - 部分数据好过没有数据
---
加载状态模式
金科玉律
仅在没有可显示数据时才显示加载指示器。
@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;
}
加载状态决策树
是否有错误?
→ 是:显示带有重试选项的错误状态
→ 否:继续
是否正在加载 且 没有数据?
→ 是:显示加载指示器(加载圈/骨架屏)
→ 否:继续
是否有数据?
→ 是,且有项目:显示数据
→ 是,但为空:显示空状态
→ 否:显示加载(兜底方案)
骨架屏 (Skeleton) vs 加载圈 (Spinner)
| 使用骨架屏的场景 | 使用加载圈的场景 |
| -------------------- | --------------------- |
| 内容形状已知 | 内容形状未知 |
| 列表/卡片布局 | 模态框操作 |
| 页面首次加载 | 按钮提交 |
| 内容占位符 | 行内操作 |
---
控制流模式
用于条件渲染的 @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>
}带有 Track 的 @for
@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. 错误横幅 (Banner) → 页面级错误,数据仍可部分使用
4. 全屏错误页 → 不可恢复,需要用户采取行动始终显示错误
至关重要:绝不要静默处理错误。
// 正确 - 错误始终呈现给用户
@Component({...})
export class CreateItemComponent {
private store = inject(ItemStore);
private toast = inject(ToastService);
async create(data: CreateItemDto) {
try {
await this.store.crea
te(data);
this.toast.success('项目创建成功');
this.router.navigate(['/items']);
} catch (error) {
console.error('createItem 失败:', error);
this.toast.error('项目创建失败,请重试。');
}
}
}
// 错误示例 - 错误被静默捕获
async create(data: CreateItemDto) {
try {
await this.store.create(data);
} catch (error) {
console.error(error); // 用户端没有任何反馈!
}
}
### 错误状态组件模式 (Error State Component Pattern)@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">重试</button>
}
</div>
,})
export class ErrorStateComponent {
title = input("出错了");
message = input("发生了意外错误");
retry = output<void>();
}
---
按钮状态模式
按钮加载状态
### 操作期间禁用
关键点:在异步操作期间务必禁用触发控件。
// 正确示例 - 加载时禁用按钮
@Component({
template:
<button
[disabled]="saving()"
(click)="save()"
>
@if (saving()) {
<app-spinner size="sm" /> 保存中...
} @else {
保存
}
</button>
})
export class SaveButtonComponent {
saving = signal(false);
async save() {
this.saving.set(true);
try {
await this.service.save();
} finally {
this.saving.set(false);
}
}
}
// 错误示例 - 用户可以多次点击
<button (click)="save()">
{{ saving() ? '保存中...' : '保存' }}
</button>
---
空状态 (Empty States)
空状态要求
每个列表/集合必须具备空状态:
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<app-empty-state
icon="folder-open"
title="暂无项目"
description="创建您的第一个项目以开始使用"
actionLabel="创建项目"
(action)="openCreateDialog()"
/>
}
### 上下文空状态@Component({
selector: "app-empty-state",
template:
<div class="empty-state">
<span class="icon" [class]="icon()"></span>
<h3>{{ title() }}</h3>
<p>{{ description() }}</p>
@if (actionLabel()) {
<button (click)="action.emit()" class="btn-primary">
{{ actionLabel() }}
</button>
}
</div>
,})
export class EmptyStateComponent {
icon = input("inbox");
title = input.required<string>();
description = input("");
actionLabel = input<string | null>(null);
action = output<void>();
}
---
表单模式
带有加载和验证的表单
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-field">
<label for="name">名称</label>
<input
id="name"
formControlName="name"
[class.error]="isFieldInvalid('name')"
/>
@if (isFieldInvalid("name")) {
<span class="error-text">
{{ getFieldError(
"name") }}
</span>
}
</div>
<div class="form-field">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
@if (isFieldInvalid("email")) {
<span class="error-text">
{{ getFieldError("email") }}
</span>
}
</div>
<button type="submit" [disabled]="form.invalid || submitting()">
@if (submitting()) {
<app-spinner size="sm" /> 提交中...
} @else {
提交
}
</button>
</form>
,})
export class UserFormComponent {
private fb = inject(FormBuilder);
submitting = signal(false);
form = this.fb.group({
name: ["", [Validators.required, Validators.minLength(2)]],
email: ["", [Validators.required, Validators.email]],
});
isFieldInvalid(field: string): boolean {
const control = this.form.get(field);
return control ? control.invalid && control.touched : false;
}
getFieldError(field: string): string {
const control = this.form.get(field);
if (control?.hasError("required")) return "此字段为必填项";
if (control?.hasError("email")) return "邮箱格式不正确";
if (control?.hasError("minlength")) return "长度太短";
return "";
}
async onSubmit() {
if (this.form.invalid) return;
this.submitting.set(true);
try {
await this.service.submit(this.form.value);
this.toast.success("提交成功");
} catch {
this.toast.error("提交失败");
} finally {
this.submitting.set(false);
}
}
}
---
对话框/模态框模式
确认对话框
async confirm(options: {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
}): Promise<boolean> {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: options,
});
return await firstValueFrom(dialogRef.closed) ?? false;
}
}
// 使用示例
async deleteItem(item: Item) {
const confirmed = await this.dialog.confirm({
title: '删除项目',
message: 确定要删除 "${item.name}" 吗?,
confirmText: '删除',
});
if (confirmed) {
await this.store.delete(item.id);
}
}
---
反模式 (Anti-Patterns)
加载状态
// 正确 - 仅在没有数据时显示加载状态
@if (loading() && !items().length) {
<app-spinner />
}
### 错误处理// 错误 - 错误被吞掉
try {
await this.service.save();
} catch (e) {
console.log(e); // 用户完全不知道发生了什么!
}
// 正确 - 将错误反馈给用户
try {
await this.service.save();
} catch (e) {
console.error("保存失败:", e);
this.toast.error("保存失败,请稍后重试。");
}
### 按钮状态<!-- 错误 - 提交过程中按钮未禁用 -->
<button (click)="submit()">提交</button>
<!-- 正确 - 禁用按钮并显示加载状态 -->
<button (click)="submit()" [disabled]="loading()">
@if (loading()) {
<app-spinner size="sm" />
} 提交
</button>
``
---
UI 状态检查清单
在完成任何 UI 组件前,请确认:
UI 状态
- [ ] 错误状态已处理并向用户展示
- [ ] 加载状态仅在无数据时显示
- [ ] 空状态处理
- [ ] 异步操作期间禁用按钮
- [ ] 在适当情况下按钮显示加载指示器
数据与变更 (Data & Mutations)
- [ ] 所有异步操作均有错误处理
- [ ] 所有用户操作均有反馈(Toast/视觉提示)
- [ ] 乐观更新在失败时可回滚
无障碍访问 (Accessibility)
- [ ] 加载状态可被屏幕阅读器识别
- [ ] 错误消息与表单字段关联
- [ ] 状态变更后的焦点管理
---
与其他技能的集成
- angular-state-management: 使用 Signal stores 管理状态
- angular: 应用现代模式(Signals, @defer)
- testing-patterns: 测试所有 UI 状态
使用场景
本技能适用于执行概览中所描述的工作流或操作。局限性
- 仅在任务明确符合上述范围时使用此技能。
- 不要将输出结果视为环境特定验证、测试或专家评审的替代方案。
- 如果缺失必要的输入、权限、安全边界或验收标准,请停止并请求澄清。