先记住这个答案
ref 可以跨渲染保存数据,但它记录的是你实际写入的那一次值,不会自动代表所有意义上的“上一次”。若在 Effect 中记录,它更准确地表示上次 Effect 观察到的已提交值;没有提交的尝试和批处理中间状态未必被记录。用于 Effect 内比较或发送变化通知时,可以在那里读取旧值并更新记录。若前一个值要参与 JSX 显示,应在 state、reducer 或上层历史结构中显式建模,不推荐通过渲染期间读取随后被 Effect 改写的 ref 来生成界面。
- 明确历史是事件、提交还是 Effect 观察序列
- 比较与写入都在同一个合适阶段完成
- 页面要展示历史时把历史建模为可渲染状态
在 Effect 内完成比较和写入
状态观察器最初收到 idle,不把首次挂载解释成一次从未知状态的业务变化。后来 status 变为 loading,再变为 done,Effect 分别记录 idle 到 loading、loading 到 done,并把 current 更新为本次已经观察到的值。
onChange 也属于 Effect 读取的响应式输入,因此需要列为依赖。它的身份变化可能让 Effect 重新执行,但 Object.is 比较发现状态没变时不发送重复变化通知;这区分了 Effect 执行与业务状态真正变化。
import { useEffect, useRef } from 'react';
type Props = {
status: string;
onChange: (before: string, after: string) => void;
};
export default function StatusObserver({ status, onChange }: Props) {
const previous = useRef(status);
useEffect(() => {
const before = previous.current;
previous.current = status;
if (!Object.is(before, status)) onChange(before, status);
}, [status, onChange]);
return <p>当前状态:{status}</p>;
}首次挂载不发变化通知;后续已观察到的不同 status 才成对回传。JSX 只读取 status,没有在渲染阶段读取 previous.current;这个记录器不是持久化事件日志。
批处理中间值未必成为这份历史
如果一个事件里先请求 loading 又直接替换成 done,最终只提交 done,那么观察器可能只看到 idle 到 done。它没有承诺记录每次 setter 的入队值,因此不能拿来审计用户究竟发出了多少个动作。
需要每次点击、每次任务尝试或每条消息都保留时,应在动作发生的边界记录事件标识和输入。展示状态是一种当前投影,事件历史则是另一类数据;不要把 Effect 的调用序列硬当作完整业务事件序列。
渲染历史应与当前值一起更新
如果页面要显示涨跌趋势、上一条报价或撤销内容,可把 previous 与 current 放进同一状态结构,更新时同时推进。这样 JSX 读取的是明确状态,而不是某个会在提交后被改写的共享 ref 容器。
还要定义相同值算不算一次历史推进、首次值显示什么、组件重新挂载是否清空记录,以及对象历史是否需要保留不可变版本。仅仅复制一个通用 usePrevious 函数名,无法替你决定这些业务规则。
容易答错的地方
- Effect 写入的 ref 一定是每次 render 的前一个值
- 没有提交的渲染和被合并的中间状态未必运行对应 Effect,记录只代表实际观察过的序列;必须按用途精确命名和解释。
- ref 保存旧对象就能永久保留旧字段内容
- 如果对象随后被原地修改,旧引用看到的内容也会改变。需要历史版本时必须保护不可变数据,不能把引用容器当作自动快照复制器。
面试官还会怎么问?
Strict Mode 会让这个示例首次多发一次变化吗?
示例以当前 status 初始化,并在 Effect 中比较相同值,额外建立检查不会凭空制造状态变化。但真实外部通知仍应按业务需求处理幂等与重挂载。
为什么不在 render 最后一行更新 previous.current?
渲染可能被重复或放弃,这样会把未提交尝试写进历史并影响现有回调。应在合适阶段记录,或把需要渲染的前后值显式保存到状态里。
组件卸载再挂载后还能保留历史吗?
组件内的 ref 会随实例重新建立,不能承担跨实例持久历史。若业务需要保留,应把记录提升到更长生命周期的状态或存储层并定义清理规则。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。