news 2026/9/8 22:16:40

在 Angular 服务中使用 Signal:用只读信号与 computed 派生状态构建共享购物车

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
在 Angular 服务中使用 Signal:用只读信号与 computed 派生状态构建共享购物车

在 Angular 服务中使用 Signal:用只读信号与 computed 派生状态构建共享购物车

【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular

Angular 的 Signals 教程进入服务层,解决的是这样一个实际需求:当多个组件需要共享同一份响应式状态(例如购物车)时,把状态放进一个可注入的服务(Service)中,并通过signalasReadonlycomputed组合出"可写源头 + 只读对外暴露 + 自动派生的汇总值"这一套干净的模式。本文基于 Angular 仓库中 Signals 教程第 7 步(7-using-signals-with-services/README.md)展开,你将学会:如何在服务内部维护私有可写信号、如何用asReadonly()防止外部篡改、如何用computed()自动算出购物车总件数与总价,以及组件如何通过inject()拿到服务并让 UI 在状态变化时自动刷新。

为什么要把 Signal 放进 Service

在继续本步骤之前,教程假设你已经掌握了 model 信号的双向绑定。在此基础上引入服务的核心原因有两个:

  • 服务适合承载跨组件的共享状态:组件彼此独立,但如果购物车这种状态只存在某个组件里,兄弟组件就无法读取。
  • Signal 让共享状态天然"自动更新":把可写信号私有化在服务内,再把派生值与只读视图暴露给外部,任何组件写入后,所有读取该状态的模板与computed都会自动收到变更通知,无需手动调用变更检测。

从官方教程的定位来看,这正是 Signals 从"单组件内部状态"走向"应用级响应式状态管理"的关键一步。

教程配套的项目结构

本步骤是一个完整的迷你购物车应用,仓库中保留了练习起点(src/)与参考答案(answer/)两份代码,对应的目录结构为:

  • cart-types.ts:定义CartItem数据模型;
  • cart-store.ts:承载购物车状态的CartStore服务;
  • cart-display.ts:负责展示与操作购物车的组件;
  • app.ts:根组件,注入CartStore并挂载<cart-display />

其中商品条目类型定义如下:

// cart-types.ts export interface CartItem { id: string; name: string; price: number; quantity: number; }

整个购物车的状态本质上是CartItem[],接下来要做的三件事,正是本步骤的三个练习任务。

第一步:为 CartStore 添加只读信号与 computed 派生信号

练习起点中的 cart-store.ts 已经用signal声明了私有的商品列表,但还没有暴露任何只读或派生信号:

// TODO: Import computed from @angular/core import {Service, signal} from '@angular/core'; import {CartItem} from './cart-types'; @Service() export class CartStore { private items = signal<CartItem[]>([]); // ... 各操作方法 }

第一个任务就是补充如下三个信号(完整的实现见 answer/src/app/cart-store.ts):

// 先补充 computed 的导入 import {Service, signal, computed} from '@angular/core'; // 再在类中添加这些信号: // 只读信号:只暴露读取能力 readonly cartItems = this.items.asReadonly(); // 派生信号:购物车总件数 readonly totalQuantity = computed(() => { return this.items().reduce((sum, item) => sum + item.quantity, 0); }); // 派生信号:购物车总价 readonly totalPrice = computed(() => { return this.items().reduce((sum, item) => sum + item.price * item.quantity, 0); });

三个声明各有用意:

  • asReadonly()起到访问控制作用cartItems虽然来自私有信号items,但通过asReadonly()包装后,外部组件只能读取、不能直接调用set/update修改商品列表,写操作被严格收敛到服务的方法里。
  • computed()用于派生状态totalQuantitytotalPrice都是对items的纯函数派生:当底层信号变化时它们会自动重新求值,模板里读取到的永远是实时汇总值。
  • 从 Angular 响应式核心看,computedsignal的实现都位于 packages/core/src/render3/reactivity/(如 signal.ts 定义可写信号 API 与asReadonly包装,computed.ts 实现派生计算节点)。如果你想了解这些声明背后的依赖追踪与"惰性求值 + 结果缓存"机制,可以直接阅读这两个文件。

理解 CartStore 的完整写入策略:状态写操作封闭在服务内部

