Ngrx Store状态不可变性疑问:修改数组内对象属性触发只读错误的原因
嘿,这个问题我之前在使用NgRx时也踩过坑,核心原因在于NgRx对状态不可变性的强制要求,以及你用扩展运算符做的是「浅拷贝」这件事,咱们一步步拆解:
1. NgRx状态的不可变性与冻结机制
NgRx的核心设计原则之一就是状态不可变——也就是说,你永远不能直接修改Store返回的状态对象,必须通过创建新的状态对象来更新。为了严格执行这个规则,很多情况下NgRx会用Object.freeze()冻结整个状态树,包括数组里的每个对象。
这就是为什么你直接修改newIngredients[index].amount会报错:虽然你拷贝了数组,但数组里的对象还是原状态的引用,修改它本质上就是在修改被冻结的原状态对象,自然会触发「只读」的错误提示。
而你用普通服务的时候没有这个问题,是因为服务里的状态是可变的,没有这种冻结和不可变的强制约束。
2. 扩展运算符的「浅拷贝」局限性
你写的newIngredients = [...state.ingredients]确实创建了一个新数组,但这只是浅拷贝:数组本身是新的,但数组里的每个ingredient对象还是和原数组里的对象共享同一个内存引用。
举个简单的例子:
const original = [{name: 'apple', amount: 1}]; const copied = [...original]; copied[0].amount = 2; console.log(original[0].amount); // 输出2,因为两个数组里的对象是同一个引用
所以你以为是在修改新数组里的对象,其实还是碰了原状态的对象,这就违反了NgRx的规则。
3. 为什么「复制对象再替换」能生效?
当你复制那个同名对象(比如{...existingIngredient, amount: newAmount}),然后替换数组里的旧对象时,你实际上做了两件事:
- 创建了一个全新的ingredient对象,完全脱离了原状态的引用
- 用这个新对象替换新数组里的旧引用,整个新数组和原状态没有任何共享的引用
这种方式完全符合NgRx的不可变要求:原状态丝毫未动,你返回了一个全新的状态对象,NgRx能正确识别到状态变化并触发订阅更新。
正确的Reducer实现示例
这里给你一个符合NgRx规范的示例写法,供参考:
import { createReducer, on } from '@ngrx/store'; import { addIngredient } from './ingredients.actions'; interface Ingredient { name: string; amount: number; } interface IngredientsState { ingredients: Ingredient[]; } const initialState: IngredientsState = { ingredients: [] }; export const ingredientsReducer = createReducer( initialState, on(addIngredient, (state, { payload }) => { const existingIngredientIndex = state.ingredients.findIndex( ing => ing.name === payload.name ); if (existingIngredientIndex !== -1) { // 存在同名食材:创建新数组,替换对应位置为新对象 return { ...state, ingredients: state.ingredients.map((ing, idx) => idx === existingIngredientIndex ? { ...ing, amount: ing.amount + payload.amount } : ing ) }; } else { // 不存在同名食材:直接添加新对象到新数组 return { ...state, ingredients: [...state.ingredients, payload] }; } }) );
内容的提问来源于stack exchange,提问作者Alexander Uzelevski

