news 2026/10/21 10:43:00

Angular UI Patterns 实战指南:用 Signals 与 @defer 构建健壮的加载、错误与数据展示状态

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Angular UI Patterns 实战指南:用 Signals 与 @defer 构建健壮的加载、错误与数据展示状态

Angular UI Patterns 实战指南:用 Signals 与 @defer 构建健壮的加载、错误与数据展示状态

【免费下载链接】agentic-awesome-skillsAAS Core is the local, agent-first control plane for complete catalog discovery, agent-owned selection, stack validation, and planning, backed by 2,600+ agentic skills. Includes CLI, local MCP, catalog, plugins, and Workbench.项目地址: https://gitcode.com/GitHub_Trending/an/agentic-awesome-skills

本文以 Agentic Awesome Skills 仓库中angular-ui-patterns技能的详细指南为主体,系统讲解现代 Angular 应用中 UI 状态设计的高阶模式:加载状态(Loading)、错误处理(Error Handling)、渐进式披露(Progressive Disclosure,基于@defer)、空状态(Empty State)、按钮与表单的异步状态,以及配套的可访问性与检查清单。读完本文,你将掌握一套"面向 AI Agent 与 LLM 友好、可直接落地"的 Angular UI 状态机设计方法,并能在 angular-ui-patterns 详细指南 与同仓库的 angular-state-management 、angular 技能之间形成完整的实战闭环。

一、技能定位与核心原则

angular-ui-patterns是 Agentic Awesome Skills 中面向"构建 UI 组件、处理异步数据、管理组件状态"场景的专项技能。根据其 SKILL.md 的定义,该技能在风险等级为 safe、来源为 self(2026-02-27 收录),专门解决以下问题:

  • Loading States:骨架屏(Skeleton)与加载动画(Spinner)的选择决策树
  • Error Handling:错误边界层级与恢复策略
  • Progressive Disclosure:用@defer实现按需渲染
  • Data Display:空、加载、错误三种数据展示状态
  • Form Patterns:表单提交状态与校验反馈
  • Dialog/Modal Patterns:对话框生命周期管理

该技能的全部操作细节沉淀在 references/detailed-guide.md 中,它是从SKILL.md抽取的"聚焦加载"参考文件——根技能定义激活条件、示例、安全约束与局限性,本文即围绕这份指南展开。

五条核心原则

  1. 永不展示陈旧 UI(Never show stale UI)——只有真正在加载时才显示加载状态;
  2. 始终暴露错误(Always surface errors)——用户必须知道何时失败;
  3. 乐观更新(Optimistic updates)——让界面感觉"瞬时响应";
  4. 渐进式披露(Progressive disclosure)——用@defer让内容按需出现;
  5. 优雅降级(Graceful degradation)——部分数据总比没有数据好。

这五条原则与同仓库 angular 技能 中 "Signals 取代 zone.js 变更检测"、"angular-state-management 的 Signal 状态分层"一脉相承:状态管理负责把数据与异步动作收敛到 Store,UI Patterns 负责把 Store 暴露出的loading/error/items三态正确地渲染出来。

二、加载状态模式(Loading State Patterns)

2.1 黄金法则

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

这是全文最重要的一条规则。它直接对应本文开头的 "Never show stale UI" 原则,也解释了为什么简单的@if (loading())写法是反模式。

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

注意这里的判断顺序本身就是一张决策树:

有错误吗? → 是:显示带重试按钮的错误状态 → 否:继续 正在加载 且 没有数据? → 是:显示加载指示器(spinner/skeleton) → 否:继续 有数据吗? → 有且包含条目:展示数据 → 有但为空:显示空状态 → 没有:显示加载(兜底)

从源码结构看,loading()与!items().length的&&组合是整个模式的精髓:当用户重新拉取数据(refetch)时,页面上已有旧数据,此时闪烁加载动画会造成"内容跳变",而保持旧数据可见、静默更新才是更好的体验。组件通过inject(ItemStore)直接消费 angular-state-management 中定义的 Signal Store(items、loading、error三个只读信号),模板中的@if/@else if链式判断取代了旧版*ngIf的嵌套地狱。

2.2 Skeleton 与 Spinner 的选择

使用 Skeleton(骨架屏)当……使用 Spinner(加载动画)当……
内容形状已知内容形状未知
列表/卡片布局模态框中的操作
页面首次加载按钮提交
内容占位符行内操作
  • Skeleton本质是"内容的形状预览",它告诉用户"马上会有一张卡片/一行列表出现在这里",因此适合列表页、卡片流、详情页首载;
  • Spinner只表达"正在忙",不透露内容形状,因此适合按钮内、模态框动作、任何无法预测结果形态的行内操作。