教程第二步的练习起点(cart-store.ts)保留了四个完整的写操作方法,它们与上述只读/派生信号共同构成了服务的完整闭环。答案代码展示的写入逻辑为:

@Service() export class CartStore { private items = signal<CartItem[]>([]); readonly cartItems = this.items.asReadonly(); readonly totalQuantity = computed(() => { return this.items().reduce((sum, item) => sum + item.quantity, 0); }); readonly totalPrice = computed(() => { return this.items().reduce((sum, item) => sum + item.price * item.quantity, 0); }); // 新增或追加同一商品的数量 addItem(id: string, name: string, price: number) { this.items.update((currentItems) => { const existingItem = currentItems.find((item) => item.id === id); if (existingItem) { // 已存在:以不可变方式替换该商品并让数量 +1 return currentItems.map((item) => item.id === id ? {...item, quantity: item.quantity + 1} : item, ); } else { // 不存在:追加一条新记录 return [...currentItems, {id, name, price, quantity: 1}]; } }); } // 删除某个商品 removeItem(id: string) { this.items.update((currentItems) => currentItems.filter((item) => item.id !== id)); } // 更新数量;数量小于等于 0 时直接移除该商品 updateQuantity(id: string, quantity: number) { if (quantity <= 0) { this.removeItem(id); return; } this.items.update((currentItems) => currentItems.map((item) => (item.id === id ? {...item, quantity} : item)), ); } // 清空购物车 clearCart() { this.items.set([]); } }

这里有几点值得留意:

