项目:html5-games | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
性能是这个项目「打开就玩」定位的组成部分:首屏要快、点开游戏要快、79 款游戏挂在同一个 SPA 里还不能互相拖累。本篇把散落在各处的性能设计收拢成四个专题——代码分割与预取、封面管线、渲染与循环、列表与 UI——每处给出源码依据与实测产物数据(来自当前 dist/ 构建产物),篇末给一份按优先级排序的优化建议。《07-模块详解》已从实现角度深读过的模块(registry、gameCoverRender),本篇只谈性能语义,机制细节以「见《07-模块详解》」引用。
分层加载模型。src/games/registry.ts 的 import.meta.glob 是全部分割的源头:构建期为 79 个游戏各生成一个独立 chunk,配合 vite.config.ts 的 manualChunks 把依赖切成 vendor(Vue 全家桶)、vueuse-vendor、pixi-vendor 三类。当前产物(dist/assets/,du 实测)给出各层体积画像:入口 index-*.js 32K,vendor-vendor-*.js 104K,GamesPage-*.js 20K(含 79 条元数据与筛选管道),GameDetailPage-*.js 3.2K,GameShell-*.js 10K(另有 10.5K CSS),单款游戏 chunk 普遍在 6-20K(最大的 metroidvania/space-shooter/roguelike/zelda 约 20K),pixi-vendor 488K 但只被 3 款游戏引用,coverEncode.worker 仅 283B。整站 6.5M 中,普通玩家的首次会话实际下载量约为 index + vendor + vueuse + GamesPage + 封面管线相关的百 K 级——pixi 与 79 款游戏代码绝大多数永远不会被一次会话触碰。
预取策略。prefetchGame(id) 在卡片 @mouseenter 与 @focusin 时触发(GameCard.vue 61-62 行),一次调用预取两个资源:目标游戏 chunk 与 GameDetailPage.vue 骨架 chunk(registry 19 行)。这个设计的巧思在于覆盖了跳转的完整依赖链——应用内跳转是「先下载页面 chunk 再由页面动态 import 游戏 chunk」的串行两跳,悬停期间两跳并行完成,点击后理论上零网络等待;即使预取未命中(快速点击、触屏无 hover),正式路径上的 defineAsyncComponent(GameDetailPage.vue 44-57 行,delay 100ms 防 spinner 闪烁)也只是多一个 RTT。重复调用零开销:gameModules[key] 的 loader 函数被浏览器与模块缓存共同去重,悬停十次只下载一次。触屏设备没有 hover,预取天然退化为「不预取」,这属于诚实降级——为触屏伪造 hover 预取全列表会浪费流量,未做是合理的。
为什么不更进一步。可选项还有「进入视口即预取全部」(IntersectionObserver 批量预取当页 24 款)与「空闲时预取全站」。前者会让每次翻页产生 24 个并发请求,对弱网用户是负优化;后者预取 79 款数 MB 代码,与「普通玩家只玩两三款」的真实分布不匹配。现状的 hover 预取是「意图信号最强、浪费最小」的均衡点,不建议加码。对 pixi 三款还有一层隐性保护:pixi-vendor 488K 只在真正进入 pixi 游戏时才与游戏 chunk 一起下载,manualChunks 隔离避免了「玩贪吃蛇也要先下 488K」的灾难。
《07-模块详解》第 3 节已走读实现,这里归纳它的四个性能决策与量化依据。其一,编码出线程:主线程同步 toDataURL('image/webp') 在软件渲染环境每张 45-65ms,首屏 24 张即约 1.5 秒的主线程长任务——这正是引入 coverEncode.worker.ts 的动机(源码注释原文记载「~1.7s 主线程长任务(页面切换卡顿元凶)」);worker 化后主线程只承担绘制(每张约 2ms)与 createImageBitmap 转移,编码在 worker 的 OffscreenCanvas 上完成,Blob 传回后 URL.createObjectURL 收尾。其二,模块级缓存 + 在途去重:coverCache(id → objectURL)保证同 id 全局只画一次,跨组件实例、跨「大厅↔运行页」往返生效;pendingRenders 处理「网格卡与精选卡同时挂载同一款」的并发场景,两份回调共享一次渲染。缓存命中的读取路径是同步 Map 查询,二次进入大厅零绘制。其三,确定性生成让缓存可重建:封面由 hash(id) 决定图案(gameCoverRender.ts 85-92 行),无随机数参与,字体翻转导致的整缓存清空重建(68-83 行)也只会产出相同像素,用户无感。其四,降级有界:worker 不可用走同步兜底(settleSync),最坏情况回到 45-65ms/张但功能完好;字体源不可达(无注册字体面)时 check() 恒 true、interReady() 状态永不翻转,故永不重建,避免无限清缓存的抖动(旧实现的教训,注释有记)。
封面是 Blob URL 而非网络图片,没有图片加载延迟与带宽成本;代价是 objectURL 从不主动 revoke,长会话中最多累积 79 个(每款一个),每个指向数十 KB 的 webp,总量在 MB 级以内——属可接受的取舍,优化建议见第 5 节 P2。
79 款游戏共享的运行时性能模型在《04-数据模型》第 3 节总结为「世界与镜像分离」,本节补全性能细节。响应式预算:高频状态(蛇身坐标、水果数组、子弹数组、棋盘二维数组)放普通 let,每帧原地修改不经 Proxy;只有 HUD 需要的低频标量(score/lives/status)用 ref。以 suika-game.vue 为例,fruits 数组每帧经历积分、三次迭代碰撞解算、filter 回收(170-284 行),若是 reactive 数组,每次 push/filter 都触发依赖收集与派发——非响应式选择让物理循环保持纯 JS 成本。镜像侧 ctxRef 用 shallowRef(useCanvasStage.ts 30 行)避免对重型宿主对象做深度代理。时间标定:所有帧循环做 dt clamp——snake 的 100ms、plane-shooter 的 100ms(377 行 Math.min(time - lastTime, 100))、suika 的 50ms(289 行)——防止标签页切回后 dt 巨大导致的一帧穿模/追帧风暴;tetris 用累加器把渲染帧率与逻辑节拍解耦(515-535 行)。后台治理:useCanvasStage/useAnimationLoop 的 pauseWhenHidden 选项在 document.hidden 时跳过 handler 但保持 rAF(useCanvasStage.ts 89-94 行),后台标签页的 CPU 归零;tetris 与 plane-shooter 都开启了该选项。音频调度:BGM 的 lookahead 调度(90ms interval + 0.4s 窗口,useGameBgm.ts 197-207 行)把节拍精度交给音频线程,主线程定时器抖动不影响播放,且主线程成本是每 90ms 一次纯计算。事件清理:cleanupTasks 出栈式清理(见《07-模块详解》第 5 节)防止的是「慢性性能病」——泄漏的 window 监听器会让每次按键触发已卸载游戏的 handler,长会话逐渐卡顿;基建把这类问题从根源消除,但如《07-模块详解》第 5 节所证,真正使用基建的游戏只有少数,多数游戏自管清理(质量参差,建议见 P1)。
一个诚实的回答先放在前面:本项目没有虚拟列表——package.json 无任何虚拟滚动依赖,GamesPage.vue 的网格渲染的是分页切片。但它用两层手段达成了虚拟列表的效果。第一层是分页切片:筛选管道产出全量 79 条后,games computed 只 slice((page-1)*24, +24)(155-158 行),DOM 中同时存在的卡片上限恒为 24(外加至多 4 张精选卡),每张卡是一个 <a> + 一张 GameCover img,节点总数千级以内——79 条的规模下,这是比虚拟滚动更简单、更稳的方案(无滚动容器测量、无滚动锚点跳动)。第二层是计算管道的节流:关键词过滤消费 300ms 防抖后的 debouncedFilters(useListFilter.ts 67-73 行),搜索框逐键只更新受控输入的 filters,不触发 79 条的过滤与排序;history.replaceState 的 URL 回写同样挂在防抖之后,且新旧 query 字符串相同则跳过(92-95 行)。排序只在 latest 与 random 两种间切换,Fisher-Yates 为 O(n),79 条微不足道。渲染侧的 CSS 手段:游戏舞台的最大化用容器查询单位(GameShell.vue 335-337 行)纯 CSS 完成,避免 JS 布局循环;卡片动效只动 transform/box-shadow(合成器友好路径),且在 prefers-reduced-motion: reduce 下整体关闭(GameCard.vue 样式区);启动屏(index.html 内联,0 依赖立即渲染)与字体 media="print" 懒加载让首屏感知时间不受 JS 下载与字体下载阻塞——这些感知层手段与真实性能同等重要。router.scrollBehavior 用 behavior: 'instant' 恢复滚动(router/index.ts 42-44 行),理由注释写明:smooth 恢复依赖帧产出,在按需出帧环境会迟迟不到位——又是一处「为真实环境放弃花哨」的选择。
| 优先级 | 建议 | 依据与预期收益 |
|---|---|---|
| P1 | 封面渲染错峰:renderCoverAsync 目前由每张卡挂载即时触发,首屏 24 张集中在同一帧排队绘制(各 2ms);可加 requestIdleCallback(或按 data-card-index 交错)分摊 | 首屏慢设备的偶发掉帧;低风险小改动 |
| P1 | 统一循环基建渗透率:仍有大量游戏自管 rAF 且未开 pauseWhenHidden(grep 显示仅 4 款 import useCanvasStage/useCanvas);逐款迁移到 useCanvasStage({ pauseWhenHidden: true }) 或至少补 visibilitychange 处理 | 后台标签页功耗与 CPU;顺手消除自管清理的参差(见《07-模块详解》第 5 节) |
| P2 | objectURL 生命周期:coverCache 中的 URL 从不 revoke;可在 cacheVersion 重建时对旧 URL 批量 revoke,页面 unload 时浏览器兜底 | 长会话内存占用 MB 级以内,属卫生问题 |
| P2 | 预取节流:prefetchGame 无去重窗口,快速划过卡片网格会连发多个 import();可加「同帧去重 + 每秒上限」 | 弱网与移动端流量;现状有模块缓存兜底,收益中等 |
| P2 | 卡片 content-visibility: auto:给 .game-card__preview 加 content-visibility 与 contain-intrinsic-size,跳过视口外卡片的渲染成本 | 页面级渲染管线减压;需真机验证与滚动条行为回归 |
| P3 | 磁盘产物:chunkSizeWarningLimit: 600 下最大游戏 chunk 20K、pixi-vendor 488K,均远低于阈值;无需进一步拆分 | 保持现状即可,拆出「共享工具 chunk」反而增加请求数 |
| P3 | 资源提示:Vite 已为入口注入 modulepreload;如需进一步压首屏,可为 GamesPage chunk 加 <link rel="modulepreload">——实测入口到大厅仅一次 RTT,收益甚微 | 可做可不做 |
不建议做的事同样值得记录:不要为 79 条元数据引入虚拟列表或分库(当前管道全计算耗时微秒级);不要把游戏循环的 let 世界改成 reactive 以追求「更 Vue」(那是纯负优化);不要给 hover 预取升级为全站预取(流量负优化);不要为封装美感把 GameShell 的工具类改为按需注入的 CSS-in-JS(运行时成本换不来收益)。总体而言,这个项目的性能设计是「架构层一次性做对、游戏层各自为战」——架构层(分割、缓存、线程外编码、节流)已经接近该规模的合理性上限,剩余优化空间集中在游戏基建渗透率与少量锦上添花。