Skip to content

第 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,说穿了就是生命周期没管好。该初始化的没初始化,该清理的没清理。"


第十三、十四颗石子

第十三颗和第十四颗黄石子,一起落进了"已处理"的堆里。

两颗小石子。 两件小事。 一个忘了存,一个忘了绑。

都是那种"说出来谁都懂,但就是容易忘"的问题。

"写代码啊,"外交官叹了口气,"难的不是复杂的算法,是细心。该存的存、该绑的绑、该清的清——说起来简单,做到位难。"

他看着那堆黄石子,数了数:

  1. 空白的一秒
  2. 证件照
  3. 织布机的垃圾
  4. 快递员与护盾
  5. 调光器
  6. 夕阳的影子
  7. 沉默的函数
  8. 不存在的 bug
  9. 摆书人
  10. 电梯记忆
  11. 调参数的手感
  12. 没装子弹的枪
  13. 性能模式失忆
  14. 消失的监听器

十四颗了。 中优项十五颗,就剩最后一颗了。


附录:前端功能自检清单

每次加完一个功能,过一遍这个清单:

  • [ ] UI 做好了吗?
  • [ ] 运行时逻辑对吗?
  • [ ] 状态持久化了吗?(刷新还在吗?)
  • [ ] 错误处理了吗?(失败了怎么办?)
  • [ ] 生命周期对吗?(创建/更新/销毁各做什么?)
  • [ ] 事件监听清理了吗?(销毁时 removeEventListener)
  • [ ] 定时器清理了吗?(销毁时 clearInterval/clearTimeout)
  • [ ] 异步回调守卫了吗?(组件销毁了还会执行吗?)

教训:很多前端 bug,说穿了就是生命周期没管好。该存的没存、该绑的没绑、该清的没清。说起来都是低级错误,但就是有人不断地犯。因为这些东西不在"核心功能"里,做完核心功能你就觉得做完了。但一个成熟的软件,就是把这些"不是核心"的小事都做好。