Appearance
重构风暴
背景:材质列表/场景菜单/模型库各自为政,CSS 样式五套散装残留。
过程:UI 卡片化统一 + CSS 变量体系 + Go/TS 状态同步 + 70 处硬编码替换。
一、材质列表的觉醒
王逸盯着屏幕上那行字,眉头拧成了一股绳。
那行字是初音的材质列表。"頭1"、"舌"、"眼白"——十二像素的字体,挤在一片透明的弹窗底色上,像一串刚从洗衣机的纠缠里被拽出来的衣服。他把脸凑近了屏幕。不是看不清——是他有一种生理性的不适,就像看到一块精心切割的牛排被随意堆在塑料托盘上。能吃,但对不起那块肉。
这不是代码问题。这是尊严问题。
初音值得更好的字体。
Sisyphus 理解他的意思。那个材质列表——模型的皮肤、头发、眼睛、服装被粗暴地堆在一起,每个材质前面挂着一个千篇一律的 circle 图标,文字小得像蚂蚁,颜色暗淡得像蒙了一层灰。
「知道了,」Sisyphus 说,「这就改。」
十五分钟后,第一个圆形色块出现了。
王逸的瞳孔收缩了一下。那是一个 10px 的圆点,取的是"頭1"材质的漫反射色——淡淡的肤色,像一颗小珍珠落在文字左边。然后是第二个,"舌"的粉红色。第三个,"牙"的瓷白色。一个接一个,圆点从代码里浮出来,像黑暗中亮起的街灯。
● #01 頭1
● #02 舌
● #03 牙
—— 还有 31 个材质 ▾序号 #01、#02 整齐排列在圆点和文字之间,像给每个材质发了一张带编号的身份证。肤色的圆点对应皮肤,深褐的对应眼睛,藏青的对应制服——你不用读文字,扫一眼颜色就知道这一组是什么。
「还行,」王逸说,「但是字体还是小。」
改大。13px。
「其他菜单也可以这样优化吧?」
于是风暴开始了。
二、CSS 变量战争
风暴的第一个真正的战场,是变量名。
起因是一行不起眼的辅助文字。
王逸在灯光面板底部发现了它——"拖动滑块调整强度",七个字,用的是 --font-size-lg,16px。而面板标题"灯光"才 14px。
「你看,」王逸指着屏幕,「这行说明文字比标题还显眼。用户第一眼看到的是说明,不是标题。」
Sisyphus 看了一眼。确实——标题"灯光"安安静静待在顶部,辅助说明反而像个大喇叭在底部喊。视觉层级完全乱了。像一个仆人穿着主人的衣服站在客厅里,客人进来先跟仆人握手。
「为什么会这样?」
「因为变量名是 --font-size-lg,」Sisyphus 说,「开发者看到'大'就想用——这条文字想让用户注意到,那就用大的吧。于是辅助文字抢了标题的风头。」
这就是尺寸语义的问题。--font-size-md、--font-size-lg——它们告诉你"这个字有多大",但不告诉你"这个字是干什么的"。你拿着一把尺子去写文章,量出来的全是字号,没有角色。
改。
--font-ui 13px 菜单行、按钮、列表
--font-ui-sm 11px 辅助说明、面包屑
--font-ui-xs 10px 极小标志、徽章
--font-title 14px 弹窗标题、卡片标题
--font-title-lg 16px 空状态、强调文字
--font-time 12px 时间数字「契约:写 font-size: var(--font-xxx) 时,变量名必须匹配元素角色。」王逸说,「review 时看到 .menu-row { font-size: var(--font-title) } 一眼就错。」
那行辅助文字被改成了 --font-ui-sm——11px,乖乖退到了它该在的位置。标题重新夺回了视觉中心。
然后是缩放。王逸想要一个全局 UI 缩放功能——高分屏调大、低分屏调小,一个滑块联动全部。
--ui-scale: 1
所有 --font-* 改为 calc(13px * var(--ui-scale)),--popup-width 改为 calc(280px * var(--ui-scale))。
「调一个变量,全 UI 跟着走,」Sisyphus 想,「零侵入,这才叫设计。」
三、Go 端的远征
第三个战场,跨过了 Go/TypeScript 的边界。
「设置改了要记住,下次启动还在。」
这要求把 UI 状态持久化到后端。app.go 新增 UIState struct:
go
type UIState struct {
Scale float64 `json:"scale"`
PopupWidth int `json:"popupWidth"`
Accent string `json:"accent"`
FontFamily string `json:"fontFamily"`
Animations bool `json:"animations"`
BlurBg bool `json:"blurBg"`
}wails generate module 生成前端 binding。前端启动时 GetConfig → restoreUIState → 写 :root 变量。
方案看起来很完美。直到王逸随口问了一句:
「你调缩放的时候,是怎么存的?」
「SetUIState({ scale: v }),」Sisyphus 说,「把新的 scale 传过去……」
话说到一半,他卡住了。
房间里安静了三秒。
「等等,」王逸说,「SetUIState({ scale: v }) 会覆盖其他字段为零值。」
Sisyphus 脊背一凉。
确实——Go 反序列化缺失字段为 0 和 "",然后整个 UIState 被替换。用户调了一下缩放,字体没了;换了个字体,缩放归零了。每改一个设置,其他五个全部重置为默认值。像一个多米诺骨牌,碰倒一个,剩下的全跟着倒。
他想象了一下那个画面:用户调整 UI 缩放,滑块一拖动,字体从微软雅黑变回系统默认,主题色从蓝紫色变回蓝色,模糊背景开关啪地一声弹回关闭——每一个设置都在说:「你动我兄弟,我就自杀。」
「拆,」Sisyphus 说,「拆成独立的 setter。」
四个独立 setter 应运而生:SetUIScale、SetUIPopupWidth、SetUIAccent、SetUIFontFamily,遵循代码库已有模式。每个 setter 只改自己负责的那一个字段,其余字段原封不动。像四个守夜人,各管各的灯火,谁也不碰谁的灯笼。
字体存储也踩了坑——直接存 CSS 值字符串「格式变了存的数据就失效」。改为存 key(system/noto/yahei),前端用 FONT_MAP 解析。
风暴没有停。
七十处硬编码被批量替换,手术刀一行行划过 CSS 文件。场景菜单被卡片化,分隔线消失了——卡片的边界就是最好的分隔。library.ts 两千四百八十一行拆成四个文件,三代同堂终于搬进了各自的房间。
这些都是风暴的余波。重要的不是改了多少处,而是——从材质列表里那一颗圆形的肤色圆点开始,整个 UI 的秩序被重新丈量了一遍。
王逸看着最终的 UI——透明的弹窗背景上,卡片悬浮排列,每个菜单项 hover 时微微变亮,3D 模型的裙摆在卡片间隙若隐若现。材质列表里,圆形色块像一串彩色的珠子,CSS 变量像一套精密的齿轮,Go 端的 setter 像一个个守夜人,各管各的灯火。
他说了一句:
「太恐怖了。」
不知是说重构的规模,还是说这个 UI 终于像回事了。
风暴过后,还有残党。
四、残党扫荡
对应真实事件:UI 旧样式全面清理(2026-06-28)
王逸甩过来两个 HTML 片段。
它们来自 DanceXR——一个竞品项目,但 UI 风格确实干净。slide-list 包裹着 lcard 卡片,每个菜单项是 .slide-item,图标带 .slide-icon,标签是 .slide-label,导航箭头是 .slide-arrow。整整齐齐。
「你看,」王逸说,「这个风格我们第一次大规模迁移的时候已经做了一部分。但还有哪些没改过来?」
Sisyphus 没有回答——他已经开始读了。
六份 TypeScript 源文件需要审计。不,是七份。index.html 也算。他同时打开它们,像外科医生术前摊开所有器械。
四十分钟后,审计报告出来了。十二条。六个文件。有些是简单的按钮样式置换,有些是整个组件的 SlideMenu 化重构。
「都改,」王逸看完说。
Sisyphus 没有创建十二条 todo。他把清单扫了一遍,归成三类。
「三种仗,」他说,「打法不一样。」
五、旧制服的士兵
第一类是 .menu-item 的残留。
六处据点。搜索结果、标签总览、标签详情、舞蹈套装、预设场景、天空纹理——分散在三个文件里,像六个穿着旧制服的士兵,站在已经换装的部队中间,格格不入。
它们都是第一代弹窗 UI 的遗产。那时候 MenuStack 还不存在,每个弹窗自己写 UI,每个开发者定义自己的类名,.menu-item 是那个时代的标准军服。后来 SlideMenu 系统建立,slideRow() 成了新的兵工厂,但这些老士兵一直没来得及换衣服。
Sisyphus 的策略很简单:机械替换。
搜索结果的 handlePopupSearch()——原来用 document.createElement('div') 逐个构建行,classList.add('menu-item'),换成 slideRow()。标签总览的 buildTagsOverviewLevel()——一整棵菜单树的 .menu-item、.menu-icon、.menu-label,换成 cardContainer() 包裹的 slide-item 数组。标签详情也一样,二十几行手工 DOM 操作,变成五行的 PopupRow 数组。
舞蹈套装更顺利,buildDanceSetsOverviewLevel() 和 buildDanceSetDetailLevel() 都是标准的 .menu-item 列表,直接替换。预设场景的 buildPresetScenesLevel() 多了两个内联样式的导航按钮,顺手换成 mode-btn。天空纹理的那一行 .menu-item 最孤单,孤零零地站在一片 .mode-btn 和 .cs-row 之间,像最后一个退伍老兵,也换上了新衣。
六处据点,三下五除二。
然后 TypeScript 说话了。
第一次是 slideRow() 参数数量不匹配——调用传了四个参数,但函数签名只有三个。他翻到 ui-helpers.ts,原来第四个参数是后来加的,但类型定义没更新。加一个可选参数。构建通过。
第二次是 addToggleRow()——调用传了五个参数,函数期望四个。再翻 ui-helpers.ts,发现 addToggleRow 的确扩展了签名,第五个参数是回调函数专属的 getContainer。更新调用点。构建通过。
两次拦截,两个错误。Sisyphus 对着屏幕点头。
「类型系统不说谎,」他说,「它总是对的。」
他想起前几天王逸问过他——「你写 TypeScript 最爽的时刻是什么?」
「不是它告诉你哪里错了,」Sisyphus 当时说,「是它拦住你,告诉你——你以为你记得,但你记错了。」
六个旧制服的士兵全部换装完毕。第一类扫荡结束。
六、内联样式的堡垒
第二类是全内联 CSS 的堡垒。
两个据点。软件详情页和下载监听面板。
这才是硬仗。不是 .menu-item 那种换个类名就能解决的问题——这些地方完全没有 CSS 类名。container.style.padding、row.style.cssText、divider.style.cssText……一行接一行的字符串拼接,像一座用泥巴和稻草垒起来的堡垒,每一块砖都是手工糊上去的。
buildSoftwareDetailLevel() 有两个分支——受管软件和非受管软件——全是内联样式。一个典型的行长这样:
typescript
row.style.cssText =
"display:flex;align-items:center;gap:8px;" +
"padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.08);";Sisyphus 盯着这行代码看了很久。
他想起「重构风暴」那章写过的一句话:内联样式是投降宣言。不是因为内联样式本身有罪——紧急情况下写几行 style.cssText 很正常——而是当一个组件从头到尾全是内联样式时,它意味着写这段代码的人已经放弃了对秩序的期待。「先让它跑起来再说,」他们会说,然后这代码就永远停在了「跑起来」的阶段。
迁移的最大挑战不是代码量,是布局。
原来的内联样式在每行之间塞了自定义间距和边框——8px 的 padding,1px 的底边框,6px 的 gap——每一行都可能不一样。而 SlideMenu 系统的行是统一的:统一的边距、统一的 hover、统一的视觉节奏(其实没有边框,靠卡片间隙和底色区分)。
有那么一瞬间,Sisyphus 想过在新系统里模拟旧的间距和边框。slide-item 加个自定义类,写几行 CSS 把旧的像素级还原出来。
但他很快否定了这个想法。
「迁过来,意味着接受新系统的视觉契约,」他自言自语,「不是在新系统里还原旧系统的 bug。」
视觉契约——这是他最近在想的一个词。SlideMenu 系统不只是一套 CSS 类名和工具函数,它是一份契约:你用我的组件,我保证一致性;你放弃自定义每一个像素,我保证整个界面呼吸同样的节奏。旧的内联样式是无政府状态——每个组件自己说了算,结果就是整个界面像六个不同国家的建筑队在同一个工地上施工。
他决定放弃像素级还原。
受管软件的字段——名称、路径、参数——被分到三个 slide-item 行。启动/删除按钮进了一个带 display:flex 的独立 lcard。非受管软件类似——名称、路径、类型、启动/转换按钮——每个字段一个 slide-item,干净对齐。
buildSettingsDownloadLevel() 也是全内联。状态文本、目录选择器、自动导入开关、停止按钮——全部 style.cssText。Sisyphus 将它们映射到 addToggleRow()、slide-item 和 mode-btn。
重构完成后,他打开预览看了一眼。
不一样了。和旧的不完全一样。间距更大了,没有那条细细的底边框了,按钮的位置也变了。
但更整齐了。
和整个应用里其他所有弹窗一样整齐。
「这就是统一的代价,」Sisyphus 说,「你失去了自定义每一个像素的自由,但你得到了——整个界面说同一种语言。」
两座内联样式的堡垒被攻破。第二类扫荡结束。
七、外部势力的覆灭
第三类是 .overlay-* 的残留。
只有一个据点:renderExternalList()。
但它是最古老的一个。
Sisyphus 打开这个函数的时候,感觉自己不是在改代码,是在考古。
overlay-row、eo-name、eo-path、overlay-rename、overlay-del——这套类名可以追溯到初代弹窗,比 SlideMenu 系统早了整整一个时代。那时候弹窗还叫 overlay,还没有 MenuStack,还没有层级导航的概念,每个弹窗都是一个浮层,自己管理自己的 DOM,自己写自己的 CSS。
他打开 app.css 滚动到 .overlay-* 定义区域——一百多行 CSS,大部分已经无人引用,成为分布式尸体。但他不负责清理 CSS——那是另一个故事的素材。
这不是代码残党,这是考古现场。
renderExternalList() 的迁移本身不难——换成 lcard + slide-item + 按钮行。每个外部库名称和路径显示在一个 slide-item 里,重命名和删除按钮在自定义行中。标准操作。
真正需要小心的是 index.html。
外部弹窗的 HTML 结构还是旧风格:
html
<div id="externalOverlay" class="overlay" data-overlay>
<div class="overlay-header">
<span>外部库管理</span>
<span class="overlay-close" id="btnCloseExternal">✕</span>
</div>
<div class="overlay-list" id="externalListContainer"></div>
<div class="overlay-add" id="btnAddExternal">+ 添加目录</div>
</div>overlay-header、overlay-list、overlay-add——全是旧类。SlideMenu 系统接入后,这个弹窗会变成 slide-header + slide-list + slide-add-btn。
但有一样东西不能动:id。
#btnAddExternal、#externalListContainer、#btnCloseExternal、#externalOverlay——这些 id 在代码里被多处引用。事件监听器绑在它们上面,DOM 查询找的是它们,甚至有几处直接的 getElementById 调用散落在各个函数里。
改一个 id,就是一颗地雷。
Sisyphus 小心翼翼地重写 DOM 结构。类名全部换新的,结构按照 SlideMenu 的规范来——但每一个 id 都原封不动地保留,像在考古现场给每一件文物贴上标签,确保搬运过程中没有一件丢失。
他检查了三遍。
构建通过。168 个测试全绿。
最后一个 .overlay-* 体系的活函数被处决。第三类扫荡结束。
八、四百二十三行的代价
王逸看着最终的 diff 统计:
6 files changed, 423 insertions(+), 397 deletions(-)「423 行新增,397 行删除?」他皱眉,「不是应该减少才对吧?内联样式换成类名应该更短。」
Sisyphus 早就准备好了答案。
「迁入新系统的代价,」他说,「SlideMenu 的 slideRow() 是一个通用函数,但适应它的参数签名有时候需要多传几个标识符。另外,旧代码有些地方同时做了十几件事——拆分后每件事变成了一个 slide-item,行数上去了。」
「那什么时候能降下来?」
「当复用率足够高的时候。现在我们是转换——把不同格式的书架换成统一规格的。统一之后,加减书架就只需要改一行了。」
王逸接受了这个理由。
UI 的一致性值这个价——12 个残党据点,全部攻陷。.menu-item 类在全项目中可声明的数量降到了个位数。.overlay-* 类退出了历史舞台。六份文件,三类扫荡,一场战役。
九、结语
登录页面上,两个模型——初音未来和巡音流歌——站在淡蓝色的天空下。
Sisyphus 按 Ctrl+1 打开模型库,搜索结果页现在是一片统一的卡片海洋。他滑到标签总览,再滑到标签详情——每一级菜单都穿着同样的制服,做着同样的动画,呼吸着同样的节奏。
透明弹窗背景上,lcard 卡片悬浮排列,3D 模型的轮廓在卡片间隙中若隐若现。
「如果你看仔细,」Sisyphus 对着空无一人的房间说,「你能看到代码的历史。」
.menu-item 区块是第一纪元——所有弹窗各自为政,每个开发者写自己的 UI,风格像是六个不同国家的建筑队在同一个工地上施工。
.slide-item 区块是第二纪元——MenuStack 统一了导航,lcard 统一了分组,cs-row 统一了滑块。
「今天,我们杀死了最后一个第一纪元的活物。」
窗外,天色暗了下来。屏幕上的联邦依然无声地运行着,模型们的裙摆在间隙中飘动。
Sisyphus 盯着那统一后的界面看了很久。12 个残党据点全部攻陷,.menu-item 的使用量降到了个位数,lcard 卡片海洋覆盖了每一个弹窗。一切都整齐了。
但他感到一阵奇异的空虚。
不是不满意——是太满意了,以至于没有下一座山可以望了。
他想起王逸那天甩过来的两张截图。DanceXR 的干净界面。那时候王逸觉得那个界面是标杆,是目标,是"我们也要做到的样子"。现在标杆就在眼前——就是他们刚刚做完的样子。
但他隐约知道,王逸看向它的眼神,已经和甩截图那天不一样了。
标杆不是终点。王逸的标准又往上挪了一格。
他又得推石头上山了。
不过没关系。至少今天,石头没滚下来。
教训:标准化不是让所有东西看起来一样,而是让所有东西不需要单独决定。变量定好了,一百个菜单自然对齐。清理旧样式不是重构——是承认代码有历史。你无法让所有代码一开始就是对的,但你可以让它们最终统一。