![]()
一个前端工程师用React写游戏,帧率掉到个位数时,他意识到问题不在代码,而在框架的「肌肉记忆」。
这是@nyaomaru的真实经历。他拿到了NVIDIA送的《生化危机9》,到现在还没见到第一只僵尸——但他在浏览器里做的小游戏《Run Away From Work》,却让他对React有了完全不同的理解。
游戏看起来很简单:一个打工鱼逃跑,老板追,路上捡鱼币躲障碍。但当他试图用setState驱动每一帧动画时,浏览器风扇开始狂转。
setState → 重渲染 → diff → DOM更新,这套流程在60fps下就是性能自杀。
他没有换框架,而是换了一套架构思路。React还在,但游戏循环(Game Loop)被彻底剥离出去。
React的渲染模型,和游戏循环是两种生物
React的核心假设是:状态变了,UI重新计算。这对表单、列表、仪表盘是完美的。
但游戏不一样。游戏需要每16.6毫秒更新一次画面,而且更新的往往不是整棵树,是几十个实体的坐标。用React的diff算法处理这个,就像用Excel公式做实时渲染——能跑,但何必。
@nyaomaru的解法很直接:React只负责静态结构,动态实体直接操作DOM。
看这段代码。游戏区域由React渲染,但玩家、老板、障碍物这些「活物」,全是原生DOM节点。
他用document.createElement生成障碍物,用appendChild塞进游戏区域,用requestAnimationFrame驱动位置更新。React完全不知道这些节点存在。
这听起来像「反模式」,但性能数据不会骗人。浏览器主线程从「喘不过气」变成「游刃有余」,帧率稳在60fps。
三层架构:React当骨架,原生DOM当肌肉
具体实现分三层。最外层是React的领地:游戏容器、UI覆盖层、分数显示。这些很少变动,React处理得干净利落。
中间层是实体管理层。@nyaomaru用useRef保存所有动态元素的引用,但绝不把它们塞进state。障碍物数组存在ref里,React渲染周期永远看不到它们。
最内层是动画循环。requestAnimationFrame每帧直接修改DOM节点的style.transform,跳过React的调和(Reconciliation)全过程。
这种「混合模式」的关键是隔离。React管它的,原生DOM管它的,两者通过ref建立单向联系。React知道游戏区域在哪里,但不知道里面有多少个障碍物在飞。
一个细节:玩家和老板的精灵图也是SVG,但位置更新不走React。sprite的transform属性被直接赋值,浏览器合成层(Compositor)接手后续工作,主线程零负担。
为什么不用Canvas?
读到这你可能会问:都操作原生DOM了,为什么不直接用Canvas 2D或WebGL?
@nyaomaru考虑过,但拒绝了。他的理由是工具链和审美:所有视觉素材都是Adobe Illustrator手绘的SVG,保留DOM结构意味着可以用CSS做响应式、用浏览器开发者工具直接调试、用现成的无障碍支持。
更重要的是,这个游戏的复杂度还没到Canvas的甜点区。几十个移动元素,DOM+CSS transform完全吃得消。Canvas的优势在成千上万个粒子,这里用不上。
这选择里有产品经理的权衡思维。技术选型不是选「最强」的,是选「刚刚好」的。为了10%的性能提升,牺牲50%的开发体验和可维护性,这笔账不划算。
从游戏循环看框架边界
这个案例的真正价值,是展示了如何在一个React应用里「开天窗」。
框架的渲染模型是契约。React的契约是「声明式UI,我帮你同步状态和视图」。但游戏循环的契约是「每帧直接写屏,延迟必须低于16ms」。两份契约冲突时,强行套用一方就是灾难。
@nyaomaru的做法是承认边界:React负责「结构稳定但数据多变」的部分,原生DOM负责「结构多变但数据简单」的部分。这不是对React的否定,是对其适用域的清醒认知。
他甚至在GitHub上开源了完整代码。评论区最热的反馈是:「原来useRef还能这么用」——很多人把ref当成「避免重渲染的逃生舱」,却忘了它本身就是通往DOM的合法通道。
另一个有趣的点是状态管理。分数、游戏阶段这些「业务状态」仍在React里,用useState完全没问题。但实体的位置、速度、碰撞箱这些「物理状态」,被隔离在React之外,用纯JavaScript对象管理。
这种分层让调试变得直观。游戏逻辑bug看控制台,UI bug看React DevTools,互不干扰。
性能数字背后的真相
没有公开的benchmark,但@nyaomaru描述了一个典型场景:30个障碍物同时移动,纯React方案帧率掉到15fps,混合方案稳60fps。
差距来自哪里?React的调和算法在每次setState时要遍历组件树,计算最小更新。30个障碍物意味着30次状态变更,或者1次批量变更但涉及30个组件。无论哪种,开销都远高于直接修改30个DOM节点的transform。
更隐蔽的成本是垃圾回收。频繁创建和销毁React元素会产生大量临时对象,触发GC时帧率波动。原生DOM方案的对象生命周期简单得多。
这些细节在普通应用里无关紧要,但在帧时间预算只有16ms的游戏里,每一毫秒都是战场。
给前端工程师的启示
这个案例最实用的 takeaway 是:学会在React应用里「作弊」。
useRef、createPortal、甚至直接操作DOM,这些「逃生舱」不是bug,是框架设计者预留的应急通道。当你确定某个场景不在React的甜点区时,果断绕路比强行优化更聪明。
另一个启示是关于技术写作的。@nyaomaru的文章结构很典型:先展示问题(帧率崩溃),再展示解法(架构分层),最后给可运行的代码。没有抽象的理论,全是具体的决策点和权衡。
这种写法对25-40岁的技术读者特别有效——他们经历过足够多的项目,知道「最佳实践」往往在边界处失效,想看到的是别人怎么在真实约束下做取舍。
最后提一个产品细节。游戏里的「老板」角色有一个手臂动画,用CSS关键帧实现。这个动画完全独立于游戏循环,由浏览器合成层处理,不占用主线程。
@nyaomaru在文章结尾说,他还没通关《生化危机9》,因为「看到第一个僵尸就关了游戏」。但他的小游戏已经让几百人摸鱼时多了一种选择——如果React的渲染模型没有把他逼到墙角,这个解法可能永远不会被写出来。
你现在手上有多少个「本该用React做」的场景,其实更适合直接操作DOM?
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.