next-shadcn-dashboard-starter 重渲染优化指南:用 useRef 存储瞬时值,避免高频更新触发不必要的渲染
next-shadcn-dashboard-starter 重渲染优化指南:用 useRef 存储瞬时值,避免高频更新触发不必要的渲染
本篇技术指南围绕仓库内置的 Vercel React 最佳实践规则集(.claude/skills/vercel-react-best-practices/)中的「Use useRef for Transient Values」规则展开,讲解如何在 next-shadcn-dashboard-starter 这类基于 Next.js 16 + React 19 的仪表盘项目中,用 useRef 替代 useState 存储鼠标坐标、定时器 ID、瞬时标志等高频变化的临时值。读完本文,你将掌握「瞬时值进 ref、UI 状态进 state」的判别标准,并通过仓库中消息聊天、看板拖拽等模块的真实源码,看到该模式在生产组件中的落地形态。
规则速览:这条最佳实践到底在说什么
原规则文件位于 rerender-use-ref-transient-values.md,其 frontmatter 给出了规则的定位:
| 元数据字段 | 值 | 含义 |
|---|---|---|
title |
Use useRef for Transient Values | 规则标题 |
impact |
MEDIUM | 影响等级为中等 |
impactDescription |
avoids unnecessary re-renders on frequent updates | 避免频繁更新引发的无谓重渲染 |
tags |
rerender, useref, state, performance | 归属类别 |
该规则归属于规则集的第 5 个分区「Re-render Optimization (rerender)」。根据 _sections.md 的描述,这一分区聚焦于「减少不必要的重渲染,以最小化浪费的计算并提升 UI 响应性」,其影响等级为 MEDIUM——不及消除瀑布请求(CRITICAL)和优化包体积(CRITICAL)那么关键,但在交互密集型页面中收益明显。
规则的核心论断只有一句话,但实践价值极高:
当一个值变化频繁、而你又不希望在每次更新时都触发一次重渲染(例如鼠标跟踪器、定时器、瞬时标志),请把它存进
useRef而不是useState。用 state 保存 UI 状态,用 ref 保存临时的、与 DOM 相关的值。更新 ref 不会触发重渲染。
反模式演示:把瞬时值塞进 useState 的代价
规则文档给出了一个典型的反模式——一个跟随鼠标移动的黑色小圆点。若用 useState 保存 lastX,每次 mousemove 都会调用 setLastX,进而触发整个组件的重新渲染:
function Tracker() {
const [lastX, setLastX] = useState(0);
useEffect(() => {
const onMove = (e: MouseEvent) => setLastX(e.clientX);
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return (
<div
style={{
position: 'fixed',
top: 0,
left: lastX,
width: 8,
height: 8,
background: 'black'
}}
/>
);
}
问题在哪里?mousemove 事件在鼠标滑动期间可能以每秒数十次甚至上百次的频率触发,而 React 渲染一次组件的成本远高于一次简单的样式赋值。这里 lastX 只影响一个 DOM 节点的 left 样式——它本质上是「瞬时值」而非「UI 状态」:没有任何界面内容需要依据它做条件渲染、派生计算或分支判断,仅仅是被浏览器读取来画一个点。让它走完整的状态更新 → 协调(reconciliation)→ 渲染流程,属于典型的高频无效重渲染。
从 React 的工作原理看,useState 的更新意味着调度一次渲染、重新执行组件函数体、对新旧 VDOM 做 diff,而 ref 的写入只是修改 ref.current 这个普通对象的属性,React 根本不会感知,也就不会触发任何渲染流程。这正是「瞬时值用 ref」的底层依据。
正确姿势:useRef 存值 + 直接操作 DOM
规则的推荐做法是:把瞬时值放进 ref,并绕过 React 的渲染管线、直接更新 DOM 节点:
function Tracker() {
const lastXRef = useRef(0);
const dotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onMove = (e: MouseEvent) => {
lastXRef.current = e.clientX;
const node = dotRef.current;
if (node) {
node.style.transform = `translateX(${e.clientX}px)`;
}
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return (
<div
ref={dotRef}
style={{
position: 'fixed',
top: 0,
left: 0,
width: 8,
height: 8,
background: 'black',
transform: 'translateX(0px)'
}}
/>
);
}
这个版本有两个关键动作:
lastXRef.current = e.clientX—— 瞬时值落进 ref,更新零成本、零渲染;node.style.transform = ...—— 用 ref 拿到 DOM 节点,直接改写样式属性,让浏览器只做一次极轻量的样式计算(这里用transform替代left还能避免触发重排,属于额外的渲染性能红利)。
事件监听器通过 effect 挂载、并在清理函数中移除,确保组件卸载时不泄漏全局监听。需要注意:ref 更新不会反映到 UI 上——lastXRef 的值变化 React 完全无感知,这也是它只能服务于「值本身无需驱动界面」的瞬时场景的原因。这条模式可以概括为:高频、只写不读渲染的值 → ref + 命令式 DOM 操作;需要驱动界面的值 → state。
仓库源码佐证:next-shadcn-dashboard-starter 中的 useRef 实战
这条规则并非纸上谈兵。在 next-shadcn-dashboard-starter 的实际源码中,可以找到多个完全一致的应用形态。
定时器与超时 ID:放进 ref,避免「假依赖」
在 use-debounced-callback.ts 中,防抖定时器 ID 被保存在 React.useRef(0) 中:
const debounceTimerRef = React.useRef(0);
React.useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []);
const setValue = React.useCallback(
(...args: Parameters<T>) => {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = window.setTimeout(() => handleCallback(...args), delay);
},
[handleCallback, delay]
);
定时器 ID 是教科书级的瞬时值:它每次调度都不同、从不需要渲染、更不该被 React 当作依赖追踪。若用 useState 保存它,setValue 每次调用都会触发一次渲染;而把它放进 ref,setValue 函数才能保持 useCallback 的稳定身份,组件引用它的地方不会因防抖调度而重渲染。
聊天模块的 messenger.tsx 有同样的用法——replyTimeoutRef 保存「正在等待 AI 回复」的超时句柄,同时 selectedRef 缓存当前选中的会话 ID 作为瞬时快照,避免状态更新间的闭包过期问题。
把 callback 存进 ref:稳定 prop 身份
use-callback-ref.tsx 更进一层:把回调函数本身存进 ref,返回一个恒定的包装函数,从而「避免作为 prop 传递时触发重渲染,或作为 effect 依赖时重复执行 effect」:
function useCallbackRef<T extends (...args: never[]) => unknown>(callback: T | undefined): T {
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
});
return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}
这里的 useRef 承担的是「最新值容器」的角色:包装函数身份恒定,但每次渲染后 callbackRef.current 都会被刷新到最新的回调,子组件拿到的始终是最新逻辑、却不会因为回调引用变化而重渲染。这是 ref 用于「值可变但不触发渲染」的又一佐证。
直接操作滚动容器与文件输入
聊天界面 chat-area.tsx 用 messagesContainerRef 和 liveRegionRef 两个 DOM ref 在收到新消息时命令式滚动到底部、同步无障碍实时区域,全程不经过任何状态更新;message-composer.tsx 用 fileInputRef 触达隐藏的文件选择 <input>;看板的 kanban-board.tsx 则用 containerRef 在拖拽过程中实时读取容器尺寸与边界。这些都是「值或节点无需驱动渲染,直接操作 DOM」的场景,与规则中的 dotRef 用法同构。
判定标准与相邻优化手段的分工
把规则文档与仓库实践放在一起,可以提炼出如下决策表:
| 场景 | 该用什么 | 原因 |
|---|---|---|
| 鼠标坐标、滚动位置、瞬时标志 | useRef + 直接操作 DOM |
更新不触发渲染,零协调开销 |
| 定时器 ID、超时句柄 | useRef |
从不需要渲染,放进 ref 还能保持回调身份稳定 |
| 需要驱动界面条件渲染的值 | useState |
界面必须响应变化 |
| 用户输入引发的高开销派生渲染 | useDeferredValue + useMemo |
让输入保持响应,渲染延后到空闲时(详见 rerender-use-deferred-value.md) |
| 滚动等高频非紧急更新 | startTransition |
标记为过渡更新,不阻塞交互(详见 rerender-transitions.md) |
三条边界值得牢记:
- ref 不会触发渲染,因此凡是要靠它驱动界面的场景都不成立;界面分支判断永远走 state。
- 高频但「最终要反映到 UI」的值(如搜索结果列表),优先考虑
useDeferredValue/startTransition而非 ref,因为 ref 只能让你丢失 UI 同步,不能延迟渲染。 - effect 依赖数组里不要出现瞬时 ref 值——ref 的写入不触发 effect 重跑,想要「值变化时执行副作用」仍需 state 或
useCallbackRef这类桥接模式。
小结
「Use useRef for Transient Values」是一条小而实用的重渲染优化规则:把 useState 留给真正驱动界面的 UI 状态,把高频变化的瞬时值放进 useRef 并直接操作 DOM。next-shadcn-dashboard-starter 的聊天、看板、防抖 hook 源码从多个侧面印证了这一模式在真实仪表盘组件中的可行性。对交互密集的聊天消息流、拖拽看板、实时图表等模块,这一取舍能显著削减高频事件下的无效渲染开销,让 UI 响应保持轻快。