Angular UI 模式

angular-ui-patterns
分类数据
作者Agentic Awesome Skills 社区
许可MIT
评分4.30/5
使用13.6K

Angular UI 模式

核心原则

1. 绝不显示过时 UI - 仅在实际加载时显示加载状态
2. 始终呈现错误 - 用户必须知道何时发生了失败
3. 乐观更新 - 让 UI 响应感觉瞬间完成
4. 渐进式披露 - 使用 @defer 在内容可用时显示
5. 优雅降级 - 部分数据好过没有数据

---

加载状态模式

金科玉律

仅在没有可显示数据时才显示加载指示器。

typescript
@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;
}

加载状态决策树

code
是否有错误?
  → 是:显示带有重试选项的错误状态
  → 否:继续

是否正在加载 且 没有数据?
→ 是:显示加载指示器(加载圈/骨架屏)
→ 否:继续

是否有数据?
→ 是,且有项目:显示数据
→ 是,但为空:显示空状态
→ 否:显示加载(兜底方案)

骨架屏 (Skeleton) vs 加载圈 (Spinner)

| 使用骨架屏的场景 | 使用加载圈的场景 |
| -------------------- | --------------------- |
| 内容形状已知 | 内容形状未知 |
| 列表/卡片布局 | 模态框操作 |
| 页面首次加载 | 按钮提交 |
| 内容占位符 | 行内操作 |

---

控制流模式

用于条件渲染的 @if/@else

html
@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

html
@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

html
<!-- 关键内容立即加载 -->
<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" />
}

---

错误处理模式

错误处理层级

code
1. 行内错误(字段级) → 表单验证错误
2. Toast 通知 → 可恢复错误,用户可以重试
3. 错误横幅 (Banner) → 页面级错误,数据仍可部分使用
4. 全屏错误页 → 不可恢复,需要用户采取行动

始终显示错误

至关重要:绝不要静默处理错误。

typescript
// 正确 - 错误始终呈现给用户
@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); // 用户端没有任何反馈!
}
}

code
### 错误状态组件模式 (Error State Component Pattern)
typescript
@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>();
}
code
---

按钮状态模式

按钮加载状态

html <button (click)="handleSubmit()" [disabled]="isSubmitting() || !form.valid" class="btn-primary" > @if (isSubmitting()) { <app-spinner size="small" class="mr-2" /> 保存中... } @else { 保存更改 } </button>
code
### 操作期间禁用

关键点:在异步操作期间务必禁用触发控件。

typescript
// 正确示例 - 加载时禁用按钮
@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>

code
---

空状态 (Empty States)

空状态要求

每个列表/集合必须具备空状态:

html
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<app-empty-state
icon="folder-open"
title="暂无项目"
description="创建您的第一个项目以开始使用"
actionLabel="创建项目"
(action)="openCreateDialog()"
/>
}
code
### 上下文空状态
typescript
@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>();
}
code
---

表单模式

带有加载和验证的表单

typescript @Component({ template: <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);
}
}
}

code
---

对话框/模态框模式

确认对话框

typescript // dialog.service.ts @Injectable({ providedIn: 'root' }) export class DialogService { private dialog = inject(Dialog); // CDK Dialog 或自定义实现

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);
}
}

code
---

反模式 (Anti-Patterns)

加载状态

typescript // 错误 - 数据存在时仍显示加载动画(会导致重新获取数据时闪烁) @if (loading()) { <app-spinner /> }

// 正确 - 仅在没有数据时显示加载状态
@if (loading() && !items().length) {
<app-spinner />
}

code
### 错误处理
typescript
// 错误 - 错误被吞掉
try {
await this.service.save();
} catch (e) {
console.log(e); // 用户完全不知道发生了什么!
}

// 正确 - 将错误反馈给用户
try {
await this.service.save();
} catch (e) {
console.error("保存失败:", e);
this.toast.error("保存失败,请稍后重试。");
}

code
### 按钮状态
html
<!-- 错误 - 提交过程中按钮未禁用 -->
<button (click)="submit()">提交</button>

<!-- 正确 - 禁用按钮并显示加载状态 -->
<button (click)="submit()" [disabled]="loading()">
@if (loading()) {
<app-spinner size="sm" />
} 提交
</button>
``

---

UI 状态检查清单

在完成任何 UI 组件前,请确认:

UI 状态

  • [ ] 错误状态已处理并向用户展示
  • [ ] 加载状态仅在无数据时显示
  • [ ] 空状态处理
集合的空状态处理(
@empty` 块)
  • [ ] 异步操作期间禁用按钮
  • [ ] 在适当情况下按钮显示加载指示器

数据与变更 (Data & Mutations)

  • [ ] 所有异步操作均有错误处理
  • [ ] 所有用户操作均有反馈(Toast/视觉提示)
  • [ ] 乐观更新在失败时可回滚

无障碍访问 (Accessibility)

  • [ ] 加载状态可被屏幕阅读器识别
  • [ ] 错误消息与表单字段关联
  • [ ] 状态变更后的焦点管理

---

与其他技能的集成

  • angular-state-management: 使用 Signal stores 管理状态
  • angular: 应用现代模式(Signals, @defer)
  • testing-patterns: 测试所有 UI 状态

使用场景

本技能适用于执行概览中所描述的工作流或操作。

局限性

  • 仅在任务明确符合上述范围时使用此技能。
  • 不要将输出结果视为环境特定验证、测试或专家评审的替代方案。
  • 如果缺失必要的输入、权限、安全边界或验收标准,请停止并请求澄清。