angular-ui-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAngular UI Patterns
Angular UI 模式
Core Principles
核心原则
- Never show stale UI - Loading states only when actually loading
- Always surface errors - Users must know when something fails
- Optimistic updates - Make the UI feel instant
- Progressive disclosure - Use to show content as available
@defer - Graceful degradation - Partial data is better than no data
- 绝不显示过时UI - 仅在实际加载时显示加载状态
- 始终暴露错误 - 用户必须知晓何时出现故障
- 乐观更新 - 让UI体验更即时
- 渐进式披露 - 使用在内容可用时展示
@defer - 优雅降级 - 部分数据优于无数据
Loading State Patterns
加载状态模式
The Golden Rule
黄金法则
Show loading indicator ONLY when there's no data to display.
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;
}仅在无数据可展示时显示加载指示器。
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;
}Loading State Decision Tree
加载状态决策树
Is there an error?
→ Yes: Show error state with retry option
→ No: Continue
Is it loading AND we have no data?
→ Yes: Show loading indicator (spinner/skeleton)
→ No: Continue
Do we have data?
→ Yes, with items: Show the data
→ Yes, but empty: Show empty state
→ No: Show loading (fallback)是否存在错误?
→ 是:显示带重试选项的错误状态
→ 否:继续
是否正在加载且无数据?
→ 是:显示加载指示器(加载动画/骨架屏)
→ 否:继续
是否有数据?
→ 是且有内容:展示数据
→ 是但为空:显示空状态
→ 否:显示加载状态(兜底)Skeleton vs Spinner
骨架屏 vs 加载动画
| Use Skeleton When | Use Spinner When |
|---|---|
| Known content shape | Unknown content shape |
| List/card layouts | Modal actions |
| Initial page load | Button submissions |
| Content placeholders | Inline operations |
| 使用骨架屏的场景 | 使用加载动画的场景 |
|---|---|
| 已知内容形状 | 未知内容形状 |
| 列表/卡片布局 | 弹窗操作 |
| 初始页面加载 | 按钮提交操作 |
| 内容占位符 | 内联操作 |
Control Flow Patterns
控制流模式
@if/@else for Conditional Rendering
使用@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>
}html
@if (user(); as user) {
<span>Welcome, {{ user.name }}</span>
} @else if (loading()) {
<app-spinner size="small" />
} @else {
<a routerLink="/login">Sign In</a>
}@for with Track
带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()"
/>
}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 for Progressive Loading
使用@defer实现渐进式加载
html
<!-- Critical content loads immediately -->
<app-header />
<app-hero-section />
<!-- Non-critical content deferred -->
@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" />
}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" />
}Error Handling Patterns
错误处理模式
Error Handling Hierarchy
错误处理层级
1. Inline error (field-level) → Form validation errors
2. Toast notification → Recoverable errors, user can retry
3. Error banner → Page-level errors, data still partially usable
4. Full error screen → Unrecoverable, needs user action1. 内联错误(字段级)→ 表单验证错误
2. 提示弹窗 → 可恢复错误,用户可重试
3. 错误横幅 → 页面级错误,数据仍可部分使用
4. 全屏错误页 → 不可恢复,需要用户操作Always Show Errors
始终展示错误
CRITICAL: Never swallow errors silently.
typescript
// 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!
}
}关键:绝不能静默吞掉错误。
typescript
// 正确做法 - 错误始终暴露给用户
@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.');
}
}
}
// 错误做法 - 错误被静默捕获
async create(data: CreateItemDto) {
try {
await this.store.create(data);
} catch (error) {
console.error(error); // 用户完全不知情!
}
}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">Try Again</button>
}
</div>
`,
})
export class ErrorStateComponent {
title = input("Something went wrong");
message = input("An unexpected error occurred");
retry = output<void>();
}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">Try Again</button>
}
</div>
`,
})
export class ErrorStateComponent {
title = input("Something went wrong");
message = input("An unexpected error occurred");
retry = output<void>();
}Button State Patterns
按钮状态模式
Button Loading State
按钮加载状态
html
<button
(click)="handleSubmit()"
[disabled]="isSubmitting() || !form.valid"
class="btn-primary"
>
@if (isSubmitting()) {
<app-spinner size="small" class="mr-2" />
Saving... } @else { Save Changes }
</button>html
<button
(click)="handleSubmit()"
[disabled]="isSubmitting() || !form.valid"
class="btn-primary"
>
@if (isSubmitting()) {
<app-spinner size="small" class="mr-2" />
Saving... } @else { Save Changes }
</button>Disable During Operations
操作期间禁用按钮
CRITICAL: Always disable triggers during async operations.
typescript
// CORRECT - Button disabled while loading
@Component({
template: `
<button
[disabled]="saving()"
(click)="save()"
>
@if (saving()) {
<app-spinner size="sm" /> Saving...
} @else {
Save
}
</button>
`
})
export class SaveButtonComponent {
saving = signal(false);
async save() {
this.saving.set(true);
try {
await this.service.save();
} finally {
this.saving.set(false);
}
}
}
// WRONG - User can click multiple times
<button (click)="save()">
{{ saving() ? 'Saving...' : 'Save' }}
</button>关键:异步操作期间必须禁用触发按钮。
typescript
// 正确做法 - 加载时按钮禁用
@Component({
template: `
<button
[disabled]="saving()"
(click)="save()"
>
@if (saving()) {
<app-spinner size="sm" /> Saving...
} @else {
Save
}
</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() ? 'Saving...' : 'Save' }}
</button>Empty States
空状态
Empty State Requirements
空状态要求
Every list/collection MUST have an empty state:
html
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<app-empty-state
icon="folder-open"
title="No items yet"
description="Create your first item to get started"
actionLabel="Create Item"
(action)="openCreateDialog()"
/>
}每个列表/集合都必须设置空状态:
html
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<app-empty-state
icon="folder-open"
title="No items yet"
description="Create your first item to get started"
actionLabel="Create Item"
(action)="openCreateDialog()"
/>
}Contextual Empty States
上下文空状态
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>();
}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>();
}Form Patterns
表单模式
Form with Loading and Validation
带加载和验证的表单
typescript
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-field">
<label for="name">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" /> Submitting...
} @else {
Submit
}
</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 "This field is required";
if (control?.hasError("email")) return "Invalid email format";
if (control?.hasError("minlength")) return "Too short";
return "";
}
async onSubmit() {
if (this.form.invalid) return;
this.submitting.set(true);
try {
await this.service.submit(this.form.value);
this.toast.success("Submitted successfully");
} catch {
this.toast.error("Submission failed");
} finally {
this.submitting.set(false);
}
}
}typescript
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-field">
<label for="name">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" /> Submitting...
} @else {
Submit
}
</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 "This field is required";
if (control?.hasError("email")) return "Invalid email format";
if (control?.hasError("minlength")) return "Too short";
return "";
}
async onSubmit() {
if (this.form.invalid) return;
this.submitting.set(true);
try {
await this.service.submit(this.form.value);
this.toast.success("Submitted successfully");
} catch {
this.toast.error("Submission failed");
} finally {
this.submitting.set(false);
}
}
}Dialog/Modal Patterns
弹窗/模态框模式
Confirmation Dialog
确认弹窗
typescript
// dialog.service.ts
@Injectable({ providedIn: 'root' })
export class DialogService {
private dialog = inject(Dialog); // CDK Dialog or custom
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;
}
}
// Usage
async deleteItem(item: Item) {
const confirmed = await this.dialog.confirm({
title: 'Delete Item',
message: `Are you sure you want to delete "${item.name}"?`,
confirmText: 'Delete',
});
if (confirmed) {
await this.store.delete(item.id);
}
}typescript
// dialog.service.ts
@Injectable({ providedIn: 'root' })
export class DialogService {
private dialog = inject(Dialog); // CDK Dialog or custom
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: 'Delete Item',
message: `Are you sure you want to delete "${item.name}"?`,
confirmText: 'Delete',
});
if (confirmed) {
await this.store.delete(item.id);
}
}Anti-Patterns
反模式
Loading States
加载状态反模式
typescript
// WRONG - Spinner when data exists (causes flash on refetch)
@if (loading()) {
<app-spinner />
}
// CORRECT - Only show loading without data
@if (loading() && !items().length) {
<app-spinner />
}typescript
// 错误做法 - 已有数据时仍显示加载动画(重新获取数据时会出现闪烁)
@if (loading()) {
<app-spinner />
}
// 正确做法 - 仅在无数据时显示加载动画
@if (loading() && !items().length) {
<app-spinner />
}Error Handling
错误处理反模式
typescript
// WRONG - Error swallowed
try {
await this.service.save();
} catch (e) {
console.log(e); // User has no idea!
}
// CORRECT - Error surfaced
try {
await this.service.save();
} catch (e) {
console.error("Save failed:", e);
this.toast.error("Failed to save. Please try again.");
}typescript
// 错误做法 - 错误被吞掉
try {
await this.service.save();
} catch (e) {
console.log(e); // 用户完全不知情!
}
// 正确做法 - 错误暴露给用户
try {
await this.service.save();
} catch (e) {
console.error("Save failed:", e);
this.toast.error("Failed to save. Please try again.");
}Button States
按钮状态反模式
html
<!-- WRONG - Button not disabled during submission -->
<button (click)="submit()">Submit</button>
<!-- CORRECT - Disabled and shows loading -->
<button (click)="submit()" [disabled]="loading()">
@if (loading()) {
<app-spinner size="sm" />
} Submit
</button>html
<!-- 错误做法 - 提交期间按钮未禁用 -->
<button (click)="submit()">Submit</button>
<!-- 正确做法 - 加载时禁用并显示加载状态 -->
<button (click)="submit()" [disabled]="loading()">
@if (loading()) {
<app-spinner size="sm" />
} Submit
</button>UI State Checklist
UI状态检查清单
Before completing any UI component:
完成任何UI组件前,请检查:
UI States
UI状态
- Error state handled and shown to user
- Loading state shown only when no data exists
- Empty state provided for collections (block)
@empty - Buttons disabled during async operations
- Buttons show loading indicator when appropriate
- 错误状态已处理并展示给用户
- 仅在无数据时显示加载状态
- 集合已设置空状态(块)
@empty - 异步操作期间按钮已禁用
- 按钮在合适时机显示加载指示器
Data & Mutations
数据与变更
- All async operations have error handling
- All user actions have feedback (toast/visual)
- Optimistic updates rollback on failure
- 所有异步操作都有错误处理
- 所有用户操作都有反馈(提示弹窗/视觉反馈)
- 乐观更新失败时会回滚
Accessibility
可访问性
- Loading states announced to screen readers
- Error messages linked to form fields
- Focus management after state changes
- 加载状态已告知屏幕阅读器
- 错误消息与表单字段关联
- 状态变更后已处理焦点管理
Integration with Other Skills
与其他技能的集成
- angular-state-management: Use Signal stores for state
- angular: Apply modern patterns (Signals, @defer)
- testing-patterns: Test all UI states
- angular-state-management: 使用Signal存储管理状态
- angular: 应用现代模式(Signals、@defer)
- testing-patterns: 测试所有UI状态