在 Angular 服务中使用 Signal:用只读信号与 computed 派生状态构建共享购物车
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
Angular 的 Signals 教程进入服务层,解决的是这样一个实际需求:当多个组件需要共享同一份响应式状态(例如购物车)时,把状态放进一个可注入的服务(Service)中,并通过signal、asReadonly、computed组合出"可写源头 + 只读对外暴露 + 自动派生的汇总值"这一套干净的模式。本文基于 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()用于派生状态。totalQuantity与totalPrice都是对items的纯函数派生:当底层信号变化时它们会自动重新求值,模板里读取到的永远是实时汇总值。- 从 Angular 响应式核心看,
computed与signal的实现都位于 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([])直接覆盖整个状态,适合清空场景。 - 坚持不可变更新:所有修改都返回新数组(
map、filter、展开运算符),绝不原地改动CartItem对象,这是 Signal 变更检测能够可靠识别"值变了"的前提。 - 边界条件被内置到服务:
updateQuantity遇到quantity <= 0会转成删除,这意味着调用方(组件)可以放心地把数量 - 1的结果传进来,由服务兜底处理。
第二步:在 CartDisplay 中完成数量增减方法
练习起点(cart-display.ts)里,increaseQuantity与decreaseQuantity两个方法只剩 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),与根组件拿到的是同一个服务实例——这正是"服务在多个组件间共享同一份状态"的体现。
从模板到服务的完整数据流
把三个文件串起来,一次"点击加购"的完整链路是:
- 用户在
cart-display中点击Add Laptop ($999),触发addLaptop()→cartStore.addItem('1', 'Laptop', 999); CartStore.addItem通过items.update(...)向私有信号写入新数组;cartItems只读视图、totalQuantity/totalPrice两个 computed 信号随之重新求值;- 根组件头部的徽章
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/目录下的src与answer两份代码,结合app.css观察界面样式。
下一步,官方教程将进入第 8 步——在指令(Directive)中使用 Signal,把信号的能力进一步扩展到指令层面。
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考