项目:html5-games | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
既有篇章对关键模块多为「机制概括 + 行号指引」,本篇换一种读法:挑三个最能代表项目精神的文件,逐段把真实代码贴出来,边读边解。三个文件的选取逻辑是「一个最短、一个最长、一个最巧」:src/games/registry.ts(20 行,最短,全书架构的支点)、src/components/games/GameShell.vue(729 行含样式,最长,79 款游戏的页面契约)、src/components/games/gameCoverRender.ts(394 行,最巧,性能与正确性的平衡样本)。走读的每个大段之后给一段「这段代码教会我们什么」的小结——它不是复述,而是把代码里可迁移的写法提炼成一句带适用边界的话。本文代码块使用四反引号围栏;文中提及的行号均对应当前源码。机制全景与设计决策背景不再复述,分别见《07-模块详解》与《16-决策记录ADR》。
全文只有两个导出,逐行贴完(文件路径 src/games/registry.ts,全文 1-20 行,仅省略中段注释):
import type { Component } from 'vue'
export const gameModules = import.meta.glob('@/games/*.vue') as Record<
string,
() => Promise<{ default: Component }>
>
export function prefetchGame(id: string): void {
void gameModules[`/src/games/${id}.vue`]?.().catch(() => {
/* 预取失败静默忽略,正式打开时由 GameDetailPage 的错误兜底处理 */
})
void import('@/pages/GameDetailPage.vue').catch(() => {})
}第一段:glob 与类型断言(第 3-6 行)。 import.meta.glob('@/games/*.vue') 是整个项目的地基:Vite 在构建期扫描这个模式,为 src/games/ 下每个 .vue 生成独立 chunk,并把「路径 → 动态 import 函数」的映射表内联进产物。两个细节值得放大。第一,泛型断言 as Record<string, () => Promise<{ default: Component }>> 是必要的:glob 的返回类型对「模块默认导出是什么」一无所知,断言把「SFC 默认导出是一个组件」这个领域知识写进类型层,下游 defineAsyncComponent 与 component :is 因此不需要再判空转型。第二,glob 写的是别名路径 @/games/*.vue,而消费方(GameDetailPage 第 52 行)查表用的是 /src/games/${gameId}.vue——import.meta.glob 的产物 key 统一是 /src 开头的绝对路径,别名只影响书写不影响 key。这两处字符串协议没有任何机制保证一致,全靠测试:src/config/__tests__/games.test.ts 用第二个 glob(/src/games/*.vue)做双向比对,配置与文件的漂移在 CI 就会红。
第二段:prefetchGame 的三重克制(第 8-20 行)。 这个 12 行的函数有三处刻意为之的写法。其一,?.() 可选链调用——查表 key 不存在时整个表达式静默为 undefined,不抛错:预取入口被 GameCard.vue 第 60-61 行挂在 @mouseenter 与 @focusin 上,输入源是「用户鼠标扫过卡片」这种高频、无保证的事件,任何异常抛出都会污染控制台甚至冒泡到全局,所以入口本身必须是钝感的。其二,.catch(() => {}) 吞掉加载失败:预取是纯优化,失败的正确处理不是重试而是「什么都不做」——正式打开路径上有 GameDetailPage 的完整错误兜底(loadError 状态 + ErrorFallback 重试,见《01-系统架构》2.2-2.3),在优化路径上做错误恢复只会重复建设。其三,void 前缀显式丢弃两个 Promise:这是 fire-and-forget 语义的类型层声明,配合 ESLint 的 @typescript-eslint/no-floating-promises 类规则(该规则在本项目未启用,但 void 前缀让任何未来启用它的人无需改代码),同时向读者声明「作者知道这里有未等待的 Promise,且这是有意的」。
第三段:为什么详情页 chunk 也预取(第 19 行)。 void import('@/pages/GameDetailPage.vue') 单独一行,注释只有一句「详情页骨架 chunk 一并预取」。背景是时序账:点击卡片 → router.push → 先下载 GameDetailPage chunk(路由组件)→ 详情页 setup 执行 → 才发起游戏 chunk 下载。两次串行网络往返,第一次却不由 hover 预取覆盖(如果只预取游戏 chunk)。把详情页也纳入预取,hover 的 300-500ms 里两条下载并行完成,点击即达——《10-性能设计与优化》第 1 节把这总结为「把点击到可玩压进一个 RTT」。这一行的存在提醒我们:预取优化的正确粒度是「用户到达目标前的整条依赖链」,不是「目标本身」。
这段代码教会我们什么:注册表可以薄到只有一句 glob,前提是把一致性交给构建期与测试而不是运行时。运行时没有目录扫描、没有清单拉取、没有字符串拼接 URL,所有不确定性在 Vite 构建期与 games.test.ts 的断言里闭环。同时它示范了「优化路径钝感、正式路径兜底」的分工——同一件事(加载游戏 chunk)有两条路径时,让便宜的那条允许失败,让贵的哪条负责报告失败。
GameShell 全文 729 行,其中样式约 530 行。走读取三段:script 的状态与手势联动、template 的插槽落位、style 的容器查询布局。
第一段:props 契约与音频手势(第 20-73 行节选)。
const props = withDefaults(
defineProps<{
title: string
/** 舞台宽高比(宽 / 高),默认 1(正方形) */
ratio?: number
instructions?: string
showRestart?: boolean
showPause?: boolean
paused?: boolean
/** 强调色(CSS 颜色),默认取主色 */
accent?: string
showInfo?: boolean
}>(),
{ ratio: 1, instructions: '', showRestart: true, showPause: false, paused: false, accent: '', showInfo: true }
)
const emit = defineEmits<{ restart: []; togglePause: [] }>()
const { muted, toggleMute, unlock } = useGameAudio()
const bgm = useGameBgm()
/** 背景音:首次用户手势后启动(规避自动播放策略),卸载即停;静音/暂停时哑音或压低。 */
function onFirstGesture(): void {
unlock()
bgm.start(props.title)
bgm.applyConditions(muted.value, props.paused)
window.removeEventListener('pointerdown', onFirstGesture)
window.removeEventListener('keydown', onFirstGesture)
}
onMounted(() => {
window.addEventListener('pointerdown', onFirstGesture)
window.addEventListener('keydown', onFirstGesture)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onFirstGesture)
window.removeEventListener('keydown', onFirstGesture)
bgm.stop()
})
watch([muted, () => props.paused], ([m, p]) => bgm.applyConditions(m, p))这一段的第一个看点是 props 的「少而准」:8 个 props 里只有 title 必填,其余全部有默认值且集中在 withDefaults 一处——默认 showRestart: true 意味着「可重开」是机台的出厂设定,不需要每个游戏声明;paused 是受控 prop 而非外壳内部状态,暂停的事实源在游戏手里,外壳只负责把它画成按钮态并同步给 BGM。第二个看点是 onFirstGesture 的自我注销:浏览器自动播放策略要求 AudioContext 在用户手势内创建/resume,而「手势」可能是任意按键或点击,所以外壳监听两个 window 级事件;触发后立即在函数体内自我移除(第 61-62 行),保证 unlock/start 只执行一次。第三个看点是 生命周期配对的完整性:onMounted 注册的两个监听器在 onBeforeUnmount 成对移除,且 bgm.stop() 也在卸载钩子里——离开游戏页时调度器停止、节点渐隐断开(useGameBgm.ts 250-271 行),切游戏不残留 BGM。最后的 watch([muted, () => props.paused], …) 把「全局静音」与「本局暂停」两个条件实时同步给 BGM 的音量仲裁(压低而非静音,见《07-模块详解》第 4 节),这意味着外壳是音频模块与游戏状态之间的唯一接线员,游戏自己不需要碰 BGM。
第二段:模板的插槽落位与「overlay 挂 wrap 层」(第 156-201 行节选)。
<main class="gshell__body">
<div class="gshell__stagewrap">
<div class="gshell__stage">
<slot />
</div>
<!-- 浮层置于舞台之外:舞台按 ratio 缩放后可能很扁(横版手游),
浮层放 wrap 层可避免被 stage 的 overflow:hidden 裁切 -->
<div class="gshell__overlay">
<slot name="overlay" />
</div>
</div>
<aside v-if="hasInfo" class="gshell__info" :class="{ 'is-open': infoOpen }">
<!-- 头行 + 折叠开关 + 内容滚动区,见 07 篇 2 节 -->
<div class="gshell__info-body"><slot name="info" /></div>
</aside>
</main>
<footer v-if="instructions" class="gshell__foot">
<AppIcon icon="ms:keyboard" :size="15" />
<span>{{ instructions }}</span>
</footer>模板的结构语言是「每个插槽有明确的几何归属」:默认插槽在 __stage(有 overflow hidden、圆角、边框、投影——「机台屏幕」的物理隐喻),overlay 在 __stagewrap(stage 的兄弟而非子节点),info 在 __body(舞台的侧栏兄弟),hud 与 actions 在顶栏(模板上部,此处未贴)。注释里那句话是踩坑实录:舞台按 ratio 缩放后对横版游戏可能非常扁,若 overlay 是 stage 的子节点,会同时被 stage 的 overflow hidden 裁切、被压进扁条里无法居中;挂在 wrap 层后浮层始终居中于可视区域。v-if="hasInfo"(hasInfo = props.showInfo && !!slots.info,第 83 行)体现插槽契约的另一半——外壳对「没提供的插槽」也要体面:没有 info 内容时不渲染侧栏容器,instructions 为空串时 footer 整体不出现,游戏不会被迫填占位文案。
第三段:容器查询的舞台最大化(样式 325-346 行节选)。
.gshell__stagewrap {
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
container-type: size;
}
.gshell__stage {
position: relative;
width: min(100cqw, calc(100cqh * var(--gratio, 1)));
height: min(100cqh, calc(100cqw / var(--gratio, 1)));
overflow: hidden;
border-radius: var(--radius-lg);
background: rgb(var(--color-surface-dim-rgb));
border: 1px solid rgb(var(--color-border-glass-rgb) / 0.22);
box-shadow: 0 24px 60px rgb(0 0 0 / 0.35), 0 0 0 1px rgb(var(--color-text-rgb) / 0.02),
0 0 44px rgb(var(--color-primary-rgb) / 0.1);
}这七行 CSS 是「保持宽高比最大化铺满」的完整解法。container-type: size 把 stagewrap 声明为尺寸容器,cqw/cqh 因此指代它的宽高;stage 的宽取「容器宽」与「容器高 × 比例」的较小者,高取其对偶——两条 min 互相约束,结果必然同时满足「不超过容器」与「宽高比恒为 --gratio」。纯 CSS 的收益是没有 JS 布局循环:旋转屏幕、折叠 info 栏、键盘弹出,浏览器布局引擎直接重排,无监听器、无时差、无清理责任。配套地,accentStyle(script 84-87 行)把游戏传入的 accent 与 ratio 注入为 --gaccent/--gratio 两个 CSS 变量,氛围光晕(__bg 的 radial-gradient 用了 color-mix)、按钮 hover、结算按钮底色全部从 --gaccent 取色——一个 prop 同时驱动配色与布局,注入点只有一个。
这段代码教会我们什么:「外壳」组件的价值密度在于把横切关注点从 N 个消费者身上摘走——音频解锁、BGM 生命周期、暂停同步、舞台布局、触控目标尺寸(@media (hover: none) 下按钮 44px)都是游戏作者本该操心的事,GameShell 一次做完,79 款游戏里的对应代码从「必须写对」降级为「根本不存在」。代价也真实:外壳成了不可随意改动的公共契约,任何 props/插槽语义调整都是 79 处消费的联爆——这也是《05-核心流程》第 6 节把 GameShell 列入危险修改区的原因。
走读三段:缓存与并发去重的骨架、主入口的线程分工、字体竞态防护。
第一段:双 Map 缓存骨架(第 20-31 行)。
const W = 640
const H = 400
const coverCache = new Map<string, string>()
/** 同 id 的在途渲染回调(网格卡与精选卡可能同时挂载同一款) */
const pendingRenders = new Map<
string,
{ game: LocalGame; callbacks: ((url: string) => void)[] }
>()
const cacheVersion = ref(0)
export const coverVersion = readonly(cacheVersion)文件头注释有一句自我警告值得先读:「coverCache 是真正的模块级缓存(注意:写在 <script setup> 里的 const 是每实例的,缓存会失效,导致每次挂载都全量重绘)」。这是 Vue 组件模型里的经典陷阱:组件 setup 内的对象随组件实例创建销毁,把缓存放组件里等于没有缓存;放模块顶层,import 它的组件共享同一份实例——与音频模块的 muted 单例(ADR-006)是同一手法。pendingRenders 处理的是另一个并发场景:大厅网格与首页精选横带可能同时挂载同一款游戏,两个 GameCover 实例同时请求渲染,若无去重就会画两遍、编两次码;pending 结构里存的是「回调数组 + 首个发起者的 game 数据」,后来者把自己的 onDone 追加进数组等同一个结果。coverVersion 是缓存失效的广播通道:字体翻转清缓存时递增,已挂载的 GameCover watch 它重绘——「清了缓存」与「通知所有消费者」是两件事,这个 ref 把后者补齐了。
第二段:主入口 renderCoverAsync 的线程分工(第 341-370 行)。
export function renderCoverAsync(game: LocalGame, onDone: (url: string) => void): void {
const id = game.id
const hit = coverCache.get(id)
if (hit) {
onDone(hit)
return
}
const pending = pendingRenders.get(id)
if (pending) {
pending.callbacks.push(onDone)
return
}
pendingRenders.set(id, { game, callbacks: [onDone] })
const canvas = document.createElement('canvas')
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d')
if (!ctx) return
drawCover(ctx, game)
const worker = getEncodeWorker()
if (!worker) {
settleSync(id)
return
}
createImageBitmap(canvas)
.then((bitmap) => worker.postMessage({ id, bitmap }, [bitmap]))
.catch(() => settleSync(id))
}函数体按「缓存命中 → 并发合并 → 绘制 → 线程分工」四步展开,每步的守卫顺序就是正确性顺序:先查缓存(最便宜),再并入 pending(防止重复绘制),然后才真正画。绘制在主线程——drawCover 约 2ms,不值得搬线程;编码在 worker——软件渲染下每张 45-65ms、一次大厅挂载 28 张约 1.7 秒的长任务(文件头注释的实测数据),必须搬走。两线程之间靠 createImageBitmap + postMessage(..., [bitmap]) 的转移语义交接:位图所有权移交 worker,零拷贝。worker 侧(coverEncode.worker.ts 全文 24 行)在 OffscreenCanvas 上 convertToBlob({ type: 'image/webp', quality: 0.9 }) 后把 Blob 发回,主线程 settleRender 落缓存并逐个通知回调。降级链路同样完整:worker 构造抛异常(getEncodeWorker 的 try/catch)或运行中 onerror(把 workerBroken 置真并把在途 id 全部转同步),都汇入 settleSync 的主线程 toDataURL 兜底——慢但正确。唯一的已知边界在 worker 内部:convertToBlob reject 时 worker 只能静默(主线程兜底覆盖不到已提交的 id,注释原文「Worker 内无法重绘字体」),该封面缺席——这是《16-决策记录ADR》ADR-005 后果栏记录的待确认点。
第三段:字体竞态防护(第 61-83 行)。
function interReady(): boolean {
return (
document.fonts.check('700 52px Inter') &&
document.fonts.check('600 19px Inter')
)
}
if (typeof document !== 'undefined' && document.fonts) {
let ready = interReady()
const regen = () => {
const now = interReady()
if (now === ready) return
ready = now
if (now && coverCache.size) {
coverCache.clear()
cacheVersion.value++
}
}
document.fonts.load('700 52px Inter').then(regen).catch(() => {})
document.fonts.load('600 19px Inter').then(regen).catch(() => {})
document.fonts.ready.then(regen).catch(() => {})
document.fonts.addEventListener('loadingdone', regen)
}问题背景:封面上的标题用 Inter 字体绘制,而字体是懒加载的(index.html 用 media="print" 技巧异步加载 Google Fonts)。首帧绘制时字体未就绪,封面标题是回退字体——需要「字体到了之后重画一遍」。粗暴做法是「字体相关事件一触发就清缓存重绘」,但注释记载了旧实现的真实事故:字体源不可达(被墙/离线)时没有任何注册字体面,三重信号(load/ready/loadingdone)照样触发,缓存被反复清空、封面反复重画。新实现的解法是只认「真实可用性的翻转」:regen 先比较 interReady() 前后值,未翻转直接 return;而 document.fonts.check() 对「无注册字体面」恒返回 true(字体面未注册意味着「没有待加载的 Inter」,check 判定可用),所以离线环境下 ready 恒为 true、永不翻转、永不重建。三行守卫同时解决了「漏画」(字体到达后翻转会触发重建)与「疯画」(不可达环境信号再多也不动)两个方向的错误。
这段代码教会我们什么:性能问题的正确解法往往不是「更快」而是「拆开」——绘制与编码拆到两个线程、一次请求拆成「首个发起者 + 等待者」、缓存失效拆成「清缓存」与「通知重绘」两步。而环境适应的要点是把「信号」翻译成「状态」再比较:事件是噪声(会重复、会在异常环境误报),状态(interReady 布尔)才是可比较的事实,只对状态翻转做动作,噪声就被挡在了门外。这套「双 Map + 版本号 + 状态翻转」的组合拳,是单页应用里处理「昂贵派生资源」的可复用模板。