先记住这个答案
副作用指的是函数在返回结果之外,对函数外部产生的可观察影响:修改传进来的对象或数组、修改全局变量、发网络请求、读写 localStorage、操作 DOM、打印日志都算。纯函数要同时满足两个条件:一是相同的参数永远返回相同的结果,二是没有任何副作用。它的好处是结果可以预测,测试时只要给输入、比对输出,不用准备环境;同样的参数还可以直接缓存结果。
- 纯函数:同输入同输出,且不改外部状态
- 修改传入的对象或数组也是副作用
- 读当前时间、随机数会让结果不确定
- 副作用无法消灭,要集中放在边缘处理
最常见的副作用:改了传进来的对象
很多人以为只有发请求、改 DOM 才算副作用,其实最容易踩的是悄悄修改了参数。
function addImpure(cart, item) {
cart.push(item) // 直接改了调用方传进来的数组
return cart
}
function addPure(cart, item) {
return [...cart, item] // 返回新数组,原数组不动
}
const cart = ['book']
const next = addPure(cart, 'pen')
console.log(cart.join(','), '|', next.join(','))
const same = addImpure(cart, 'bag')
console.log(cart.join(','), '|', same === cart)查看输出与解释
book | book,pen
book,bag | trueaddPure 调用后,原来的 cart 还是 book,新结果在 next 里。addImpure 调用后,调用方手里的 cart 被改成了 book,bag,而且返回的就是同一个数组。
在 React 里这正是一个经典 bug:用 addImpure 的方式更新 state,数组引用没变,React 比较前后 state 时认为没有变化,界面就不会重新渲染。Redux 的 reducer 要求是纯函数,也是同样的原因。
容易答错的地方
- 以为没有赋值语句就是纯函数
console.log、发请求、arr.sort()都没有显式赋值,但都是副作用。sort、reverse、splice会直接修改原数组,需要不修改原数组时,用toSorted、toReversed、toSpliced或者先复制一份。- 以为展开运算符复制就足够了
[...cart]和{ ...user }只复制第一层。如果函数里改的是嵌套对象,比如copy.address.city = 'x',原对象的address也会被改,因为两者共享同一个嵌套对象。要改哪一层,就得把从根到那一层的路径都复制一遍。
面试官还会怎么问?
为什么记忆化(memoization)要求函数是纯的?
记忆化是把参数和结果存起来,下次同样的参数直接返回缓存的结果。它的前提就是「同样的参数一定得到同样的结果」。如果函数依赖外部状态,外部状态变了,缓存返回的就是过期的错误结果。
函数内部修改局部变量算副作用吗?
不算。副作用看的是函数外面能不能观察到。函数里新建一个数组、用 for 循环往里 push,最后返回它,外面看不到这个过程,调用结果仍然只由参数决定,这个函数仍然是纯的。
React 组件为什么要求是纯函数?
React 可能会多次调用组件函数,开发环境的严格模式还会故意调用两次来暴露问题。组件如果在渲染过程中改了外部变量或者发了请求,多调用一次就会出现重复请求、数据错乱。所以渲染阶段只根据 props 和 state 计算 JSX,其它事情放到事件处理函数或 useEffect 里做。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。