返回博客

触控板捏合抖成筛糠?React 高频事件 + state-as-effect-dep 的连环坑

某天接到一个反馈:文档预览组件里点工具栏的「+」「−」按钮缩放完全丝滑,换成触控板双指捏合,整个标注层就开始抖——覆盖线闪烁、高亮标记跳动,像是掉了 30 帧。换方向再捏(先放大立刻收回),还会出现明显的「弹回」现象。

第一反应是 CSS transition 冲突,改了半天没用。打开 Chrome Performance 一看,真正的凶器赫然在目。

现象

  • 触控板双指捏合 / Ctrl+滚轮缩放时,页面上的标注覆盖层(高亮线、边注轨道)反复闪烁抖动
  • 同样的缩放逻辑,工具栏按钮触发完全不抖
  • 捏合方向反转(放大 → 立即缩回)时出现「弹回跳变」
  • Chrome Performance 火焰图:手势期间,同一组件以 30-60 次/秒的频率重渲染,且每次渲染都带着一波昂贵的子 effect 调用

根因

翻源码找到两段连环反模式:

// 反模式 1:重 effect 把高频 state 列为依赖
const [scale, setScale] = useState(1)
 
useEffect(() => {
  clearOverlays(container)          // 清掉所有覆盖层
  highlightOverlays(container, tpl) // 重建标注高亮
  fit(container, scale)             // 适配缩放
  scheduleRedraw(container)         // 触发轨道重绘
}, [templateId, tpl, scale])        // ← scale 在这里 = 每个 wheel 事件都重建一次覆盖层
// 反模式 2:事件监听器也依赖同一 state
useEffect(() => {
  function onWheel(e: WheelEvent) {
    const next = scale * factor(e)  // ← 读的是 stale 闭包里的 scale
    setScale(next)
  }
  window.addEventListener('wheel', onWheel)
  return () => window.removeEventListener('wheel', onWheel)
}, [scale])  // ← 每次 setScale → 重渲染 → listener 被卸载再重挂

两段代码组合出的死亡链路:

wheel 事件 → setScale → React 重渲染 → 监听器重挂 → 重 effect 重跑(清+重建覆盖层)→ 下一个 wheel 事件…

手势期间这个循环以 30-60Hz 不间断运转,标注层每帧都被完整地拆了又装。

「弹回」的根因更隐蔽:监听器刚被卸载、新监听器还没挂好的微任务窗口里,下一个 wheel 事件到来时 scale 闭包仍是旧值,next = 旧scale × factor 会把刚应用的增量抹掉,形成肉眼可见的跳变。

坑:高频 state 进了 useEffect 依赖数组,等于每帧都调一次重型 DOM 操作

useCallback / React.memo / 防抖 setTimeout 对这个问题没有任何帮助——它们不改变 state 与 effect 的依赖关系。换手势库(@use-gesture/reactreact-zoom-pan-pinch)也白费,库可以聚合事件,但你的 effect 依赖不变,该抖还是抖。根因是结构性的:高频更新的状态被硬接进了 effect 的依赖图。

正解

三处结构性修改,缺一不可。

第一步:state 双写 ref

const [scale, setScale] = useState(1)   // 只驱动工具栏 UI 显示
const scaleRef = useRef(1)              // 高频路径的唯一真值来源
 
// 统一的 apply 函数:ref 同步写,state 异步调度
const applyScale = useCallback((next: number) => {
  scaleRef.current = next   // 下一个 wheel 事件立刻能读到最新值
  setScale(next)            // 异步触发工具栏百分比刷新
  doExpensiveDomApply(next) // fit / overlay 更新只在此处调用一次
}, [])  // 空依赖 = 引用稳定,永远不需要重建

第二步:重 effect 里把高频 state 从依赖列表踢出去

useEffect(() => {
  clearOverlays(container)
  highlightOverlays(container, tpl)
  fit(container, scaleRef.current)  // 读 ref,不读 state
  scheduleRedraw(container)
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId, tplSig, tpl])
// scale 故意不在这里——缩放变化只需要 fit(),不需要重建整个覆盖层
// 如果你看到这行 eslint-disable,不要把 scale 加回去

第三步:监听器只挂一次,用 rAF 合并同帧事件

useEffect(() => {
  let pending: number | null = null
  let raf = 0
  function flush() {
    raf = 0
    if (pending == null) return
    const next = pending; pending = null
    applyScale(next)
  }
  function onWheel(e: WheelEvent) {
    if (!e.ctrlKey && !e.metaKey) return
    e.preventDefault()
    // 始终从 pending 或 ref 读基准值,绝不读 state
    const baseline = pending ?? scaleRef.current
    pending = clamp(baseline * factor(e), MIN, MAX)
    if (!raf) raf = requestAnimationFrame(flush)
  }
  window.addEventListener('wheel', onWheel, { capture: true, passive: false })
  return () => {
    window.removeEventListener('wheel', onWheel, { capture: true })
    if (raf) cancelAnimationFrame(raf)
  }
}, [applyScale])  // applyScale 引用稳定,这个 effect 只挂载/卸载一次

rAF 合并让同一帧内的多个 wheel 事件只触发一次 setScale,将 React 渲染频率上限锁在屏幕刷新率。pending ?? scaleRef.current 消灭了弹回:每个事件都在最新的在途值上累加,而不是回头读 React 上次提交的旧状态。

正解:state 显示、ref 计算、rAF 聚合——三层分离
  • useState 只驱动 UI 展示(工具栏百分比、按钮 disabled 状态)
  • useRef 是高频路径的唯一真值,handler 读它,永不过期
  • requestAnimationFrame 把任意数量的帧内事件折叠成一次 setState
  • 重型 effect 的依赖列表只保留「文档结构变化」,不含「缩放量」

实测结果

修完之后用 React DevTools Profiler 验证:手势期间组件渲染频率从「每个 wheel 事件一次」降到「每帧最多一次(≤60Hz)」;重建覆盖层的 effect 只在真正切换文档时触发,手势中完全静默;捏合方向反转再也不弹回。

一句话外卖

高频事件状态一旦出现在 useEffect 的依赖数组里,就等于每帧都在重放整个 effect——救不了它的不是 useCallback,而是把「计算用的值」从 useState 移到 useRef