用 useRef 保存一个数字,然后在组件函数体里每执行一次就加一,这是调试渲染行为时常见的小工具。可一旦项目根节点使用了 <React.StrictMode>,这个计数器就会表现异常:初始加载可能直接显示 2,点击按钮后数字也不是加 1,而是成倍增长。要弄明白这背后的原因,不能只盯着 useRef 本身,还需要把渲染函数、ref 对象的稳定性以及 StrictMode 的开发期检查机制放在一起分析。

一、一个最小的跳变复现
先看下面这个组件。它用一个 ref 记录渲染次数,并借助 useState 触发一次空更新,让组件重新渲染。
import { useRef, useState } from 'react';
export default function RenderCounter() {
const countRef = useRef(0);
const [, update] = useState(0);
countRef.current += 1;
return (
<div>
<p>渲染次数:{countRef.current}</p>
<button onClick={() => update((n) => n + 1)}>刷新</button>
</div>
);
}
如果项目没有开启 StrictMode,这段代码在页面上会显示 1,点击刷新按钮后依次变成 2、3、4。原因很直观:每次渲染函数执行时,countRef.current 都会增加 1,最终被用于展示。
但如果把组件放在 StrictMode 下运行,情况就变了:初次加载可能显示 2,点击按钮后可能直接跳到 4,再点变成 6。这是因为 React 在开发模式会对函数组件进行双调用,同一个 ref 对象被连续自增了两次。注意这里计数跳变的根源既不是 JavaScript 对象引用错误,也不是 useRef 的初始值被重置,而是渲染函数本身被额外执行了一次。
二、StrictMode 在幕后做了什么
StrictMode 是 React 提供的一个开发期检查组件,它不会渲染任何真实 DOM 节点,也不会影响生产构建。它的核心任务之一就是在开发模式下主动多次调用某些函数,帮助开发者提前暴露副作用不纯、渲染不确定等问题。对于函数组件来说,这些被多次调用的目标就包括组件函数体、传给 useState 或 useMemo 的初始化函数、状态更新函数,以及 useEffect 等 effect 的设置函数。
在挂载阶段,StrictMode 会额外执行一次渲染和卸载模拟。也就是说,组件函数体会先执行一次,React 计算好差异后丢弃这批结果,再执行一次并最终提交。对于 useRef 来说,存放 hooks 状态的链表并不会因为这次丢弃而清空,因此两次函数体调用拿到的是同一个 ref 对象。于是 countRef.current += 1 会在同一个内存位置上连续加两次,最终展示出来的就是 2。
更新阶段同样存在双调用。即使 React 只提交一次更新,渲染函数也可能在开发环境被调用两次。如果在渲染阶段直接修改 ref,就相当于把每次提交对应的计数放大了一倍。这也是为什么点击按钮后容易出现 4、6、8 这样的跳变,而不是稳定的递增。
三、渲染期间修改 ref 为什么不可靠
即使没有 StrictMode,直接在组件函数体中读取或写入 ref 也不是推荐做法。React 的渲染函数应当保持纯函数特性:给定相同的 props 和 state,就应当输出相同的 UI。渲染期间修改 ref,本质上是引入了外部可变状态,这会让同一次渲染过程不再确定。
更麻烦的是,React 的并发渲染允许在计算过程中暂停、丢弃或重新执行一部分渲染工作。如果渲染函数中包含了 ref.current++ 这样的副作用,那么一旦 React 因为优先级变化重新执行函数体,这个自增操作就会被重复执行。StrictMode 只是把这种不确定性在开发环境更明显地暴露出来而已。
useRef 最大的价值在于它提供一个在多次渲染之间稳定存在的可变容器,但不触发重新渲染。它适合保存 DOM 节点引用、定时器句柄、事件回调中需要读取的最新状态等。把它同时当作计数器展示在页面上,相当于把可变副作用和渲染结果耦在一起,自然会受到 React 渲染策略的影响。
四、更可靠的渲染计数方式
如果只是想观察提交次数,可以把自增逻辑从渲染阶段移到 effect 里。这样在普通环境下不会因为渲染函数被双调用而出现每次更新翻倍的问题。
import { useEffect, useRef, useState } from 'react';
export default function CommitCounter() {
const commitRef = useRef(0);
const [value, setValue] = useState(0);
useEffect(() => {
commitRef.current += 1;
console.log('提交次数:', commitRef.current);
});
return (
<div>
<p>当前值:{value}</p>
<button onClick={() => setValue((v) => v + 1)}>加一</button>
</div>
);
}
这段代码没有在渲染阶段触碰 ref,只是在每次提交后输出计数。非 StrictMode 环境下它会打印 1、2、3。StrictMode 开发环境下,挂载时 React 会模拟卸载并重新挂载 effect,所以最开始可能连续打印 1 和 2,这是严格模式故意制造的检查扰动。但后续每次点击更新只会多打印一次,不会像渲染阶段自增那样每次增加两倍。如果要把次数显示到界面上,还需要再维护一个 state;但这会引入额外渲染,实际价值并不高。
对多数开发场景来说,渲染次数更适合作为控制台或性能面板中的调试信息,而不是 UI 业务数据。React DevTools 的 Profiler 能记录每次提交、耗时和组件渲染原因,比手写计数器准确得多。手写计数器还容易受 memo、批量更新、优先级调度等因素干扰,所以不建议把渲染次数写进功能逻辑。
更关键的是,不要把 StrictMode 当作 bug 来源。它只是在开发环境把本来就可能存在的副作用问题放大了。解决方式不是关掉 StrictMode,而是让渲染函数保持纯净,把 ref 用在真正适合可变数据的地方,例如保存 DOM 元素、定时器 ID 或事件回调中的最新值。

