不可变数据
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
Redux 常见问题:不可变数据
不可变性有哪些优势?
不可变性能够提升应用性能,并简化编程和调试过程。因为永远不会变化的数据比那些在应用中可被任意修改的数据更容易进行逻辑推理。
特别是在 Web 应用中,不可变性能够以简单高效的方式实现精细的变更检测技术,确保仅在绝对必要时才执行更新 DOM 这一计算密集型操作(这也是 React 性能优于其他库的核心因素)。
扩展阅读
文章
为什么 Redux 要求不可变性?
-
Redux 和 React-Redux 都采用了浅层相等检查。具体而言:
- Redux 的
combineReducers工具会对 reducer 引起的引用变更进行浅层检查 - React-Redux 的
connect方法生成的组件会浅层检查根状态的引用变更,并通过mapStateToProps的返回值判断是否需要重新渲染包装组件。这种浅层检查需要依赖不可变性才能正确运作
- Redux 的
-
不可变数据管理从根本上提高了数据处理的安全性
-
时间旅行调试要求 reducer 必须是纯函数且无副作用,这样才能在不同状态间准确跳转
更多信息
文档
讨论
为什么 Redux 的浅层相等检查需要不可变性?
如果希望正确更新任何已连接组件,Redux 的浅层相等检查必须依赖不可变性。要理解原因,我们需要了解 JavaScript 中浅层和深层相等检查的区别。
浅层与深层相等检查有何区别?
浅层相等检查(或称_引用相等_)仅验证两个不同_变量_是否指向同一个对象;而深层相等检查(或称_值相等_)必须逐项比较两个对象所有属性的_值_。
因此浅层检查只需执行简单(且快速)的 a === b 操作,而深层检查需要递归遍历两个对象的所有属性,在每一步比较属性值。
正是出于这种性能优势,Redux 选择了浅层相等检查。
更多信息
文章
Redux 如何运用浅层相等性检查?
Redux 在其 combineReducers 函数中使用浅层相等性检查,以决定是返回根状态对象的新变更副本,还是在未发生变更时返回当前根状态对象。
更多信息
文档
combineReducers 如何运用浅层相等性检查?
建议的 Redux 存储结构是通过键名将状态对象拆分为多个"切片"或"领域",并为每个独立数据切片提供单独的 reducer 函数。
combineReducers 通过接收定义为键值对哈希表的 reducers 参数来简化这种结构的工作方式,其中每个键是状态切片的名称,对应的值是将作用于该切片的 reducer 函数。
例如,若状态结构为 { todos, counter },调用 combineReducers 的形式如下:
combineReducers({ todos: myTodosReducer, counter: myCounterReducer })
其中:
-
键名
todos和counter分别指向独立的状态切片; -
值
myTodosReducer和myCounterReducer是 reducer 函数,各自作用于对应键名标识的状态切片。
combineReducers 遍历这些键值对时,每次迭代会:
-
为每个键名对应的当前状态切片创建引用;
-
调用对应的 reducer 并传入该切片;
-
为 reducer 返回的可能已变更的状态切片创建引用。
遍历过程中,combineReducers 会用各 reducer 返回的状态切片构建新状态对象。该新状态对象可能与当前状态对象不同。此时 combineReducers 使用浅层相等性检查判断状态是否变更。
具体而言,每次迭代阶段,combineReducers 都会对当前状态切片和 reducer 返回的切片进行浅层相等性检查。若 reducer 返回新对象,浅层检查将失败,combineReducers 会将 hasChanged 标志设为 true。
遍历完成后,combineReducers 检查 hasChanged 标志。若为 true 则返回新构建的状态对象;若为 false 则返回_当前_状态对象。
重点强调:若所有 reducer 都返回传入的相同 state 对象,则 combineReducers 将返回当前根状态对象,而非新更新的对象。
更多信息
文档
视频
React-Redux 如何运用浅层相等性检查?
React-Redux 使用浅层相等性检查判断其包装的组件是否需要重新渲染。
为了实现这一点,它假设被包裹的组件是纯组件;也就是说,在相同的 props 和状态下,该组件将产生相同的结果。
通过假设被包裹的组件是纯组件,它只需检查根状态对象或从 mapStateToProps 返回的值是否发生变化。如果没有变化,则无需重新渲染被包裹的组件。
它通过保留对根状态对象的引用以及对 mapStateToProps 函数返回的 props 对象中_每个值_的引用来检测变更。
随后会对保留的根状态对象引用与传入的状态对象进行浅比较,并对 props 对象中每个值的引用与重新运行 mapStateToProps 后返回的值进行一系列独立的浅比较。
更多信息
文档
文章
为什么 React-Redux 要对 mapStateToProp 返回的 props 对象中每个值进行浅比较?
React-Redux 执行的是对 props 对象中每个_值_的浅比较,而非对整个 props 对象进行比较。
这是因为 props 对象实际上是属性名及其值的哈希表(或用于检索/生成值的选择器函数),如下例所示:
function mapStateToProps(state) {
return {
todos: state.todos, // prop value
visibleTodos: getVisibleTodos(state) // selector
}
}
export default connect(mapStateToProps)(TodoApp)
因此,如果对 mapStateToProps 多次调用返回的 props 对象进行浅比较,结果总会失败——因为每次都会返回新对象。
React-Redux 因此会分别保留对返回的 props 对象中每个_值_的引用。
更多信息
文章
React-Redux 如何通过浅比较判断组件是否需要重新渲染?
每次调用 React-Redux 的 connect 函数时,它会对其存储的根状态对象引用与从 store 传入的当前根状态对象进行浅比较。如果比较通过,说明根状态对象未更新,因此无需重新渲染组件,甚至无需调用 mapStateToProps。
但如果比较失败,则说明根状态对象_已更新_,此时 connect 会调用 mapStateToProps 来检查被包裹组件的 props 是否更新。
它会对 props 对象中的每个值单独进行浅比较,仅当任一比较失败时才触发重新渲染。
在下例中,如果连续调用 connect 时 state.todos 和 getVisibleTodos() 的返回值未改变,则组件不会重新渲染:
function mapStateToProps(state) {
return {
todos: state.todos, // prop value
visibleTodos: getVisibleTodos(state) // selector
}
}
export default connect(mapStateToProps)(TodoApp)
相反,在下例中组件将_总是_重新渲染,因为无论其值是否改变,todos 始终是新对象:
// AVOID - will always cause a re-render
function mapStateToProps(state) {
return {
// todos always references a newly-created object
todos: {
all: state.todos,
visibleTodos: getVisibleTodos(state)
}
}
}
export default connect(mapStateToProps)(TodoApp)
如果 mapStateToProps 返回的新值与 React-Redux 保留的先前值的浅比较失败,则会触发组件的重新渲染。
更多信息
文章
讨论
为什么浅层相等检查不适用于可变对象?
如果传入函数的对象是可变的,浅层相等检查无法检测该函数是否修改了该对象。
这是因为引用同一对象的两个变量始终相等——无论对象值是否改变——因为它们指向同一对象。因此以下结果恒为真:
function mutateObj(obj) {
obj.key = 'newValue'
return obj
}
const param = { key: 'originalValue' }
const returnVal = mutateObj(param)
param === returnVal
//> true
对 param 和 returnValue 的浅层检查仅验证两个变量是否引用同一对象(确实如此)。mutateObj() 可能返回修改后的 obj,但它仍是传入的原始对象。对象值在 mutateObj 内部的变更对浅层检查毫无影响。
更多信息
文章
对可变对象进行浅层相等检查会导致 Redux 出现问题吗?
对可变对象进行浅层相等检查不会导致 Redux 本身出现问题,但会影响依赖 store 的库(如 React-Redux)。
具体而言,若 combineReducers 传给 reducer 的状态切片是可变对象,reducer 可直接修改并返回它。
此时 combineReducers 执行的浅层检查总会通过——尽管 reducer 返回的状态切片值已被修改,但对象本身未变(仍是传入时的原始对象)。
因此 combineReducers 不会设置 hasChanged 标志,即使状态已变更。若其他 reducer 均未返回新的更新后状态切片,hasChanged 标志保持 false,导致 combineReducers 返回现有的根状态对象。
存储库(store)仍会更新根状态的新值,但由于根状态对象本身未变,绑定 Redux 的库(如 React-Redux)无法感知状态变化,因此不会触发包装组件的重新渲染。
更多信息
文档
为什么 reducer 直接修改状态会导致 React-Redux 无法重新渲染包装组件?
若 Redux reducer 直接修改并返回传入的状态对象,根状态对象的值会变化,但对象引用保持不变。
由于 React-Redux 对根状态对象执行浅层检查以判断其包装组件是否需要重新渲染,它将无法检测到状态变更,因此不会触发重新渲染。
更多信息
文档
为什么选择器函数将持久对象的可变引用返回给 mapStateToProps 会阻止 React-Redux 重新渲染包装组件?
如果从 mapStateToProps 返回的 props 对象中某个值是跨 connect 调用持久存在的对象(例如可能是根状态对象本身),但该对象被选择器函数直接变更后返回,React-Redux 将无法检测到变更,因此不会触发包装组件的重新渲染。
如前所述,选择器返回的可变对象内部值可能已改变,但对象引用本身未变,而浅层比较仅检查对象引用而非其内部值。
例如以下 mapStateToProps 函数永远不会触发重新渲染:
// State object held in the Redux store
const state = {
user: {
accessCount: 0,
name: 'keith'
}
}
// Selector function
const getUser = state => {
++state.user.accessCount // mutate the state object
return state
}
// mapStateToProps
const mapStateToProps = state => ({
// The object returned from getUser() is always
// the same object, so this wrapped
// component will never re-render, even though it's been
// mutated
userRecord: getUser(state)
})
const a = mapStateToProps(state)
const b = mapStateToProps(state)
a.userRecord === b.userRecord
//> true
注意:相反地,如果使用不可变对象,组件可能在不需要时重新渲染。
更多信息
文章
讨论
不可变性如何使浅层检查能检测对象变更?
若对象不可变,函数内对其任何修改都必须创建对象的副本进行操作。
这个变更后的副本与传入函数的原始对象是两个独立对象,因此当它被返回时,浅层检查会识别出这是不同的对象,比较结果将不匹配。
更多信息
文章
reducer 中的不可变性如何导致组件不必要渲染?
不可变对象禁止直接修改,必须通过创建副本来实施变更。
这在修改副本时完全可行,但在 reducer 上下文中,如果返回未经修改的副本,Redux 的 combineReducers 仍会认为状态需要更新——因为你返回的是与传入的状态切片对象完全不同的新对象。
此时 combineReducers 会将这个新根状态对象返回给 store。虽然新对象的值与当前根状态相同,但因其引用不同,将触发 store 更新,最终导致所有连接组件不必要地重新渲染。
为避免此问题,当 reducer 不修改状态时,必须始终返回传入的状态切片对象。
更多信息
文章
mapStateToProps 中的不可变性如何导致组件不必要渲染?
某些不可变操作(例如数组过滤)总会返回新对象,即使其内部值并未改变。
若此类操作在 mapStateToProps 中作为选择器函数使用,React-Redux 对返回的 props 对象中每个值进行的浅层比较总会失败,因为选择器每次返回的都是新对象。
因此,即使新对象的值未变化,被包裹的组件仍会重新渲染。
例如以下情况总会触发重新渲染:
// A JavaScript array's 'filter' method treats the array as immutable,
// and returns a filtered copy of the array.
const getVisibleTodos = todos => todos.filter(t => !t.completed)
const state = {
todos: [
{
text: 'do todo 1',
completed: false
},
{
text: 'do todo 2',
completed: true
}
]
}
const mapStateToProps = state => ({
// getVisibleTodos() always returns a new array, and so the
// 'visibleToDos' prop will always reference a different array,
// causing the wrapped component to re-render, even if the array's
// values haven't changed
visibleToDos: getVisibleTodos(state.todos)
})
const a = mapStateToProps(state)
// Call mapStateToProps(state) again with exactly the same arguments
const b = mapStateToProps(state)
a.visibleToDos
//> { "completed": false, "text": "do todo 1" }
b.visibleToDos
//> { "completed": false, "text": "do todo 1" }
a.visibleToDos === b.visibleToDos
//> false
相反,若 props 对象中的值引用可变对象,组件可能在该渲染时未渲染。
更多信息
文章
有哪些处理数据不可变性的方法?必须使用 Immer 吗?
在 Redux 中并非必须使用 Immer。如果编写得当,原生 JavaScript 完全能够在不依赖不可变库的情况下实现不可变性。
但用 JavaScript 保证不可变性较为困难,意外改变对象的情况极易发生,导致应用中产生难以定位的 bug。因此,使用 Immer 等不可变更新工具库能显著提升应用可靠性,并大幅降低开发难度。
更多信息
讨论
使用原生 JavaScript 进行不可变操作有哪些问题?
JavaScript 在设计上从未保证提供不可变操作。因此,如果在 Redux 应用中选择使用原生 JavaScript 实现不可变操作,需注意以下几个问题:
意外对象变更
使用 JavaScript 时,极易在无意中改变对象(例如 Redux 状态树)。例如:更新深层嵌套属性、创建对象新引用而非新对象、进行浅拷贝而非深拷贝等操作,都可能导致意外对象变更,即使经验丰富的 JavaScript 开发者也可能中招。
为避免这些问题,请务必遵循推荐的不可变更新模式。
冗长代码
更新复杂的嵌套状态树会导致代码冗长,编写枯燥且调试困难。
性能低下
以不可变方式操作 JavaScript 对象和数组可能较慢,当状态树规模增大时尤为明显。
要记住,修改不可变对象时必须操作其_副本_,而复制大型对象可能很慢,因为每个属性都需要被复制。
相比之下,Immer 等不可变库采用的结构共享技术,能高效生成复用原对象大部分结构的新对象。
更多信息
文档
文章