angular-ui-patterns - SKILL

发布时间:2026/10/26 1:42:28
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 用户界面模式

核心原则

  1. 绝不显示过时的界面- 仅在实际加载时显示加载状态
  2. 始终显示错误- 用户必须知道何时出现故障
  3. 乐观更新- 让界面感觉即时响应
  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()" /> }`,})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 WhenUse Spinner When
Known content shapeUnknown content shape
List/card layoutsModal actions
Initial page loadButton submissions
Content placeholdersInline 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)
  • 测试模式:测试所有用户界面状态

何时使用

此技能可用于执行概述中描述的工作流程或操作。

限制

  • 仅当任务明确符合上述描述的范围时,才使用此技能。
  • 不要将输出视为环境特定验证、测试或专家审查的替代品。
  • 如果缺少所需的输入、权限、安全界限或成功标准,请停下来并要求澄清。