Appearance
第 22 章 · 忘记的设置
背景:性能模式刷新即丢、下载监听重建后失效
过程:持久化性能模式并重建重绑监听
相关代码:[settings.ts](file:///C:/Users/zhujieling11/frontend/src/menus/settings.ts)
设置页是一座奇怪的城市。
用户在里面改了一堆开关、填了一堆路径、选了一堆选项。 然后他关了软件,第二天打开——
一半的设置还在,一半的设置不见了。
"我昨天明明改了的!"用户说。
是啊,你昨天明明改了的。 但软件忘了。
性能模式的失忆
第一个故事,关于性能模式。
"性能模式开关,用户关掉之后,刷新页面又开了,"外交官说,"等于白关。"
"为什么?"AI 同行者问。
"因为没存,"外交官翻开 settings.ts,"开关的状态只存在内存里的一个变量里。页面一刷新,变量就没了,回到默认值——开。"
"那其他设置呢?比如模型库路径、外部软件路径?"
"那些存了,存在 Go 端的配置文件里,"外交官说,"性能模式是前端加的功能,加的时候忘了存。"
"又是'加了功能忘了序列化'?"AI 笑了,"和材质启用状态那个好像。"
"一模一样的问题,"外交官摇头,"功能加了,UI 加了,运行时逻辑加了——唯独持久化忘了加。"
"那怎么办?"
"存 localStorage,"外交官说,"性能模式是纯前端的设置,不涉及后端,存 localStorage 就行。初始化的时候读,改的时候写。"
他写了两行代码:
typescript
// 初始化
let performanceMode = localStorage.getItem('perfMode') === '1';
// 修改时
function setPerformanceMode(enabled: boolean) {
performanceMode = enabled;
localStorage.setItem('perfMode', enabled ? '1' : '0');
}"就这么简单?"
"就这么简单,"外交官说,"但就是有人忘。"
为什么会忘
"为什么持久化这么容易忘?"AI 问。
"因为持久化不在功能的核心路径上,"外交官想了想,"你做一个功能的时候,想的是——用户点开关 → 变量变了 → 行为变了。这三步是核心,是'功能成没成'的判断标准。"
"持久化是第四步——关掉再打开还在。第四步不在'功能完成'的检查清单里。做完前三步,你就觉得'做完了'。"
"所以才会忘。"
"所以才会忘,"外交官点头,"这就是为什么要有 checklist——做任何有状态的功能,检查清单里都要有一项:持久化了吗?"
他在笔记本上写下:
功能完成四件套:UI ✅ 运行时 ✅ 序列化 ✅ 错误处理 ✅ 缺一个,就是半成品。
消失的监听器
第二个故事,关于下载监听。
settings.ts 里有一个功能——用户选了模型库路径之后,后端开始扫描,扫描完成后前端要刷新列表。
"怎么通知前端扫描完成了?"AI 问。
"Go 端有一个事件,叫 libraryScanComplete,"外交官说,"前端监听这个事件,收到了就刷新。"
"那问题是什么?"
"问题是——监听器绑在 dirInput 元素上,"外交官的表情有点微妙,"而 dirInput 元素,在切换设置页面的时候,会被销毁重建。"
"啊……"
"啊,"外交官重复,"第一次进设置页,创建 dirInput,绑定监听器。用户切到别的设置页,dirInput 被删了,监听器也没了。用户再切回来,dirInput 重新创建了——但没人再绑定监听器了。"
"所以扫描完成的事件,就收不到了?"
"收不到了,"外交官说,"而且用户不知道为什么。他只知道——'有时候扫描完不刷新,不知道是不是卡了'。"
"间歇性 bug 最烦人了。"
"最烦人,"外交官同意,"因为复现不了。你第一次进设置页,好好的。你切出去再切回来,坏了。用户不会告诉你他切了页面——他只会说'有时候不好使'。"
怎么修
"怎么修?"
"两种方法,"外交官伸出两根手指:
方法一:监听器不绑在元素上,绑在全局。 "事件监听和 DOM 元素没关系,就不要绑在元素上。绑在 window 上、或者某个全局对象上。元素销毁了,监听器还在。"
方法二:每次渲染元素的时候重新绑定。 "build 函数里创建完 dirInput,紧接着就 addEventListener。每次创建都绑一次,确保不会丢。"
"哪种好?"
"看情况,"外交官说,"如果这个监听器只在设置页有用,那每次渲染的时候绑比较好——页面销毁了监听器也可以移除,省内存。如果这个监听器全局都要用,那就绑全局。"
"那下载监听属于哪种?"
"只在设置页有用,但——"外交官顿了顿,"扫描完成这个事件,模型库弹窗也关心。模型库的刷新按钮也要听这个事件。所以——"
"所以应该绑全局?"
"应该有一个统一的事件中心,"外交官说,"Go 端的事件统一由一个地方管,各个模块自己去订阅。这样就不会出现'某个元素删了,监听器就没了'的问题。"
"但那是架构重构了,不是小修小补。"
"对,所以这次我们用方法二先补上——每次创重新建 dirInput 的时候,把监听器重新绑上。先修 bug,架构优化以后再说。"
两件小事的共同点
"你发现没有,"外交官忽然说,"这两个问题,本质上是同一个问题。"
"同一个问题?"
"同一个问题——生命周期没管好,"外交官说,"性能模式是'创建时没读,销毁时没写'。下载监听是'创建时没绑,销毁时没解'。都是生命周期的问题。"
"一个组件/一个页面/一个功能,它的生命周期是什么样的?
- 创建的时候要做什么?(初始化、读配置、绑事件)
- 更新的时候要做什么?(数据变化了怎么同步)
- 销毁的时候要做什么?(存配置、解事件、清资源)
这三个问题想清楚了,80% 的 bug 都不会出现。"
"生命周期驱动开发。"
"可以这么说,"外交官笑了,"很多前端 bug,说穿了就是生命周期没管好。该初始化的没初始化,该清理的没清理。"
第十三、十四颗石子
第十三颗和第十四颗黄石子,一起落进了"已处理"的堆里。
两颗小石子。 两件小事。 一个忘了存,一个忘了绑。
都是那种"说出来谁都懂,但就是容易忘"的问题。
"写代码啊,"外交官叹了口气,"难的不是复杂的算法,是细心。该存的存、该绑的绑、该清的清——说起来简单,做到位难。"
他看着那堆黄石子,数了数:
- 空白的一秒
- 证件照
- 织布机的垃圾
- 快递员与护盾
- 调光器
- 夕阳的影子
- 沉默的函数
- 不存在的 bug
- 摆书人
- 电梯记忆
- 调参数的手感
- 没装子弹的枪
- 性能模式失忆
- 消失的监听器
十四颗了。 中优项十五颗,就剩最后一颗了。
附录:前端功能自检清单
每次加完一个功能,过一遍这个清单:
- [ ] UI 做好了吗?
- [ ] 运行时逻辑对吗?
- [ ] 状态持久化了吗?(刷新还在吗?)
- [ ] 错误处理了吗?(失败了怎么办?)
- [ ] 生命周期对吗?(创建/更新/销毁各做什么?)
- [ ] 事件监听清理了吗?(销毁时 removeEventListener)
- [ ] 定时器清理了吗?(销毁时 clearInterval/clearTimeout)
- [ ] 异步回调守卫了吗?(组件销毁了还会执行吗?)
教训:很多前端 bug,说穿了就是生命周期没管好。该存的没存、该绑的没绑、该清的没清。说起来都是低级错误,但就是有人不断地犯。因为这些东西不在"核心功能"里,做完核心功能你就觉得做完了。但一个成熟的软件,就是把这些"不是核心"的小事都做好。