angular-ui-patterns - SKILL
name: angular-ui-patterns
description: “Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.”
risk: safe
source: self
date_added: “2026-02-27”
Angular 用户界面模式
核心原则
- 绝不显示过时的界面- 仅在实际加载时显示加载状态
- 始终显示错误- 用户必须知道何时出现故障
- 乐观更新- 让界面感觉即时响应
- 渐进式披露- 使用
@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()" /> }`,})exportclassItemListComponent{privatestore=inject(ItemStore);items=this.store.items;loading=this.store.loading;error=this.store.error;}加载状态决策树
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)骷髅 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 |
控制流模式
@if/@else 用于条件渲染
@if (user(); as user) {<span>Welcome, {{ user.name }}</span>} @else if (loading()) {<app-spinnersize="small"/>} @else {<arouterLink="/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-stateicon="inbox"message="No items yet"actionLabel="Create Item"(action)="create()"/>}@defer 用于渐进式加载
<!-- Critical content loads immediately --><app-header/><app-hero-section/><!-- Non-critical content deferred -->@defer (on viewport) {<app-comments[postId]="postId()"/>} @placeholder {<divclass="h-32 bg-gray-100 animate-pulse"></div>} @loading (minimum 200ms) {<app-spinner/>} @error {<app-error-statemessage="Failed to load comments"/>}错误处理模式
错误处理层级
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 action始终显示错误
严重:绝不默默吞下错误。
// CORRECT - Error always surfaced to user@Component({...})exportclassCreateItemComponent{privatestore=inject(ItemStore);privatetoast=inject(ToastService);asynccreate(data:CreateItemDto){try{awaitthis.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 caughtasynccreate(data:CreateItemDto){try{awaitthis.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>`,})exportclassErrorStateComponent{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-spinnersize="small"class="mr-2"/>Saving... } @else { Save Changes }</button>操作期间禁用
重要:在异步操作期间始终禁用触发器。
// CORRECT - Button disabled while loading@Component({template:`<button [disabled]="saving()" (click)="save()" > @if (saving()) { <app-spinner size="sm" /> Saving... } @else { Save } </button>`})exportclassSaveButtonComponent{saving=signal(false);asyncsave(){this.saving.set(true);try{awaitthis.service.save();}finally{this.saving.set(false);}}}// WRONG - User can click multiple times<button(click)="save()">{{saving()?'Saving...':'Save'}}</button>空状态
空状态要求
每个列表/集合必须有一个空状态:
@for (item of items(); track item.id) {<app-item-card[item]="item"/>} @empty {<app-empty-stateicon="folder-open"title="No items yet"description="Create your first item to get started"actionLabel="Create Item"(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>`,})exportclassEmptyStateComponent{icon=input("inbox");title=input.required<string>();description=input("");actionLabel=input<string|null>(null);action=output<void>();}表单模式
带加载和验证的表单
@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>`,})exportclassUserFormComponent{privatefb=inject(FormBuilder);submitting=signal(false);form=this.fb.group({name:["",[Validators.required,Validators.minLength(2)]],email:["",[Validators.required,Validators.email]],});isFieldInvalid(field:string):boolean{constcontrol=this.form.get(field);returncontrol?control.invalid&&control.touched:false;}getFieldError(field:string):string{constcontrol=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"";}asynconSubmit(){if(this.form.invalid)return;this.submitting.set(true);try{awaitthis.service.submit(this.form.value);this.toast.success("Submitted successfully");}catch{this.toast.error("Submission failed");}finally{this.submitting.set(false);}}}对话/模态模式
确认对话框
// dialog.service.ts@Injectable({providedIn:'root'})exportclassDialogService{privatedialog=inject(Dialog);// CDK Dialog or customasyncconfirm(options:{title:string;message:string;confirmText?:string;cancelText?:string;}):Promise<boolean>{constdialogRef=this.dialog.open(ConfirmDialogComponent,{data:options,});returnawaitfirstValueFrom(dialogRef.closed)??false;}}// UsageasyncdeleteItem(item:Item){constconfirmed=awaitthis.dialog.confirm({title:'Delete Item',message:`Are you sure you want to delete "${item.name}"?`,confirmText:'Delete',});if(confirmed){awaitthis.store.delete(item.id);}}反模式
加载状态
// 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/>}错误处理
// WRONG - Error swallowedtry{awaitthis.service.save();}catch(e){console.log(e);// User has no idea!}// CORRECT - Error surfacedtry{awaitthis.service.save();}catch(e){console.error("Save failed:",e);this.toast.error("Failed to save. Please try again.");}按钮状态
<!-- WRONG - Button not disabled during submission --><button(click)="submit()">Submit</button><!-- CORRECT - Disabled and shows loading --><button(click)="submit()"[disabled]="loading()">@if (loading()) {<app-spinnersize="sm"/>} Submit</button>UI 状态检查表
在完成任何UI组件之前:
UI 状态
- 错误状态已处理并显示给用户
- 仅在没有数据时显示加载状态
- 为集合提供的空状态(
@empty块) - 在异步操作期间按钮被禁用
- 按钮在适当时显示加载指示器
数据与变异
- 所有异步操作都有错误处理
- 所有用户操作都有反馈(提示/视觉)
- 在失败时回滚乐观更新
无障碍性
- 加载状态已向屏幕阅读器宣布
- 与表单字段相关的错误消息
- 状态更改后的焦点管理
与其他技能的整合
- angular-state-management: 使用 Signal 存储来管理状态
- angular:应用现代模式(Signals,@defer)
- 测试模式:测试所有用户界面状态
何时使用
此技能可用于执行概述中描述的工作流程或操作。
限制
- 仅当任务明确符合上述描述的范围时,才使用此技能。
- 不要将输出视为环境特定验证、测试或专家审查的替代品。
- 如果缺少所需的输入、权限、安全界限或成功标准,请停下来并要求澄清。