Skip to content

不存在的 bug

背景:审计怀疑 beforeunload 持久化遗漏,但需要先确认是否是虚惊。

过程:验证 scene-serialize.ts 双保险 + 全字段覆盖。


审计清单里有一项:

中优 #9: beforeunload 状态持久化可能不完整——验证场景自动保存是否覆盖 envState、材质启用状态等新增字段。

"听起来像个 bug,"AI 同行者说,"自动保存可能漏了东西。"

"先别急着改,"外交官说,"审计清单是审计的时候列的。审计的时候可能没仔细看实现。先查。"

"查什么?"

"查 scene-serialize.ts,"外交官说,"看看 serializeScene 都存了什么,beforeunload 触发的自动保存是不是完整。"


侦探工作

外交官打开 scene-serialize.ts,像个侦探一样开始翻找。

先找自动保存的触发点:

typescript
// 自动保存触发点
document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
        saveAutosave();
    }
});

window.addEventListener('beforeunload', () => {
    saveAutosave();
});

"两个触发点,"外交官点点头,"visibilitychange 和 beforeunload。双保险。"

"为什么要两个?"

"beforeunload 有时候不可靠,"外交官解释,"移动端切后台的时候,beforeunload 不一定触发——系统可能直接把进程杀了。visibilitychange 更靠谱——页面一隐藏就存。两个都有,双保险。"

"不错啊。"

"不错,"外交官继续往下翻,"再看 serializeScene 存了什么。"


存了什么

serializeScene 的返回类型:

typescript
export interface SceneState {
    version: number;
    timestamp: number;
    models: ModelState[];
    envState?: EnvState;
    lightingState?: LightingState;
    cameraState?: CameraState;
    playbackState?: PlaybackState;
}

"envState 有了,lightingState 有了,cameraState 有了,playbackState 有了。"外交官一个个数。

再看 ModelState:

typescript
interface ModelState {
    id: string;
    path: string;
    position: [number, number, number];
    rotation: [number, number, number];
    scale: number;
    visible: boolean;
    materialState?: MaterialState;
    vmdPaths?: string[];
    // ...
}

"materialState 也有了。那材质启用状态呢?"AI 问。

"材质启用状态在 materialState 里面——我们前几天刚加的 materialEnabled,"外交官翻了翻 scene-material.ts,"getMatState 返回的对象里有 enabled 字段,serialize 的时候会一起存进去。"

"那 envState 全吗?比如天空参数、地面参数、粒子参数……"

"全,"外交官打开 env-lighting.ts 里的 EnvState 定义,"天空、地面、雾、云、水、粒子、风——都有。"

他合上文件:

"审计清单里的担心是多余的。该存的都存了。"


为什么审计会提

"那审计的时候为什么会把它列进清单?"AI 问。

"因为不确定,"外交官说,"审计的时候,不可能把每一个函数的实现都仔细看一遍。有些项是'看起来可能有问题,需要验证'——不是确认的 bug,是待验证的疑点。"

"就像体检报告上的'建议进一步检查'。"

"对,就是这个意思,"外交官笑了,"审计清单里的条目,不一定都是真的 bug。有的是真问题,有的是疑似问题,有的是'最好确认一下'。"

"那确认了没问题怎么办?"

"勾掉,"外交官说,"在 checklist 里打个勾,写一句'已验证,无需修改'。然后该干嘛干嘛。"

"不用改代码?"

"不用改,"外交官确认,"发现没有问题,也是一种成果。至少排除了一个疑点,心里踏实了。"


双保险的设计

"不过既然来了,"外交官说,"我们顺便看看这个双保险的设计好不好。"

"哪双保险?"

"visibilitychange + beforeunload,"外交官指着那两段代码,"为什么两个都要?一个不够吗?"

"不够,"AI 想了想,"beforeunload 在移动端不可靠,visibilitychange 在桌面端切窗口也会触发——但用户可能只是切出去看个微信,马上就切回来。这时候也存吗?"

"也存,"外交官说,"存一下又没坏处。自动保存本来就是'多存几次总没错'的事。存多了不会出问题,存少了才会出问题。"

"那会不会存得太频繁了?"

"不会,"外交官摇头,"serializeScene 很快——就是把几个状态对象拼起来,JSON 序列化一下。几毫秒的事。而且 localStorage 的写入也很快。哪怕用户切出去十次,存十次,也没什么影响。"

"宁滥勿缺。"

"宁滥勿缺,"外交官点头,"自动保存的第一原则就是——多存无害,少存要命。用户最崩溃的时刻,就是做了半小时的东西,软件崩了,打开发现只存了 10 分钟前的。"


版本号的意义

"还有一个细节,"外交官指着 SceneState 里的 version 字段,"你看这个。"

"版本号?"

"版本号,"外交官说,"每次序列化格式变了,版本号就加一。反序列化的时候,先读版本号,根据版本号走不同的解析逻辑。"

"有什么用?"

"向前兼容,"外交官解释,"假设现在版本是 3。用户用新版本存了一个场景,然后降级回旧版本——旧版本读到版本号 3,发现自己不认识,就可以提示'此场景由新版本创建,无法加载'。而不是默默地加载出错。"

"那新版本读旧版本呢?"

"新版本认识所有旧版本,"外交官说,"版本 1 有哪些字段,版本 2 加了哪些,版本 3 又加了哪些——新版本都知道。读的时候,缺的字段用默认值补上。"

"向后兼容。"

"对,向后兼容,"外交官说,"版本号是序列化系统的安全带。没有版本号的序列化,就是裸奔——格式一变,旧数据全废。"


虚惊一场

第八颗黄石子,外交官在旁边画了个圈,打了个勾。

"这颗不用改?"AI 问。

"不用改,"外交官说,"但也不是白查。至少我们确认了——自动保存是靠谱的。双保险、全字段、有版本号。设计得不错。"

"那也算完成了?"

"算,"外交官点头,"审计的目的不是为了找出 48 个 bug,是为了让系统更可靠。确认没有问题,和修复问题一样重要。"

他把那颗石子翻了个面——正面写着"待验证",反面写着"已验证,无需修改"。

虚惊一场。 但虚惊一场,是世界上最好的结局之一。


附录:自动保存系统设计检查清单

检查项说明本项目状态
触发时机beforeunload + visibilitychange 双保险✅ 已有
保存频率不能太频繁(IO 压力),也不能太稀疏(丢数据)✅ 事件触发,非定时
数据完整性所有场景状态都要存,不能漏✅ 已验证完整
版本号序列化格式要有版本号,兼容旧数据✅ 已有
错误处理保存失败(如 localStorage 满了)要提示用户⚠️ 待验证
多槽位保留最近 N 个自动保存,避免最新的就是坏的⚠️ 待验证

教训:审计清单里的条目不一定都是真 bug。有的是"疑似问题,需要验证"。验证了没问题,也是成果。至少排除了一个疑点,心里踏实了。而且验证的过程中,你还能顺便学习一下现有代码的设计——比如双保险、版本号,这些都是好东西。