三、控制流模式(Control Flow Patterns)

3.1@if/@else条件渲染

Angular 内置控制流@if支持局部变量别名(as user),配合 Signal 输入非常干净:

@if (user(); as user) { <span>Welcome, {{ user.name }}</span> } @else if (loading()) { <app-spinner size="small" /> } @else { <a routerLink="/login">Sign In</a> }

@if (user(); as user)在条件为真时将 Signal 解包值绑定到局部变量user,避免在模板里重复调用user();登录态、加载态、未登录态三态一目了然。这与 angular 技能 中 Signal 输入input()/output()的写法完全兼容,属于新一代模板语法(v17+ 起内置,无需导入CommonModule)。

3.2@for搭配track

@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()" /> }

要点:

  • track item.id提供稳定的身份标识,让 Angular 最小化 DOM 复用与重建(等价于旧trackBy,但语法更简洁且必填,用于正确复用视图与状态);
  • @empty块是列表的空状态兜底——每个列表/集合必须有空状态是本文的另一条硬性要求,且它天然和@for配对,无需额外的*ngIf。

3.3@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" /> }

@defer的四个子块构成了完整的渐进式披露生命周期:

  • 主块:延迟加载的内容(本例中评论组件在进入视口时才渲染);
  • @placeholder:延迟期间立即显示的占位内容,可配合 Tailwind 的animate-pulse做骨架效果;
  • @loading (minimum 200ms):懒加载真正执行时的加载态,minimum 200ms避免"一闪而过"的加载闪烁;
  • @error:懒加载失败时的错误兜底。

在 angular 技能 中还能看到@defer (hydrate on viewport)/(hydrate on interaction)等增量水合(Incremental Hydration, v20+)写法,以及on idle/on timer(ms)等触发条件表——SSR 场景下@defer从"懒渲染"进一步升级为"懒水合",同一套心智模型可平滑迁移。

四、错误处理模式(Error Handling Patterns)

4.1 错误处理层级

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

选择策略的核心是"错误的严重程度与可恢复性":字段校验错误就地展示;可重试的操作失败用 Toast 提示并允许重试;页面级数据加载失败用横幅保留已展示部分;完全不可恢复(如整页崩溃)才用全屏错误页。

4.2 绝不静默吞掉错误

CRITICAL:永远不要把错误静默吞掉。

// 正确 - 错误始终暴露给用户 @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); // 用户什么都看不到! } }

注意正确版本的三层动作:成功给正反馈(toast.success)→ 失败既打日志(console.error 保留调试线索)又给用户可读提示(toast.error)。这与 angular-state-management 中UserStore.loadUser的try/catch/finally结构一致:catch 里把_error信号置为可读文案,finally里复位_loading——Store 负责记录错误,UI 负责把错误渲染出来。

4.3 可复用错误状态组件

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

两个值得学习的细节:

  1. retry输出配合@if (retry.observed)判断父组件是否绑定了(retry)事件——只有提供了重试回调时才渲染"Try Again"按钮,组件可同时用于"可重试"与"仅展示"两种场景;
  2. 图片使用NgOptimizedImage的ngSrc并显式声明width/height,避免布局偏移(CLS),与 angular 技能 中priority、loading="lazy"、placeholder="blur"的图像优化建议相呼应。

五、按钮状态模式(Button State Patterns)

5.1 按钮加载态

<button (click)="handleSubmit()" [disabled]="isSubmitting() || !form.valid" class="btn-primary" > @if (isSubmitting()) { <app-spinner size="small" class="mr-2" /> Saving... } @else { Save Changes } </button>

按钮的禁用条件往往是复合的:isSubmitting() || !form.valid——既防重复提交,又阻止无效表单提交。

5.2 异步操作期间必须禁用触发器

CRITICAL:异步操作期间始终禁用触发控件。

// 正确 - 加载期间按钮被禁用 @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>

错误的版本只切换了文案而没有真正disabled,用户依然可以连续点击触发多次请求。正确版本的关键是try/finally:无论成功失败都必须在finally中复位saving信号,否则按钮会永久卡在禁用态。

六、空状态(Empty States)

6.1 空状态是硬性要求

每个列表/集合都必须有空状态,且优先用@for的@empty块实现:

@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()" /> }

6.2 上下文感知的空状态组件

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

组件设计要点:title必填、icon/description有默认值、actionLabel默认为null——只有当父组件传入操作文案时才渲染 CTA 按钮(与错误状态组件的retry.observed思路一致),这让空状态既能"纯展示",也能"引导下一步动作"。

七、表单模式(Form Patterns)

表单是加载、校验、提交三态的集大成者。完整示例:

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

