Sanity 仓库中的 Vercel React 最佳实践:用 useRef 存储瞬态值,消除高频更新引发的重渲染
【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity
本文围绕 Sanity monorepo 内置的 Vercel React 最佳实践规则rerender-use-ref-transient-values(Use useRef for Transient Values)展开:先完整继承该规则文档的核心判定标准与正反代码示例,再结合 React 的渲染调度机制与 Sanity Studio 源码中真实存在的Resizer拖拽组件,讲透"为什么用 ref 存瞬态值就能避免重渲染"以及该模式的适用边界,帮助你在开发内容工作台(content workspace)类高密度交互应用时做出正确的 state/ref 取舍。
一、规则定位:Vercel React 最佳实践体系中的一条 MEDIUM 级重渲染规则
该规则文件位于仓库的 rerender-use-ref-transient-values.md,是 Sanity 仓库为 AI Agent 内置的vercel-react-best-practices技能(skill)的一部分。同一内容在 skills/vercel-react-best-practices/rules/ 下存在一份逐字节相同的镜像副本(已用diff确认两份文件完全一致),skills-lock.json记录了该技能集的同步来源信息。
规则文件的 YAML frontmatter 给出了它的元数据:
--- title: Use useRef for Transient Values impact: MEDIUM impactDescription: avoids unnecessary re-renders on frequent updates tags: rerender, useref, state, performance ---从 SKILL.md 可以看到,该技能集共包含57 条规则、8 个类别,并按影响优先级排序。本规则属于第 5 类Re-render Optimization(重渲染优化),影响级别为MEDIUM,规则摘要为"Use refs for transient frequent values(为高频瞬态值使用 ref)"。完整编译文档见 AGENTS.md,其中该规则对应第 5.12 节"Use useRef for Transient Values"。值得注意的是,AGENTS.md 开头明确声明:这份文档主要是给 Agent 和 LLM 在维护、生成或重构 React/Next.js 代码库时遵循的规范,人类读者同样可用,但优化目标是自动化与一致性。
二、规则核心:state 管 UI,ref 管瞬态值
规则文档给出的判定标准一句话概括就是:
当一个值变化频繁、而你又不希望每次更新都触发重渲染(例如鼠标追踪器、定时器、瞬态标志位)时,把它存进
useRef而不是useState。组件状态(state)留给 UI,ref 留给临时性的、贴近 DOM 的值。更新 ref 不会触发重渲染。
"瞬态值(transient value)"的典型场景在文档中明确列出三类:
- mouse trackers—— 鼠标位置追踪(mousemove 事件,频率可达每秒数十上百次);
- intervals—— 定时器等周期性产生的中间值;
- transient flags—— 交互过程中的临时标志位(如"正在拖拽")。
这类值的共同点是:它们服务于 DOM 相邻(DOM-adjacent)的计算,而不是驱动组件的 UI 输出。用useState保存它们,每次更新都会走 React 的调度、重渲染与协调(reconciliation)流程,成本与鼠标移动频率成正比,属于典型的"高频更新 × 无 UI 收益"的浪费。
错误示例:每次 mousemove 都触发重渲染
规则文档给出的反例是一个追踪鼠标横坐标的Tracker组件:
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', }} /> ) }问题在于:setLastX(e.clientX)使lastX成为响应式状态,每收到一次mousemove,React 都要调度一次重渲染——组件函数体重新执行、div的 props diff 重新计算、样式重新提交到 DOM。对于以 60Hz 甚至更高频率触发的鼠标事件,这意味着持续不断的渲染开销,而组件树中其他依赖它的部分也连带被牵连。
正确示例:ref 存值 + 直接写 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。useRef返回的对象在组件整个生命周期内是同一个引用,写它的.current字段对 React 完全不可见——不经过调度器,不产生更新队列条目,因此不触发任何重渲染。node.style.transform = ...:既然位置变化不影响 React 树,就直接通过dotRef拿到的 DOM 节点命令式地写transform,连样式提交也绕开了 React。初始渲染时left: 0+transform: 'translateX(0px)'提供稳定的初始布局,之后的位置更新完全发生在 React 渲染循环之外。
三、原理剖析:为什么写 ref 不会触发重渲染
从 React 的运行模型看(结合源码结构可以推断):useState的更新会创建一个更新对象挂入 fiber 的更新队列,并调用ensureRootIsScheduled调度一次渲染;而useRef的返回值只是一个挂载在 fiber 上的普通可变对象{current}。对ref.current的赋值是普通 JS 属性写入,React 没有任何订阅机制感知它,因此不存在"标记脏 fiber → 重新执行组件函数"的链路。
这带来一条必须记住的边界:ref 不是响应式的。你不能在 render 函数里读ref.current并期望"值变了 UI 也跟着变"——ref 变化不会导致组件重新执行。这正是规则文档强调"Keep component state for UI"的原因:
- 值需要反映到界面输出(文本、条件分支、列表内容)→ 用
useState; - 值只服务于事件回调里的计算或直接 DOM 操作→ 用
useRef。
正例中lastXRef实际上只承担了"记录最新位置"的簿记角色(例如供后续逻辑读取),真正的视觉反馈由直接写 DOM 完成。两者缺一不可:若只存 ref 却不写 DOM,界面上将看不到任何变化;若用 state 存值,就退化回错误示例的高频重渲染。
四、Sanity 源码印证:Studio 中的 Resizer 拖拽正是这个模式
这条规则不是纸面理论——Sanity Studio 自身的代码就在用它。Resizer.tsx 是 Studio 面板布局中的拖拽分隔条组件,它的拖拽处理与本规则的正例几乎同构:
const mouseXRef = useRef(0) // 第 77 行:瞬态值用 ref const handleMouseDown = useCallback( (event: MouseEvent) => { event.preventDefault() mouseXRef.current = event.pageX // 第 83 行:按下时记录起点 onResizeStart() const handleMouseMove = (e: globalThis.MouseEvent) => { e.preventDefault() onResize(mouseXRef.current - e.pageX) // 第 89 行:拖拽中只算 delta } const controller = new AbortController() const handleMouseUp = () => controller.abort() const {signal} = controller window.addEventListener('mousemove', handleMouseMove, {signal}) window.addEventListener('mouseup', handleMouseUp, {signal}) }, [onResize, onResizeStart], )对照规则文档逐条印证:
mouseXRef = useRef(0)(第 77 行):记录鼠标按下的横坐标,这个值在拖拽期间反复被读取,但从不出现在任何 JSX 输出里,是标准的瞬态值;- mousemove 处理逻辑放在事件回调而非 effect 依赖里(第 87–90 行):
handleMouseMove每次只计算mouseXRef.current - e.pageX的位移量并回调给父组件,Resizer自身在拖拽过程中零重渲染; - 用
AbortController的signal管理监听器生命周期(第 92–97 行):mouseup时abort()一次性移除 mousemove/mouseup 两个监听,等价于规则正例中useEffect清理函数的职责,只是把清理时机绑定到了交互结束。
同一仓库中还有多个组件采用相同的 mousemove + 命令式/回调模式处理高频指针事件,例如 PaneDivider.tsx、PanelResizer.tsx、Panels.tsx 与 CommandList.tsx,从源码结构看,"高频指针事件不走 state"是 Studio 布局交互的一致约定。
五、适用边界与配套规则
使用本规则时需注意几个边界条件:
- ref 的值不能在渲染中被"读出来展示"。若某值变化后必须反映到 UI(比如显示当前坐标的数字),就必须回到 state,再考虑用
startTransition等机制降级渲染优先级——这属于技能集中rerender-transitions(Use transitions for non-urgent updates)的范畴。 - 与
rerender-move-effect-to-event联动。本规则的正例本质是"把交互逻辑放进事件处理器",两条规则经常组合出现:mousemove 的响应逻辑写在addEventListener回调里,而不是通过 state 中转再在 effect 中消费。 - 与
advanced-event-handler-refs联动。若事件处理器本身每轮渲染都重建,可把 handler 存入 ref 以保持引用稳定,进一步减少 effect 的重绑定。 - 与
client-passive-event-listeners联动。规则文档反例的mousemove监听未使用{passive: true}(因为它调用了状态更新);对 scroll/touchmove 等不preventDefault的监听器,应显式声明 passive 以避免浏览器阻塞滚动。
在 AGENTS.md 的编译目录中,本规则被编排为 Re-render Optimization 章节的第 5.12 条,与 5.11"Use Transitions for Non-Urgent Updates"相邻,两者共同构成"非紧急、高频更新"的两条处理路径:还能反映到 UI 的,走 transition 降级;不需要反映到 UI 的,走 ref + 命令式 DOM。
六、如何把这条规则用到你自己的开发流程
- 给 Agent 用:该技能集被放在
.agents/skills/下,供 AI 编码助手在"写新 React 组件、做性能评审、重构现有代码"时自动参照(SKILL.md 的 "When to Apply" 一节列出了触发场景)。当 Agent 生成类似鼠标追踪、拖拽、定时刷新的组件时,这条 MEDIUM 级规则会引导它产出 ref 版本而非 state 版本。 - 给开发者用:判断标准可直接复用文档中的一句话——"值变化频繁且不需要每次更新都重渲染"就选
useRef,"值需要驱动 UI"就选useState。落地时参照本文第二节的正例骨架:useRef存值 +useRef拿 DOM 节点 + 事件回调里直接写样式,初始渲染给出稳定的默认样式。 - 在 Sanity 生态内:如果你为 Studio 编写插件或自定义组件(如自定义 desk tool、panes),处理拖拽、悬停追踪、视口滚动这类高频指针交互时,参照 Resizer.tsx 的写法(ref 存瞬态值 + AbortController 管理监听生命周期)是与现有代码库风格一致的实践。
小结:rerender-use-ref-transient-values这条规则的技术内核非常聚焦——把"频繁变化但不驱动 UI"的值从响应式 state 迁移到不可观察的 ref,并配合命令式 DOM 写入,把高频更新的成本从 React 渲染循环彻底移除。它的影响级别是 MEDIUM,但恰是内容工作台这类交互密集应用中"体感流畅度"的关键来源之一。
【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考