通过重新认识纯函数来了解不可变数据结构
我知道一提到纯函数很多同学都说知道,每天都在写或者使用到纯函数,就比如说很多写React的同学就容易陷入误区,认为自己天天都在写的函数式编程就是纯函数编程,不就是一进一出,一次封装到处调用吗。
但我猜想你在使用可变状态(mutable state)、无限制副作用(unrestricted side effects)和无原则设计(unprincipled design)的过程中已经遇到过一些麻烦。
我们先来简单认识一下纯函数的概念:
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
通过slice
和 splice
,来看看纯函数与带有副作用函数的区别:
js
var xs = [1,2,3,4,5];
// 纯的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
// 不纯的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3);
//=> []
重新认识纯函数是因为这个概念涉及到了很多场景以及相关的概念,例如在各种全局状态管理的框架中,如redux在修改state状态时,是不允许你直接修改state对象的,而是需要在reducer函数中去接收旧 state 和 action,并返回新 state。那为什么要这么做呢,这里面就涉及到了不可变状态模型。而这个不可变状态正好是用来支撑纯函数定义中的无副作用概念。
关联知识:
vuex在mutations中是可以直接修改state参数的,主要是依赖 Vue 的响应式系统自动追踪变更,因此并未强制要求数据结构不可变。有兴趣的可以去看看源码。
"Reducer" 函数的名字来源是因为它和Array.reduce()
函数使用的回调函数很类似。
我们来看一下Redux Reducer的经典示例:
ts
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
// 返回新状态对象,不修改旧state
return { ...state, count: state.count + 1 };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
default:
return state; // 未处理action,返回原状态
}
}
这个示例里就涉及到了不可变数据结构的概念和纯函数的运用。
首先看看纯函数的使用:
- 接收当前不可变状态和一个描述发生了什么的 Action 对象作为输入。
- 基于当前状态和 Action,计算并返回一个全新的状态对象。
从上面的示例和步骤就可以确定,该函数无论运行多少次都不会产生任何副作用,相同的输入一定会得到相同的输出。
其次就是不可变数据结构:
如果在counterReducer这个函数中,我们直接操作state对象属性值的修改会发生什么,这里我们先回答一个问题:为什么需要不可变数据?
1、React UI 更新机制
在React中状态更新必须通过创建新对象/数组,而非直接修改原状态,因为 React State 在 Diff 更新的时候,是通过 shallowEqual 去比较,比较结果是 false 才去更新。
jsx
const [user, setUser] = useState({ name: 'Alice', age: 30 });
// 🚫 错误:直接修改状态
function updateAge() {
user.age = 31; // 直接修改
setUser(user); // 不会触发重新渲染
}
// ✅ 正确:创建新对象
function updateAge() {
setUser(prev => ({ ...prev, age: 31 }));
}
2、不可变数据本身
- 可预测性:
大大降低了理解程序行为和数据流变化的复杂性,尤其是在异步、并发或多线程环境中。你不需要担心其他代码在你背后偷偷修改了你的数据。 - 时间旅行与调试:
保留旧状态变得非常简单且高效 - 并发安全:
多个线程/任务可以同时安全地读取同一份不可变数据,因为它们知道这份数据永远不会被修改,无需复杂的锁机制。 - 高效的变更检测:
判断状态是否发生了变化变得非常廉价和快速。因为状态是不可变的,引用比较 (oldState === newState
)就能确定对象本身是否发生了变化。 - 符合纯函数原则:
不可变性是纯函数的必要条件(纯函数不修改输入参数,不产生副作用)。
具体的作用和解释可以看看下面这篇文章: [ Immer 源码 ] 来聊聊 Immer 实现不可变数据结构[React Immer 源码] 来聊聊 Immer 实 - 掘金
现在我们就可以回答了,在Redux等一些状态管理库中,为什么不可变数据很重要。
1. 通过记录状态变化来实现时间旅行
为了确保数据的真实性与同步性,不可变数据就变得尤其重要,也是实现时间旅行的关键。因为每次状态更新都是一个纯函数作用于前一个不可变状态产生的新不可变状态,保存历史状态(如实现撤销/重做)变得非常简单高效------只需存储状态快照序列和应用过的 Action 序列。重放 Actions 就能重现任何历史状态。
简单来说为了不可变数据是为了确保我们能够获得安全的历史快照
js
// 可变状态的问题
const state = { count: 0 };
const history = [state];
state.count = 1; // 修改原始状态
console.log(history[0].count); // 1 - 历史记录被污染!
// 不可变状态的解决方案
const initialState = { count: 0 };
const safeHistory = [initialState];
const newState = { ...initialState, count: 1 };
console.log(safeHistory[0].count); // 0 - 历史保持原始状态
只要理解了这一步我们就可以利用时间旅行的思想来实现以下场景:
- 文本编辑器:文档编辑历史
- 图形设计工具:设计步骤回溯
- 表单交互:复杂表单操作撤销
- 数据分析:参数调整历史
- 游戏开发:玩家操作回退
2. 通过浅比较来提高效率
因为在状态管理中,我们需要确保每次的数据操作返回的都是一个新的对象或者值,因此每次的操作都会获得一个新的引用地址,在进行数据对比的时候就可以通过浅对比来确认对象是否发生了变化,从而提升了对比的效率。
最后我们回答上面回上面那个问题:在counterReducer这个函数中,我们直接操作state对象属性值的修改会发生什么?
1. 状态突变(Mutation)问题
- 直接修改了原始状态对象,破坏了状态的不可变性
- 原始状态被意外更改,失去了不可变状态的核心优势
2. 组件不会重新渲染
- React-Redux 通过 引用比较 检查状态是否变化
3. 时间旅行调试失效
-
Redux DevTools 依赖状态不可变性来实现:
- 状态历史记录
- 撤销/重做功能
- 时间旅行调试
-
直接修改状态会使这些功能完全失效
4. 不可预测的行为
这一点在我们日常写代码中也是需要注意的,尽量不要修改请求返回的原始数据。
- 原始状态被意外修改,导致程序行为不可预测
- 可能引发难以追踪的 bug
5. 性能优化失效
- React 的浅比较优化(如
React.memo
)依赖不可变性 - 库如 Reselect 的选择器缓存机制会被破坏
最重要的一点是该行为违反了 Redux 的核心原则。我们看下官网中是怎么说的:
Reducer 必须是纯函数------即相同的输入只会返回相同的结果的函数。纯函数不能产生任何副作用。只有这样,才可能实现一些花里胡哨的特性,比如热重载和时间旅行(time travel)。
其实写到后面我也开始乱了,因为写着写着发现需要深入和扩展的东西太多了,一时整理不过来。大家想要深入了解的话可以看看相应的文章或者官方文档。
最后大家可以思考一下这几个拓展问题:
- 如何正确的在状态管理中使用异步函数
- 确保不可变数据结构的方法有哪些?
- Immer和Immutable库的优势与核心是什么?
- 利用纯函数与不可变数据结构还有什么高级的用法或者使用场景?