在 React 里跑一个命令式游戏循环——单词雨
键道 KeyDo 复盘:单词从天而降,requestAnimationFrame 每帧推进物理。React 每帧 setState 会卡死,状态得放 ref,渲染只靠一个空 tick 触发。
键道 KeyDo 的第四个模式「单词雨」是个小游戏:单词从顶部落下,你敲首字母锁定目标,整词打完就消灭它,落地扣一条命。它需要一个每秒 60 帧的游戏循环。而在 React 里跑游戏循环,第一反应的写法会直接把页面卡死。这篇讲怎么让 React 和 requestAnimationFrame 和平共处。
别每帧 setState
游戏循环每一帧要做的事:推进每个单词的 y 坐标、检测落地、生成新词、检查生命。如果这些状态都是 React state,每帧都 setState 一次——一秒 60 次触发组件重渲、diff、reconcile。词一多,帧率崩,输入卡顿,游戏体验直接报废。
React 的渲染是为「用户交互驱动的离散更新」设计的,不是为「每 16 毫秒推进一次的连续模拟」。硬把高频游戏状态塞进 React state,是拿声明式框架去干命令式的活。
状态全进 ref,渲染只靠一个空 tick
解法是把游戏世界完全搬出 React:
- 所有可变状态放 ref:
wordsRef(屏上所有单词的数组)、lockedRef(当前锁定的目标 id)、loopRef(等级、击杀数、生命、计时、requestAnimationFrame句柄); - 每帧在
frame(now)里直接改这些 ref,不碰 React state; - 渲染怎么办?循环末尾来一个
setTick(t => t + 1)——一个纯粹用来「戳一下让 React 重画」的空状态。React 重渲时直接读wordsRef.current画出当前帧。
function frame(now) {
const dt = Math.min(now - L.lastTime, 100) / 1000 // 秒
L.lastTime = now
for (const w of wordsRef.current) w.y += w.speed * dt // 命令式推进物理
// …检测落地、扣命、生成新词,全在 ref 上…
setTick(t => t + 1) // 只为触发一次重画
L.raf = requestAnimationFrame(frame)
}
真相在 ref,React 只是个画布。 每帧一次 setTick 仍然会重渲,但它只做一件轻活——读 ref 里的坐标摆好 DOM,没有复杂的 state diff 链。
时间步长:dt 要有,还要封顶
物理推进不能按「每帧固定移动几像素」——不同刷新率的屏幕(60Hz / 120Hz)会让游戏快慢不一。得用时间步长 dt:这一帧距上一帧过了多少秒,位移就是 speed * dt。这样 120Hz 屏每帧动得少、帧多,60Hz 屏每帧动得多、帧少,最终速度一致。
但 dt 有个陷阱:Math.min(now - L.lastTime, 100) 那个封顶很关键。如果用户切走标签页再回来,requestAnimationFrame 会暂停,now - lastTime 可能是好几秒——不封顶的话,所有单词「唰」地瞬移到屏幕底部,一次扣光所有命。封顶到 100ms,最坏也就跳一小步。
更彻底的处理是直接暂停:
if (document.hidden) {
L.lastTime = 0 // 清掉时间基准
L.raf = requestAnimationFrame(frame)
return // 不推进任何状态
}
标签页不可见时,循环空转但不推进游戏,并把 lastTime 清零;等切回来重新以当前时刻为基准,游戏无缝续上。练习时长 playMs 也只在可见时累计——挂后台的时间不算你在练。
锁定「最危险」的那个词
输入逻辑是 ztype 那套:敲一个字母,如果还没锁定目标,就在所有以该字母开头的词里选一个锁定;已锁定就继续打它下一个字母,对了推进、错了「噗」一声。
选哪个锁定有讲究——选位置最低的那个:
const candidates = words.filter(w => w.text[0] === key)
target = candidates.reduce((a, b) => (a.y > b.y ? a : b)) // y 最大 = 最靠下 = 最危险
最靠下的词最快落地,威胁最大,优先干掉它符合直觉。生成新词时也顺手规避歧义:不生成和现有词首字母相同的(否则你敲那个字母,系统不知道你要锁哪个),也不生成同屏重复词。首字母锁定这套玩法的前提,是任一时刻不能有两个词的首字母撞车,这个约束得在生成侧就守住。
一个闭包读到旧值的小坑
endGame 里要读最终得分,但它是在 effect 建立时的闭包里定义的,直接读 score 会读到那一帧的旧值。得分又是 React state(HUD 要显示,没法只放 ref)。解法是加一个 scoreRef 做镜像:
const scoreRef = useRef(0)
useEffect(() => { scoreRef.current = score }, [score])
// endGame 里读 scoreRef.current 拿最新分
这是「ref 存真相」在混合场景下的一个补丁——score 既要参与 React 渲染(留 state),又要在命令式回调里读最新值(加 ref 镜像)。两头都要,就两头都留一份。
小结
在 React 里跑一个 60fps 的命令式游戏循环:
- 别每帧 setState:高频连续模拟不该走 React 的离散更新,会拖垮帧率;
- 状态进 ref,渲染靠空 tick:游戏世界全放 ref,每帧一个
setTick只负责「戳一下重画」; - 用带封顶的时间步长:
speed * dt抹平刷新率差异,dt封顶 +document.hidden暂停,防切标签页后瞬移; - 锁定最危险的目标:选最靠下的词,生成侧规避首字母撞车保证锁定无歧义;
- 混合状态加 ref 镜像:既要渲染又要在命令式回调里读最新值的量,state 和 ref 各留一份。
一句话:React 不擅长跑游戏循环,那就别让它跑——让 requestAnimationFrame 在 ref 上跑模拟,React 退回到它最擅长的:把当前帧画出来。
留言