一个usePrevious引发的血案

🚀 探索更多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 中看到完整的复现代码。

血案分析

问题的根源在于执行时机:

  1. 用户操作触发数据变更count 从 0 变为 1

  2. 第一次渲染

    • usePrevious 返回 previous.current(此时还是前一个值或 undefined
    • 渲染完成,显示正确的对比数据
  3. useEffect 执行previous.current = 1(更新为最新值)

  4. 额外渲染被触发:由于实时更新逻辑,组件重新渲染

  5. 第二次渲染

    • 此时 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
}

这个实现的精妙之处在于:

  1. 在渲染期间直接处理 :不依赖 useEffect 的异步执行
  2. 原子性操作:在同一次渲染中完成前值保存和当前值更新
  3. 不受额外渲染影响:无论后续有多少次重新渲染,逻辑都保持一致

血的教训

部署修复后,功能恢复正常,但这次事故给了我深刻的教训:

不应该用 useRef 来存储状态,而应该用 useState

useRef 的问题在于它存储的是一个可变的引用,当多次渲染发生时,这个引用的值可能在不可预期的时机被修改。而 useState 保证了状态更新的一致性和可预测性。

这个问题在 React 社区也有相关讨论,详见 React Issue #25893,里面详细分析了为什么基于 useRef + useEffect 的方案在复杂场景下会出现问题。

后记

这个 usePrevious 的血案让我意识到,选择正确的 React API 比写出看似巧妙的代码更重要。有时候,最简单直接的方案往往是最可靠的。

从此以后,我对任何涉及状态管理的代码都格外小心,也更加重视生产环境的监控和测试。

毕竟,没有什么比周五下午的生产事故更能让人印象深刻的了...


参考资料:

相关推荐
伍哥的传说2 小时前
Radash.js 现代化JavaScript实用工具库详解 – 轻量级Lodash替代方案
开发语言·javascript·ecmascript·tree-shaking·radash.js·debounce·throttle
前端程序媛-Tian3 小时前
【dropdown组件填坑指南】—怎么实现下拉框的位置计算
前端·javascript·vue
iamlujingtao3 小时前
js多边形算法:获取多边形中心点,且必定在多边形内部
javascript·算法
嘉琪0013 小时前
实现视频实时马赛克
linux·前端·javascript
十盒半价4 小时前
React 性能优化秘籍:从渲染顺序到组件粒度
react.js·性能优化·trae
爱分享的程序员4 小时前
前端面试专栏-前沿技术:30.跨端开发技术(React Native、Flutter)
前端·javascript·面试
超级土豆粉4 小时前
Taro 位置相关 API 介绍
前端·javascript·react.js·taro
草履虫建模4 小时前
RuoYi-Vue 项目 Docker 容器化部署 + DockerHub 上传全流程
java·前端·javascript·vue.js·spring boot·docker·dockerhub
伍哥的传说5 小时前
React & Immer 不可变数据结构的处理
前端·数据结构·react.js·proxy·immutable·immer·redux reducers
拾光拾趣录5 小时前
前端灵魂拷问:10道题
前端·面试