要点拆解:

  1. 校验时机:isFieldInvalid用control.invalid && control.touched,即"只有用户触碰过才报错",避免表单一渲染就满屏红字;
  2. 错误信息集中化:getFieldError按hasError优先级返回人类可读文案,模板只做展示;
  3. 提交保护:form.invalid || submitting()双重门禁,配合finally复位submitting;
  4. 行内错误 + Toast 组合:字段级错误行内展示(对应错误层级第 1 层),提交失败用 Toast(第 2 层)。

八、对话框/模态框模式(Dialog/Modal Patterns)

确认对话框是异步 UI 的经典场景——"确认 → 等待关闭 → 决定是否继续执行"需要把 Dialog 的关闭事件转成 Promise:

// 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: 'Delete Item', message: `Are you sure you want to delete "${item.name}"?`, confirmText: 'Delete', }); if (confirmed) { await this.store.delete(item.id); } }

firstValueFrom(dialogRef.closed)把对话框的关闭流收敛为一个Promise<boolean>,业务代码可以用await顺序化"确认-删除"流程,彻底避免回调地狱。这里的store.delete同样可以配合乐观更新与失败回滚——angular-state-management 中的deleteProduct展示了"先本地移除、失败后恢复 previousData"的完整回滚范式。

九、反模式速查(Anti-Patterns)

主题错误写法正确写法
加载状态@if (loading())无脑显示 Spinner,refetch 时闪烁@if (loading() && !items().length)只有无数据才显示
错误处理catch (e) { console.log(e); }用户毫无感知console.error+toast.error,错误必须可视化
按钮状态只切文案不disabled,可重复提交[disabled]="loading()"+ 加载中显示 Spinner

这三组反模式本质是同一件事的三个侧面:用户永远不该面对"闪烁的旧数据、无声的失败、可重复触发的操作"。

十、UI 状态检查清单

完成任何 UI 组件前,逐项自检:

UI 状态

  • 错误状态已处理并向用户展示
  • 仅在无数据时显示加载状态
  • 集合类组件提供空状态(@empty块)
  • 异步操作期间按钮已禁用
  • 按钮在适当时机显示加载指示器

数据与变更

  • 所有异步操作都有错误处理
  • 所有用户操作都有反馈(toast/视觉提示)
  • 乐观更新失败时回滚

可访问性(A11y)

  • 加载状态对屏幕阅读器播报
  • 错误信息与对应表单字段关联
  • 状态变更后的焦点管理

十一、与其他技能的协同

该技能在仓库内明确声明与三个技能联动:

  • angular-state-management:用 Signal Store(signal()+computed()或 NgRx SignalStore)承载状态——UI 层只消费loading/error/items等只读信号;
  • angular:应用现代模式(Signals、Standalone、Zoneless、@defer),本指南中的input()/output()/inject()全部属于该技能覆盖的现代 API;
  • testing-patterns:对全部 UI 状态(error/loading/empty/data)编写测试。

三者构成完整链路:状态管理负责"数据怎么来",UI Patterns 负责"状态怎么画",测试负责"画得对不对"。调用时建议先阅读 angular-ui-patterns 的 SKILL.md 确认激活条件与安全边界,再按需加载本指南对应章节;端到端任务则应完整通读。

结语

从"黄金法则"(无数据才显示加载)到@defer的渐进式披露,从错误层级到乐观更新回滚,angular-ui-patterns提供的不是零散技巧,而是一套可判定的状态机设计规范。它要求开发者始终回答三个问题:用户当前看到的是不是最新数据?失败是否可见?操作是否可重复触发?当你把这三问内化为习惯,再配合 Signal Store 的统一三态输出,就能构建出既符合现代 Angular(Signals + 内置控制流 + Standalone)又对 AI Agent 友好的健壮界面——这也是本技能作为 Agentic Awesome Skills 中可被 LLM 直接检索、执行的高密度参考文档的价值所在。

【免费下载链接】agentic-awesome-skillsAAS Core is the local, agent-first control plane for complete catalog discovery, agent-owned selection, stack validation, and planning, backed by 2,600+ agentic skills. Includes CLI, local MCP, catalog, plugins, and Workbench.项目地址: https://gitcode.com/GitHub_Trending/an/agentic-awesome-skills

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 10:42:02

算法思维体操:基于generator生成器自己实现Async/Await(TypeScript)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 10:38:20

达梦数据库创建用户和授权

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 10:36:57

CPU 和主板芯片组对应关系

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 10:34:35

自己挖的坑自己填--jxl进行Excel下载堆内存溢出问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 10:33:02

GD32H759 RT-Thread USB CDC ACM虚拟串口移植实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 10:32:08

计量芯片通讯报文校验和机制:量产隐患与排查优化全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华