Skip to main content

不可变数据

Redux 常见问题解答:不可变数据​

不可变性有什么好处?​

不可变性可以提升应用性能,并简化编程和调试,因为永远不会变化的数据比在应用中任意改变的数据更容易理解。

特别是在 Web 应用中,不可变性使得实现复杂的变化检测技术变得简单且低成本,确保只有在绝对必要时才执行计算开销大的 DOM 更新操作(这是 React 相较于其他库性能提升的基石)。

更多信息​

文档

相关文章

为什么 Redux 要求不可变性?​

更多信息​

文档

讨论

为什么 Redux 使用浅层相等检查需要不可变性?​

要让已订阅的组件正确更新,Redux 使用浅层相等检查时就要求遵循不可变性。要理解原因,先来看 JavaScript 中浅层相等检查和深度相等检查的区别。

浅层相等和深度相等检查有何不同?​

浅层相等检查(或称 引用相等)仅检查两个不同的 变量 是否引用同一对象;而深度相等检查(或称 值相等)需递归检查两个对象的每个属性值。

浅层相等检查就像简单且快速的 a === b,而深度相等检查需要递归遍历两个对象的所有属性,逐一比较属性值。

正因为浅层相等检查的性能优势,Redux 采用了它。

更多信息​

文章

Redux 如何使用浅层相等检查?​

Redux 在 combineReducers 中使用浅层相等检查,以决定返回一个新的修改过的根状态对象,还是如果没有修改就返回当前根状态对象。

更多信息​

文档

combineReducers 如何使用浅层相等检查?​

Redux store 的推荐结构是按键将状态对象拆分为多个“切片”或“领域”,并为每个数据切片提供独立的 reducer 函数。

combineReducers 简化了这种结构的管理,它接受一个 reducers 参数,该参数是一个键值对哈希表,键是状态切片名,值是对应处理该切片的 reducer 函数。

例如,如果状态结构是 { todos, counter },调用 combineReducers 如下:

combineReducers({ todos: myTodosReducer, counter: myCounterReducer })

其中:

  • 键 todos 和 counter 分别表示单独的状态切片;
  • 值 myTodosReducer 和 myCounterReducer 是处理各自状态切片的 reducer 函数。

combineReducers 遍历每对键值对,每一次:

  • 引用当前状态切片;
  • 调用对应的 reducer 处理该切片;
  • 引用 reducer 返回的可能被修改过的状态切片。

遍历完成后,combineReducers 会用 reducers 返回的状态切片构造一个新的状态对象。这个新状态对象可能与当前状态相同,也可能不同。此时,combineReducers 使用浅层相等检查判断状态是否更改。

具体地,遍历中 combineReducers 对当前状态切片与 reducer 返回的状态切片做浅层相等检查。如果 reducer 返回新对象,浅层检查失败,combineReducers 会将 hasChanged 标志设置为 true。

遍历结束后,combineReducers 会检查 hasChanged 标志。若为 true,则返回新构建的状态对象;若为 false,则返回当前状态对象。

强调一点:如果所有 reducers 都返回传入它们的同一个 state 对象,combineReducers 会返回当前根状态对象,而不是新对象。

更多信息​

文档

视频

React-Redux 如何使用浅层相等检查?​

每次派发 action 后,React-Redux 都会使用新的根状态重新运行传给 useSelector 的 selector,并通过 === 将结果与上一次的结果进行比较。如果两个值引用相同,组件就不会重新渲染;如果引用不同,则会重新渲染。

这一次比较正是不可变性对 React 侧很重要的原因。如果 reducer 修改现有对象并将其返回,selector 返回的引用就与之前相同,检查会判定为相等;即使数据已经改变,组件也不会更新。如果 selector 每次调用都会新建对象或数组(例如调用 array.filter()),每次 dispatch 后检查都会判定为不相等;即使相关数据没有变化,组件也会重新渲染。React Redux 常见问题介绍了这两类问题及其解决方法。

更多信息​

文档

为什么浅层相等检查无法用于可变对象?​

浅层相等检查无法检测出函数是否变更了传入的可变对象。

因为两个引用同一对象的变量 总是 相等,无论该对象的值是否改变。如下所示:

function mutateObj(obj) {
obj.key = 'newValue'
return obj
}

const param = { key: 'originalValue' }
const returnVal = mutateObj(param)

param === returnVal
//> true

浅检查只是比较两个变量是否引用同一对象,它们确实引用同一对象。即使 mutateObj() 返回了修改后的对象,实际上也还是同一个传入的对象。它的值被修改与否对浅层检查无影响。

