🚀 探索更多React Hooks的可能性?访问 www.reactuse.com 查看完整文档,通过 npm install @reactuses/core
快速安装,让你的React开发效率翻倍!
血案现场
那是一个平静的周五下午,我正准备提早下班享受周末,突然运营小姐姐火急火燎地跑过来:"页面上的数据显示有问题!用户投诉说前后对比功能完全乱了!"
打开生产环境一看,我的心凉了半截。用户反馈页面中的"上次访问数据"和"本次访问数据"显示的竟然是一样的!这可是一个金融产品的核心功能,用户需要对比两次数据的差异来做投资决策。
更要命的是,这个功能在测试环境一直都是好的...
紧急排查
经过一番紧急排查,我发现问题出在一个看似人畜无害的自定义Hook上:
csharp
// 我们的 usePrevious 实现
import { useEffect, useRef } from 'react'
export function usePrevious<T>(state: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = state
})
return ref.current
}
在业务代码中是这样使用的:
javascript
function DataComparisonPage() {
const [currentData, setCurrentData] = useState(null)
const previousData = usePrevious(currentData) // 获取上一次的数据
// 页面逻辑...
return (
<div>
<div>上次数据: {previousData?.value}</div>
<div>本次数据: {currentData?.value}</div>
<div>变化: {currentData?.value - previousData?.value}</div>
</div>
)
}
逻辑看起来完全没问题啊!为什么会出现这种诡异的情况?
复现问题
经过一番折腾,我终于在本地复现了这个问题。关键在于生产环境中有一些实时更新的逻辑,会在数据变化后触发额外的渲染:
typescript
function App() {
const [count, setCount] = useState(0)
const previous = useRef<number | null>(null);
const [_, forceUpdate] = useState<number>(-1);
useEffect(() => {
console.log('count', count)
previous.current = count;
}, [count])
// 这里模拟生产环境中的实时更新逻辑
// 实际项目中可能是 WebSocket 推送、轮询等
useEffect(() => {
forceUpdate(Math.random())
}, [count])
console.log('previous', previous)
return (
<>
<h1>Hello World</h1>
<div className='card'>
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>previous is {previous.current}</p>
</div>
</>
)
}
你可以在 这个 playground 中看到完整的复现代码。
血案分析
问题的根源在于执行时机:
-
用户操作触发数据变更 :
count
从 0 变为 1 -
第一次渲染:
usePrevious
返回previous.current
(此时还是前一个值或undefined
)- 渲染完成,显示正确的对比数据
-
useEffect 执行 :
previous.current = 1
(更新为最新值) -
额外渲染被触发:由于实时更新逻辑,组件重新渲染
-
第二次渲染:
- 此时
previous.current
已经是最新值 1 - 页面显示:上次数据 = 1,本次数据 = 1
- 用户看到的对比结果完全错误!
- 此时
生产环境中,这种额外的渲染可能来自:
- WebSocket 推送更新
- 定时器刷新
- 其他状态管理导致的重新渲染
- 第三方库的副作用
测试环境没有这些复杂的交互,所以一直没发现问题。
救命稻草
经过一番研究和社区讨论,我找到了正确的实现方式:
scss
import { useState } from 'react'
// Following these issues I think this is the best way to implement usePrevious:
// https://github.com/childrentime/reactuse/issues/115
export function usePrevious<T>(value: T): T | undefined {
const [current, setCurrent] = useState<T>(value)
const [previous, setPrevious] = useState<T>()
if (value !== current) {
setPrevious(current)
setCurrent(value)
}
return previous
}
这个实现的精妙之处在于:
- 在渲染期间直接处理 :不依赖
useEffect
的异步执行 - 原子性操作:在同一次渲染中完成前值保存和当前值更新
- 不受额外渲染影响:无论后续有多少次重新渲染,逻辑都保持一致
血的教训
部署修复后,功能恢复正常,但这次事故给了我深刻的教训:
不应该用 useRef
来存储状态,而应该用 useState
。
useRef
的问题在于它存储的是一个可变的引用,当多次渲染发生时,这个引用的值可能在不可预期的时机被修改。而 useState
保证了状态更新的一致性和可预测性。
这个问题在 React 社区也有相关讨论,详见 React Issue #25893,里面详细分析了为什么基于 useRef
+ useEffect
的方案在复杂场景下会出现问题。
后记
这个 usePrevious
的血案让我意识到,选择正确的 React API 比写出看似巧妙的代码更重要。有时候,最简单直接的方案往往是最可靠的。
从此以后,我对任何涉及状态管理的代码都格外小心,也更加重视生产环境的监控和测试。
毕竟,没有什么比周五下午的生产事故更能让人印象深刻的了...
参考资料: