ref 存真相,快照交给 React——一个逐字符打字引擎
键道 KeyDo 复盘:打字是高频键盘事件,直接塞进 React 状态会被 StrictMode 和异步 setState 坑到。真实状态放 ref,改完提交一份快照渲染。
键道 KeyDo 里,键位课程和极速测试共用同一个打字引擎 useTypingEngine:监听全局键盘、逐字符判断对错、维护光标位置。听起来就是「按一下键,改一下 state」,但在 React 里这么写会踩两个坑。这篇讲怎么绕过去。
打字为什么不能直接塞进 state
打字是高频事件——每次击键都要读当前光标位置、判断对错、推进位置。问题在两点:
setState是异步的。你在handleKey里setPos(pos + 1),紧接着想读新的pos,读到的还是旧值。连续快打时,基于陈旧闭包值的更新会互相覆盖。- StrictMode 会双调用 updater。开发模式下 React 故意把
setState(updater)的 updater 函数调两次来帮你查副作用。如果你把「判断对错、累加击键数」这类逻辑写进 updater,就会执行两遍——错误数翻倍、计数错乱。
结论:打字这种「读改写紧耦合的高频状态」,不该由 React 的异步 state 直接承载。
真相放 ref,渲染用快照
解法是把状态一分为二:
- 真相存在一个
useRef里(stateRef.current)——光标pos、每个字符的charStates、startedAt、keystrokes、errors。它是同步的、可变的、立即可读的; - 渲染用一份快照。每次在事件处理器里改完 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(还没敲到)、correct、incorrect。Backspace 把光标退一格、并把那一位改回 pending——允许回删纠正,但计数里的 keystrokes 和 errors 不回退(错了就是错了,准确率如实反映)。
计时是惰性的: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 里打架。
留言