更多信息​

文章

Redux 中使用可变对象的浅层相等检查会有问题吗?​

对可变对象进行浅层相等检查不会给 Redux 本身造成问题,但会影响依赖 store 的库,例如 React-Redux。

具体而言,如果传入 reducer 的状态切片是可变对象,reducer 可能会直接修改它并返回。

这样,combineReducers 的浅层检查总是通过,因为切片被修改了值,但对象本身没变 —— 它依然是传入的那个对象。

于是 combineReducers 不会把 hasChanged 标志置为 true,即使状态实际更改。如果没有其他 reducers 返回新的切片,hasChanged 永远为 false,导致 combineReducers 返回 当前 根状态对象。

store 中的根状态仍会更新为新值,但根状态对象本身没有变化。React-Redux 等绑定到 Redux 的库因此无法察觉状态被修改,也就不会重新渲染已订阅的组件。

Redux Toolkit 的 configureStore 会添加仅用于开发环境的不可变性检查中间件;reducer 修改状态时会立即抛出错误,因此这类问题不会等到组件无法更新时才暴露出来。

更多信息​

文档

不可变性如何使浅层检查能感知对象变更?​

如果对象不可变,要修改其中的数据只能复制一份对象并修改这份副本。

这份被修改的副本是 与传入对象不同 的新对象,返回时浅层检查会识别出它与传入对象不同,从而失败。

更多信息​

文章

你 reducer 内的不可变性如何导致组件不必要的渲染?​

你不能直接修改不可变对象,只能修改它的副本,保持原对象不变。

当你修改副本没问题,但在 reducer 里如果返回一个 没有被修改的副本,combineReducers 仍会认为状态需要更新,因为你返回了一个不同的对象。

combineReducers 随后会把这个新的根状态对象返回给 store。新对象的值虽然与当前根状态相同,但因为对象引用不同,store 仍会更新。应用中的每个 useSelector hook 都会重新运行 selector;任何读取了复制后切片并返回新引用的 selector,都会导致组件不必要地重新渲染。

为避免这种情况,reducer 未修改状态时,必须始终返回传给该 reducer 的原状态切片对象。createSlice 生成的 reducer 会自动做到这一点:如果 draft 没有变化,Immer 就会返回原对象。

selector 也可能出现类似问题:每次调用都返回新数组或对象的 selector 会导致每次 dispatch 都重新渲染。此类情况请参阅为什么组件重新渲染得太频繁?。

为避免此问题,如果 reducer 没有修改状态,必须返回传入的原状态切片对象。

更多信息​

不同的不可变数据处理方法有哪些?必须用 Immer 吗?​

Redux 本身只要求 reducer 返回新值,而不是修改现有值。如何生成这些新值由你决定。

Redux Toolkit 的 createSlice 和 createReducer 内部使用 Immer,因此用它们编写的 case reducer 可以使用“修改”语法,而 Immer 会生成不可变结果。这是我们推荐的方式,也是当前所有 Redux 教程采用的方式。Immer 是 Redux Toolkit 的内置部分;使用 Immer 编写 reducer介绍了它的工作原理、推荐模式、常见陷阱以及将其集成进来的原因。

如果不使用 Redux Toolkit 而是手动编写 reducer,就需要通过对象展开和非修改数组方法,复制每一层发生变化的嵌套结构。不可变更新模式介绍了正确做法,并列出最容易导致意外修改的错误。你也可以在手写 reducer 中直接调用 Immer 的 produce。

修改不可变对象意味着必须对其做完整拷贝,拷贝大量属性开销大。

相对而言,像 Immer 这类不可变库支持结构共享,在复制对象时重用大量已有结构,因此性能更好。

手动编写不可变更新有哪些问题?​

在纯 JavaScript 中手动编写不可变更新有两个问题,而 Immer 都能解决:

  • 意外修改。 更新嵌套属性时,很容易误用原引用而没有复制,或没意识到只复制了对象顶层。意外修改是 Redux bug 最常见的原因,通常会表现为组件没有重新渲染。Redux Toolkit 的 configureStore 包含仅用于开发环境的不可变性检查中间件,reducer 修改状态时会抛出错误。
  • 代码冗长。 正确更新嵌套数据时,需要为每一层都编写多行展开复制代码,这会掩盖更新意图,也增加了出错的机会。

手写更新模式及常见错误请参阅不可变更新模式;Immer 如何处理相同更新,请参阅使用 Immer 编写 reducer。