ReactKeyDo

ref 存真相,快照交给 React——一个逐字符打字引擎

键道 KeyDo 复盘:打字是高频键盘事件,直接塞进 React 状态会被 StrictMode 和异步 setState 坑到。真实状态放 ref,改完提交一份快照渲染。

键道 KeyDo 里,键位课程和极速测试共用同一个打字引擎 useTypingEngine:监听全局键盘、逐字符判断对错、维护光标位置。听起来就是「按一下键,改一下 state」,但在 React 里这么写会踩两个坑。这篇讲怎么绕过去。

打字为什么不能直接塞进 state

打字是高频事件——每次击键都要读当前光标位置、判断对错、推进位置。问题在两点:

  1. setState 是异步的。你在 handleKeysetPos(pos + 1),紧接着想读新的 pos,读到的还是旧值。连续快打时,基于陈旧闭包值的更新会互相覆盖。
  2. StrictMode 会双调用 updater。开发模式下 React 故意把 setState(updater) 的 updater 函数调两次来帮你查副作用。如果你把「判断对错、累加击键数」这类逻辑写进 updater,就会执行两遍——错误数翻倍、计数错乱。

结论:打字这种「读改写紧耦合的高频状态」,不该由 React 的异步 state 直接承载。

真相放 ref,渲染用快照

解法是把状态一分为二:

  • 真相存在一个 useRef 里(stateRef.current)——光标 pos、每个字符的 charStatesstartedAtkeystrokeserrors。它是同步的、可变的、立即可读的;
  • 渲染用一份快照。每次在事件处理器里改完 ref,调一次 commit() 把当前状态浅拷贝成一个新对象 setSnap 出去,触发重渲染。
function handleKey(e) {
  const st = stateRef.current          // 直接读最新真相
  if (!st || st.finished) return
  // …判断对错、推进 pos、累加计数,全部同步改 st…
  const correct = normalizeKey(e.key) === text[st.pos]
  st.keystrokes += 1
  if (!correct) st.errors += 1
  st.charStates[st.pos] = correct ? 'correct' : 'incorrect'
  st.pos += 1
  if (st.pos >= text.length) st.finished = true
  commit()                             // 改完,提交一份快照去渲染
}

所有判断和计数都在事件处理器里同步完成,一气呵成读到最新值;React 只负责拿快照画界面。commit()charStates.slice() 拷一份新数组——因为 React 靠引用比较判断要不要重渲,直接改原数组它看不见。updater 里不写任何副作用,副作用全留在事件处理器里,StrictMode 双调用也不会影响它。

三个字符状态和一次惰性开始

每个字符有三态:pending(还没敲到)、correctincorrect。Backspace 把光标退一格、并把那一位改回 pending——允许回删纠正,但计数里的 keystrokeserrors 不回退(错了就是错了,准确率如实反映)。

计时是惰性的:startedAt 初始为 null,直到第一次真正敲下字符才记 Date.now()。这样「打开页面盯着题目发呆」的时间不算进 WPM,计时从你动手那一刻才开始。

完成回调,为什么要绕一圈放进 effect

打完最后一个字符,要通知父组件结算。直觉是在 handleKey 里直接调 onFinish()——但这是在一个组件的事件处理期间,同步去更新父组件的 state,React 会抱怨级联更新。

所以完成信号也走快照:handleKey 只把 st.finished = true 提交进快照,另起一个 effect 监听它:

useEffect(() => {
  if (snap.finished) onFinishRef.current?.()
}, [snap.finished])

finished 从 false 变 true,effect 触发,此时已经脱离了原来的事件处理上下文,再调 onFinish 就干净了。同理,onKey / onFinish 这些回调都用 ref 存最新版(onFinishRef.current = onFinish),这样 keydown 的 effect 不必把它们列进依赖、频繁解绑重绑监听器。

小结

在 React 里做一个高频的逐字符打字引擎:

  • 别让 React state 直接扛高频读改写:异步 setState 读不到最新值,快打会互相覆盖;
  • 真相放 ref,渲染用快照:事件处理器里同步改 ref,改完 commit 一份浅拷贝驱动重渲;
  • 副作用不进 updater:StrictMode 双调用 updater,判断和计数留在事件处理器里才安全;
  • 计时惰性、计数不回退:第一次击键才开始计时,Backspace 能纠错但错误照记;
  • 完成回调绕 effect:用状态位 + effect 触发,避免在事件处理期间同步更新父组件。

一句话:React 管声明式渲染,命令式的高频输入交给 ref——两边各司其职,别让它们在 updater 里打架。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。