  • 两种更新原语交替使用update((currentItems) => ...)依赖当前值产生新数组,适合"在旧状态基础上计算"的追加、删除、改数量场景;set([])直接覆盖整个状态,适合清空场景。
  • 坚持不可变更新:所有修改都返回新数组(mapfilter、展开运算符),绝不原地改动CartItem对象,这是 Signal 变更检测能够可靠识别"值变了"的前提。
  • 边界条件被内置到服务updateQuantity遇到quantity <= 0会转成删除,这意味着调用方(组件)可以放心地把数量 - 1的结果传进来,由服务兜底处理。

第二步:在 CartDisplay 中完成数量增减方法

练习起点(cart-display.ts)里,increaseQuantitydecreaseQuantity两个方法只剩 TODO,模板与其余方法均已就绪:

@Component({ selector: 'cart-display', template: `...`, // 含加购按钮、购物车列表 @for、汇总区 }) export class CartDisplay { cartStore = inject(CartStore); // TODO: 从 cartStore.cartItems() 取当前条目并调用 updateQuantity 数量 +1 increaseQuantity(id: string) {} // TODO: 从 cartStore.cartItems() 取当前条目并调用 updateQuantity 数量 -1(需 > 1) decreaseQuantity(id: string) {} }

参考答案给出的实现是(完整文件见 answer/src/app/cart-display.ts):

increaseQuantity(id: string) { const items = this.cartStore.cartItems(); const currentItem = items.find((item) => item.id === id); if (currentItem) { this.cartStore.updateQuantity(id, currentItem.quantity + 1); } } decreaseQuantity(id: string) { const items = this.cartStore.cartItems(); const currentItem = items.find((item) => item.id === id); if (currentItem && currentItem.quantity > 1) { this.cartStore.updateQuantity(id, currentItem.quantity - 1); } }

这些方法遵循一致的套路:读取当前状态 → 查找目标条目 → 通过服务方法提交新数量。注意两点边界处理:

  • decreaseQuantity只有在quantity > 1时才允许减一,避免出现 0 或负数(服务层的quantity <= 0删除逻辑作为第二道保险);
  • increaseQuantity/decreaseQuantity本身不直接改信号,而是调用cartStore.updateQuantity,保持了"唯一写入口在服务"的封装性。

它们被模板绑定到购物车每条商品旁的+/-按钮上((click)="increaseQuantity(item.id)"),点击后信号变化,@for列表与总价区域会自动重渲染。

第三步:在根组件中注入 CartStore 并展示购物车

练习起点的 app.ts 需要补齐inject导入、CartStore/CartDisplay导入、imports数组以及组件类字段。参考答案如下:

import {Component, inject} from '@angular/core'; import {CartStore} from './cart-store'; import {CartDisplay} from './cart-display'; @Component({ selector: 'app-root', imports: [CartDisplay], template: ` <div class="shopping-app"> <header> <h1>Signals with Services Demo</h1> <div class="cart-badge"> Cart: {{ cartStore.totalQuantity() }} items (${{ cartStore.totalPrice() }}) </div> </header> <main> <cart-display /> </main> </div> `, styleUrl: './app.css', }) export class App { cartStore = inject(CartStore); }

这个组件的关键动作有两处:

  • inject(CartStore)完成依赖注入:把服务实例挂到组件字段上。组件在构造函数阶段即可从注入器解析出CartStore,随后无论是模板插值还是方法调用都能访问。
  • 模板直接调用信号函数cartStore.totalQuantity()cartStore.totalPrice()出现在插值表达式中。Angular 会在模板读取这些计算信号时建立依赖,任何底层items变化都会让顶栏徽章里的"件数与总价"自动更新。

<cart-display />作为子组件通过imports引入,它在自己的组件体内同样inject(CartStore),与根组件拿到的是同一个服务实例——这正是"服务在多个组件间共享同一份状态"的体现。

从模板到服务的完整数据流

把三个文件串起来,一次"点击加购"的完整链路是:

  1. 用户在cart-display中点击Add Laptop ($999),触发addLaptop()cartStore.addItem('1', 'Laptop', 999)
  2. CartStore.addItem通过items.update(...)向私有信号写入新数组;
  3. cartItems只读视图、totalQuantity/totalPrice两个 computed 信号随之重新求值;
  4. 根组件头部的徽章Cart: 1 items ($999)cart-display中的商品列表、汇总区自动更新。

期间所有组件都没有手动调用变更检测或手动同步状态,UI 的刷新完全由 Signal 的依赖追踪自动驱动——这也是"服务的可写信号 + 只读暴露 + 派生汇总"这套组合模式的核心价值。需要说明的是,教程演示代码里以@Service()装饰器标注服务类;在真实的 Angular 应用中以单例注入形式提供服务时,惯用写法是配合@Injectable({providedIn: 'root'})让服务在整个注入器范围内共享。

本课要点回顾与下一步

如教程结尾所总结,本步骤需要牢记的四条核心概念是:

  • 服务级信号(Service-level signals):服务内部可以使用signal管理跨组件共享的响应式状态;
  • 依赖注入(Dependency injection):组件中通过inject()获取携带信号的、共享同一实例的服务;
  • 服务中的计算信号(Computed signals in services):用computed创建随源信号自动更新的派生状态,例如总件数与总价;
  • 只读信号(Readonly signals):用asReadonly()暴露只读视图,把对内部状态的写操作限制在服务的方法内,防止外部代码直接篡改。

教程的起点代码与完整答案都保存在本仓库中,如果你想亲手运行或对照练习,可以打开adev/src/content/tutorials/signals/steps/7-using-signals-with-services/目录下的srcanswer两份代码,结合app.css观察界面样式。

下一步,官方教程将进入第 8 步——在指令(Directive)中使用 Signal,把信号的能力进一步扩展到指令层面。

【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular

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

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

基于STM32F103的摄像头循迹小车:从图像处理到PID控制全解析

简介&#xff1a;基于STM32F103的摄像头循迹智能小车系统&#xff0c;面向嵌入式开发学习者和智能车竞赛爱好者&#xff0c;融合OV7670图像采集、二值化道路识别和超声波避障等关键技术&#xff0c;可广泛应用于毕业设计、课程设计与机器人入门实践。压缩包共166个文件&#xf…

作者头像 李华
网站建设 2026/9/8 22:09:52

不花3万报班!嵌入式自学路线全解析:从C语言到Linux驱动

这个标题我看了很久&#xff0c;心里挺有感触的。2026年&#xff0c;居然还能看到"3万块报嵌入式培训班"这种话题被反复讨论。一方面说明嵌入学这一行的热度确实还在&#xff0c;另一方面也说明信息差依然大得离谱。我见过太多被销售话术忽悠、背着贷款学完却找不到工…

作者头像 李华