提测前一天晚上,我盯着手里的测试机开始怀疑人生:用 React + pixi.js 做的多平台小游戏,主场景在真机上只有三十几帧,手机上半部分烫得像暖手宝。这段代码在电脑浏览器里跑得好好的,打包进某个小程序容器就掉链子。后来回头看,问题从来不在某一帧的代码,而是从第一天选型、画架构图的时候就埋下了雷。
如果你也在纠结小游戏到底要不要上 React + pixi.js,或者已经入坑但被多端兼容磨到怀疑人生,这篇复盘应该对你有用。我会从选型逻辑讲起,再到工程骨架、渲染循环、多端适配和性能优化,最后专门复盘三个踩得最狠、网上资料又讲得比较模糊的坑。所有结论都配当时的实测数据,不是拍脑袋。
1. 为什么是 React + pixi.js,而不是“一步到位”的游戏引擎
1.1 需求倒逼选型:这个项目到底在要什么
这个项目是一个休闲向的多平台小游戏,玩法偏消除和短关卡,预期生命周期内要不断跟随运营活动换皮、加模式。目标平台当时列了一串:桌面浏览器 H5、移动端浏览器,还有两个不同规则的小程序容器。团队是清一色的前端背景,React 的熟练度远高于任何游戏引擎经验,这是所有讨论的前提。
如果把这几条需求翻译成选型标准,其实就三句话:包体要小,因为容器平台对首次加载体积有硬约束;迭代要快,因为换皮活动的周期常常以周计;画面复杂度中等,不需要 3D、不需要重型物理模拟,核心表现靠位图动画、粒子反馈和 UI 动效。后面所有技术取舍,都是围绕这三句话展开的。很多人选引擎时是从“能做什么”出发,我建议反过来,先从“这个项目不需要什么”把选项砍掉一半。
1.2 pixi.js 负责画面,React 负责“人机接口”
在这套组合里,职责边界第一条:凡是每帧都要变化的东西,全部进 pixi;凡是离散业务事件驱动的界面,全部交给 React。pixi 管的是一张 canvas 上的精灵、纹理、滤镜、粒子、动画循环;React 管的是弹窗、结算页、设置面板、加载进度这类 DOM 层的东西。
但这里有一个容易被低估的点:游戏里很多状态介于两者之间。比如分数、连击数、当前关卡——它们既会被 canvas 里的系统修改,又要显示在 DOM 层的 HUD 上。我们的做法是单独建一个轻量状态仓库,pixi 的业务系统往这个仓库里写数据,React 组件通过订阅读数据。这样 canvas 和 DOM 之间没有直接依赖,跨端迁移时最舒服。后面章节会详细讲这个仓库怎么设计,这里先记住结论:不要让 pixi 直接调用 React 的 setState,也不要让 React 直接操作 pixi 的舞台对象,状态仓库是两者之间唯一的桥梁。
1.3 对比过常见引擎,为什么还是自己搭
要交代清楚选型,还得说说我们对比过的另外两类方案。一类是完整的 2D 游戏框架,自带场景、动画、物理和插件体系,功能确实全面,但代价是核心包体积大一个量级,而且它的生命周期跟 React 完全不同,硬桥接很容易出现两套 update 循环打架的局面;另一类是以编辑器为核心的小游戏引擎,适合小游戏生态为主战场的团队,但它的工作流对 Web 前端来说学习成本不低。在这两类都不是最优的情况下,React + pixi.js 这种“渲染引擎 + UI 框架”的组合反而是贴身方案。
| 对比维度 | React + pixi.js | 完整游戏框架 | 编辑器型小游戏引擎 |
|---|---|---|---|
| 包体积 | 核心可裁剪,落地约 300-400KB | 完整框架通常明显更大 | 视导出配置而定,起步不轻 |
| 团队上手 | 前端 React 经验直接迁移 | 需要重新学一套生命周期 | 需要编辑器工作流,养成周期长 |
| React 生态 | 天然融合,无缝复用组件库 | 需自建桥接层 | 基本无关 |
| 自带能力 | 只有渲染,其他自己搭 | 场景/物理/动画齐全 | 综合能力最强 |
| 容器适配 | 适配层由自己控制,黑盒少 | 依赖框架官方支持范围 | 小游戏平台适配最成熟 |
注意我刻意没写“哪个更好”。选型只存在“在约束条件下哪个代价最小”。完整框架的物理引擎,在我们这个项目里一个都用不上,它反而把基线包体抬高了一截;编辑器型引擎的生态优势,也没法抵消前端团队在 React 侧的存量熟练度。如果你要做的游戏物理交互是核心玩法,或者你的主战场就在小游戏容器里,结论完全可能反过来。
2. 工程骨架:让 React 和 pixi 各住各的房间
2.1 目录结构:先划清边界再写代码
选型定了之后,第一件事不是搭场景,而是把目录边界划死。我们最终的结构是这样的:
src/ bootstrap/ # 全局初始化、平台检测 renderer/ # pixi Application 生命周期 scenes/ # 每个玩法场景:MainScene、LevelScene systems/ # 游戏循环里的纯逻辑系统 ui/ # React 组件:HUD、弹窗、设置页 store/ # 非 React 的轻量状态层 + 事件订阅 platforms/ # 浏览器/小程序容器适配层renderer 目录只负责 pixi Application 的创建、销毁和 resize,不写任何玩法逻辑;scenes 目录放命令式的场景对象,每个 Scene 有 enter/exit/update 三个方法;systems 目录是纯逻辑系统,输入是游戏状态,输出是状态变更;ui 目录是 React 组件;store 目录是状态仓库和事件订阅;platforms 目录是唯一允许写“平台差异 if”的地方。这条纪律后面救了我们很多次,尤其是遇到容器差异时,你不会想去满世界找那段脏代码。
2.2 Canvas 生命周期:useRef、useEffect 与 StrictMode 的三角关系
React 接入 pixi 最常见的写法是把 canvas 放进组件,在 useEffect 里创建 Application。这个方向对,但有两个细节必须在项目第一天就处理好。第一是 StrictMode:开发模式下 effect 会被执行两次,如果第二个 Application 创建时第一个还没 destroy 干净,轻则警告重则 WebGL context 直接异常。我们后来直接在游戏根组件外面包了一个不启用 StrictMode 的入口,文档里也写明了原因,避免后来的人踩坑。第二是 cleanup 要彻底:app.destroy 要把 children、texture、baseTexture 都带上,否则热更新几次之后 GPU 内存肉眼可见地涨。
import { useEffect, useRef } from 'react'; import * as PIXI from 'pixi.js'; export default function GameCanvas() { const canvasRef = useRef<HTMLCanvasElement | null>(null); const appRef = useRef<PIXI.Application | null>(null); useEffect(() => { if (!canvasRef.current) return; const app = new PIXI.Application({ view: canvasRef.current, width: 1280, height: 720, antialias: false, autoDensity: true, }); appRef.current = app; startGame(app); return () => { app.destroy(true, { children: true, texture: true, baseTexture: true }); appRef.current = null; }; }, []); return <canvas ref={canvasRef} style={{ touchAction: 'none', width: '100%', height: '100%' }} />; }注意两处细节:autoDensity: true让 pixi 自动根据 devicePixelRatio 调整 canvas 的缓冲大小,避免高分屏发虚;antialias: false是因为我们的画面基本是位图素材,抗锯齿在纹理层面解决比在渲染管线里硬扛划算得多。canvas 的样式里必须带touchAction: 'none',否则移动端浏览器会把触摸手势优先交给页面滚动,游戏里的拖拽和点击都会被吞掉。
2.3 状态仓库:把 React 挡在帧循环外面
游戏循环每秒跑 60 次,React 的 setState 如果跟着跑 60 次,再快的组件也会被渲染调度拖垮。我们刻意做了个“缓冲层”:pixi 的 ticker 里只更新游戏系统的内部状态,只有像分数、弹窗开关这类离散事件才会写入 store,再由 store 通知 React。为了复用 React 的并发渲染能力,组件用 useSyncExternalStore 订阅,这个 API 的特点是只有拿到的快照变化时才会重渲染,天然适合低频、离散的游戏事件。
// store/gameStore.ts type Listener = () => void; const listeners = new Set<Listener>(); let state = { score: 0, combo: 0, phase: 'playing' as const, }; export function subscribe(listener: Listener) { listeners.add(listener); return () => listeners.delete(listener); } export function getSnapshot() { return state; } export function updateScore(delta: number) { state = { ...state, score: state.score + delta }; listeners.forEach((l) => l()); }对应组件侧:
import { useSyncExternalStore } from 'react'; import { subscribe, getSnapshot } from '../store/gameStore'; export function ScoreBadge() { const state = useSyncExternalStore(subscribe, getSnapshot); return <div>分数:{state.score}</div>; }这里还有一个 React 生态里常见的坑:别用 Context 去广播游戏状态。Context 的一次更新会让所有 Consumer 组件重渲染,游戏页面组件树层级不浅,一次分数更新可能拖垮整棵树的 reconcile。细粒度订阅的代价是多写一点样板代码,但换来的是帧循环与 UI 渲染彻底解耦。
3. 游戏循环与渲染帧:别让 React 参与每一帧
3.1 哪些对象能做成组件,哪些必须命令式
官方有一个把 pixi 对象声明化的渲染方案,可以把精灵写成 JSX。我们用下来的结论是:适合对象数量少、结构稳定的 UI 化元素;不适合游戏场景里成百上千的动态对象。原因很简单,reconciler 的 diff 是有成本的,而游戏场景里大量对象每帧都在更新 transform,声明式写法里你改的永远是最外层状态,diff 复杂度并不低。换成命令式直接改 sprite.position,语义清楚,性能也直接可控。
3.2 ticker 更新链:固定步长、delta 钳制、限帧
pixi 自带 Application.ticker,游戏主循环就挂在上面。我们每个 Scene 暴露 update(deltaSec) 并由一个 SceneManager 串起来,所有系统(匹配、计分、动画、音效)都在 update 里顺序调用。这里三个细节比较关键。第一,delta 必须钳制:浏览器切后台再回来,rAF 暂停导致 deltaMS 可能变成几千毫秒,如果不处理,精灵会瞬移、动画会跳帧;我们统一取 Math.min(deltaMS, 64),超过的部分丢弃,宁缺帧不乱跳。第二,限帧:部分高刷新率机型会把 ticker 跑到 120fps,对休闲游戏来说纯浪费电,直接app.ticker.maxFPS = 60。第三,update 里不要做任何 IO、不要分配大对象,这个阶段是性能敏感区,GC 停顿会直接表现为掉帧。
app.ticker.add((ticker) => { const deltaSec = Math.min(ticker.deltaMS, 64) / 1000; sceneManager.update(deltaSec); fxSystem.update(deltaSec); audioSystem.update(deltaSec); });3.3 事件从 PIXI 世界回到 React 的路径
玩家点击 canvas 后,pixi 的 EventSystem 会走到我们绑定的回调。回调里只做两件事:调用对应的游戏系统,以及必要时把结果写入 store。React 侧完全不知道 pixi 存在,它只订阅自己关心的快照。举个例子,点击一个可消除方块时,回调先调用 matchSystem.tryMatch,系统判定成功后更新组合数并触发动画,再把新的分数写入 store;HUD 上的 ScoreBadge 因为订阅了 store,自动拿到新值。整条链路是单向的:pixi -> systems -> store -> React,绝不反向。
// 绑定在某个可交互对象上的回调 function onTileTap(index: number) { const match = matchSystem.tryMatch(index); if (match.ok) { comboSystem.addCombo(match.targetCount); fxSystem.play('match', match.positions); updateScore(scoreSystem.value); } }4. 多平台适配:真正拉开差距的地方
4.1 分辨率、DPR 与安全区
多平台游戏第一个问题是屏幕比例千差万别。我们采用“设计分辨率 + 等比缩放”的策略:所有场景按 1280x720 设计,适配时取目标容器宽高比,计算一个等比 scale,把主场景容器居中,两侧留背景。缩放公式是scale = min(clientWidth / 1280, clientHeight / 720)。这样横屏竖屏都不会裁掉关键玩法区域,代价是部分屏幕上会有留边,但休闲游戏可以接受。如果你做的是强表现的动作游戏,可以改成 fill 模式,配合安全区把操作按钮向内收。
DPR 处理是第二个点。pixi 的 autoDensity 会根据 devicePixelRatio 自动调整 canvas 缓冲大小。高 DPR 机型上画面确实更锐利,但像素填充量按 DPR 的平方上涨,低端机直接卡顿。我们的策略是全局 DPR 封顶 2,低端机再降级到 1.5。第三个点是安全区:Web 端靠 viewport-fit=cover 和 env(safe-area-inset-*) 拿安全区;小程序容器拿的是容器暴露的刘海和胶囊区域参数。这些差异全部封装在 platforms 目录的 getSafeArea() 里,UI 层只调接口,绝不自己判断平台。
// platforms/adapter.ts export interface SafeArea { top: number; bottom: number; left: number; right: number; } export function getSafeArea(): SafeArea { // 浏览器端:解析 env(safe-area-inset-*) // 小程序端:使用容器暴露的参数 // 统一在 platforms 目录内分平台实现 }4.2 输入统一:没有 hover、没有右键、触摸点要抢在异步前拷贝
桌面鼠标和触摸屏的行为差异比大多数人想象的大。鼠标有 hover,触摸没有;鼠标有右键,触摸没有;触摸有多个触点,鼠标没有。pixi 的 EventSystem 帮我们统一了 pointer 事件,但到了小程序容器,pointer 事件不一定完整,我们只好自己在 platforms 层把 touchstart / touchmove / touchend 翻译成统一的 game-input 事件。这里有一个非常容易踩的坑:touch 事件对象的 changedTouches 只在同步阶段可靠,如果你在回调里做异步操作,回头再读坐标,拿到的是空的或者错位的对象。所以我们的规则是事件回调第一行就把坐标拷贝成普通 number 变量。另外,CSS 里 canvas 必须 touch-action:none,否则浏览器会把快速连击识别成滚动,直接吞掉你的触摸事件。
function tapToWorld(e: TouchEvent | MouseEvent) { const touch = (e as TouchEvent).changedTouches ? (e as TouchEvent).changedTouches[0] : (e as MouseEvent); const clientX = touch.clientX; // 第一行立刻拷贝 const clientY = touch.clientY; // 后续全部使用 clientX/clientY,不要再碰事件对象 return viewportStore.toWorld(clientX, clientY); }4.3 容器适配与 WebGL 上下文丢失
所谓“多平台”,本质是容器之间的浏览器 API 完整度差异。桌面浏览器和移动浏览器算一类,小程序容器是另一类:canvas 创建方式不同、requestAnimationFrame 的注入方式不同、devicePixelRatio 的获取方式也不同。我们定义了一个 PlatformAdapter 接口,把 createCanvas、requestAnimationFrame、devicePixelRatio、getSafeArea 都抽象出来,启动时按容器注入。pixi 本身也能接收自定义 adapter,但具体要看你用的版本对容器支持到什么程度。在选型阶段就必须确认目标容器支持 WebGL,因为 pixi 新版本已经移除了 Canvas 渲染器,如果某个容器只能提供 2D context,这条路直接走不通。我们因为这条约束提前排掉了一个容器。
WebGL 上下文丢失是多端游戏最阴的故障:现象是用户切后台几分钟或者系统弹窗抢占 GPU 后回来,画面白屏,控制台一条报错都没有。原因是移动端 GPU 在内存压力下会主动回收上下文,恢复后所有纹理绑定都失效。处理办法是监听 canvas 的 webglcontextlost 和 webglcontextrestored,丢失时阻止默认行为并暂停 ticker,恢复后重新上传所有纹理。我们维护了一个 textureManager,记录所有图集底图和纹理的原始数据,恢复时统一 reuploadAll。
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); app.ticker.stop(); // 提示层告诉用户游戏暂停,不是崩溃 }); canvas.addEventListener('webglcontextrestored', () => { textureManager.reuploadAll(); app.ticker.start(); });5. 性能优化:实操顺序与真机数据
5.1 先砍 draw call,再看 CPU
优化从渲染管线开始。pixi 会把相同状态的对象合批,但纹理不同、混合模式不同都会打断批次,表现为 draw call 数量上升。游戏里最典型的浪费是每个精灵单独用一张小纹理。我们把美术产出的零散 PNG 全部打包成图集,UI 和玩法资源各打一套,场景里所有子弹、特效碎片用同一张图集,实测 draw call 从 100+ 降到 30 左右。检查 draw call 不需要额外工具,pixi 的 debug 面板能看到当前渲染批次,也可以临时给 Renderer 挂统计。优化顺序永远是先看 draw call、再查纹理内存、最后才考虑 CPU 侧的 JS 开销,因为绝大多数游戏掉帧都是渲染管线的瓶颈。
5.2 对象池、cacheAsBitmap 和纹理生命周期
动态对象的创建和销毁是 GC 抖动的主要来源,子弹、飘字、粒子这类高频对象统一走对象池。我们的池子很简单:acquire 时从空闲栈里取,取不到才 factory 创建;release 时把对象还原到初始状态后放回栈里。注意 release 时一定要把 scale、alpha、position、visible 全部还原,否则下次取出来就是脏对象。对于结构复杂但很少变化的静态对象(比如带阴影的按钮底),用 cacheAsBitmap 把它烘焙成位图,能省掉重复的绘制指令;但这个开关只适合低频变化的对象,如果容器频繁变化,烘焙反而更慢,因为它每次都要重新生成纹理。
class ObjectPool<T> { private items: T[] = []; constructor(private factory: () => T) {} acquire(): T { return this.items.pop() ?? this.factory(); } release(item: T) { this.reset(item); this.items.push(item); } private reset(item: T) { // 还原 position/scale/alpha/visible 等字段 } }纹理生命周期是最容易被忽视的。我们一开始在场景切换时逐个 destroy sprite,结果出现了花屏和内存泄漏。后来才明白:从图集里切出来的纹理不能单独 destroy,图集是共享的,必须按图集整体管理生命周期。场景卸载时先 removeChildren 解除引用,再决定整张图集是否销毁。内存曲线的判断用“峰值而不是均值”,多端机型上,最准的衡量标准是低端机的页面总内存,休闲游戏最好压在 200MB 以内。
5.3 真机数据:优化前后的对照
| 目标平台 | 机型档位 | 优化前帧率 | 优化后帧率 | 优化后 draw call | 备注 |
|---|---|---|---|---|---|
| 桌面浏览器 H5 | 中端笔记本 | 60 | 60 | 28 | 压力很小 |
| 移动浏览器 H5 | iOS 中高端 | 50-60 | 60 | 30 | 瓶颈在 DPR 与滤镜 |
| 移动浏览器 H5 | 安卓中端 | 35-45 | 50-60 | 31 | 关闭抗锯齿后明显改善 |
| 小程序容器 | 安卓中端 | 25-35 | 45-55 | 32 | 容器自身开销约 5-8ms |
三个结论写在这里。第一,draw call 在所有档位都压到了 35 以下,但帧率差距依然存在,说明这部分项目的瓶颈在填充率而不是合批;第二,安卓中端机上关闭抗锯齿、DPR 降到 1.5 带来的收益比任何代码优化都大,因为像素填充量直接减半;第三,小程序容器比浏览器多出约 5-8ms 的固定开销,留给游戏逻辑的预算更紧,必须把单帧预算从 16ms 压到 12ms 才算安全。
6. 三个踩得最深的坑:根因、排查、修复
6.1 图集碎图边缘的“白边”和“黑边”
现象是某些图标的边缘在高分屏或缩放后出现一圈 1px 左右的白边,安卓部分机型上甚至会变黑边。我们把问题图单独导出纹理检查,发现并不是美术资源本身的问题,而是图集打包时相邻纹理被 mipmap 的低分辨率层级采样到了,边缘像素混进了隔壁图块的颜色。修复分三步:图集打包时开启 extrude 预留 2px 边框;纹理的 wrapMode 显式设为 CLAMP;对不参与缩放的纹理直接关闭 mipmap。这三步做完,边缘问题在测试机型上全部消失。经验是:只要图集可能被缩放,就必须留边框,不要拿美术资源肉眼检查代替,真机验证才是唯一标准。
6.2 切后台再回来,白屏一小时
最早发现这个 bug 是同事在真机上锁屏几分钟后按回来,整个游戏白屏,属于必现。第一反应是代码崩了,但连上调试器之后页面正常无报错,接着我们又怀疑是内存泄漏导致页面被系统回收,可回收后应该重新加载而不是白屏。最后是加上 contextlost 监听才确认是 WebGL 上下文丢失。修复方案就是上面写的那些:监听、阻止默认行为、暂停循环、恢复后重建纹理。这里再补一句,不同系统端触发概率差别很大,安卓中低端机更常见,越老的系统越容易触发,所以不要在预览环境里测了几次没复现就关掉这个处理。
6.3 快速连击后按钮“失灵”
测试组反馈:连续快速点击同一个按钮五六次之后,按钮彻底没反应。这个坑藏得很深。我们的按钮在 Web 端监听 pointerdown,在小程序容器里却退化成了 click。容器对快速连击有一套手势判定,连续触摸被识别成滚动意图后,后续的 click 就不会派发了,于是按钮看起来就是“失灵”。修复是在 platforms 层对所有容器统一使用 touchstart/pointerdown 作为游戏控件触发,并在监听时传 passive:false;同时在坐标拷贝上严格执行前文说的“同步拷贝”规则。另外要注意桌面浏览器同时存在 click 和 pointerdown,同一个物理点击可能触发两次,我们用一个 lastTriggerTime 和 50ms 阈值做了去重。这个坑教会我一条原则:游戏控件的响应逻辑永远不要挂在 click 上,click 是给链接准备的,不是给战斗准备的。
现在回头看这个项目,React + pixi.js 这套组合最大的价值,是让一群写页面出身的人用最短路径切进了游戏渲染的世界,同时没有把后续迭代的路走死。它不是一个万能方案:如果你的玩法重度依赖物理模拟,或者主战场就在小游戏容器生态里,完整游戏引擎仍然更合适。但对我来说,下次再做“业务逻辑不重、要跨三四端、还要持续上活动”的小游戏,我大概率还是会捡起这套骨架,只是第一天就会把 PlatformAdapter 写完整,把白屏和触摸这两个坑提前堵死。希望这篇复盘能帮你少走几段夜路。