Skip to content

重构风暴

背景:材质列表/场景菜单/模型库各自为政,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。前端启动时 GetConfigrestoreUIState → 写 :root 变量。

方案看起来很完美。直到王逸随口问了一句:

「你调缩放的时候,是怎么存的?」

SetUIState({ scale: v }),」Sisyphus 说,「把新的 scale 传过去……」

话说到一半,他卡住了。

房间里安静了三秒。

「等等,」王逸说,「SetUIState({ scale: v }) 会覆盖其他字段为零值。」

Sisyphus 脊背一凉。

确实——Go 反序列化缺失字段为 0 和 "",然后整个 UIState 被替换。用户调了一下缩放,字体没了;换了个字体,缩放归零了。每改一个设置,其他五个全部重置为默认值。像一个多米诺骨牌,碰倒一个,剩下的全跟着倒。

他想象了一下那个画面:用户调整 UI 缩放,滑块一拖动,字体从微软雅黑变回系统默认,主题色从蓝紫色变回蓝色,模糊背景开关啪地一声弹回关闭——每一个设置都在说:「你动我兄弟,我就自杀。」

「拆,」Sisyphus 说,「拆成独立的 setter。」

四个独立 setter 应运而生:SetUIScaleSetUIPopupWidthSetUIAccentSetUIFontFamily,遵循代码库已有模式。每个 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.paddingrow.style.cssTextdivider.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-itemmode-btn

重构完成后,他打开预览看了一眼。

不一样了。和旧的不完全一样。间距更大了,没有那条细细的底边框了,按钮的位置也变了。

但更整齐了。

和整个应用里其他所有弹窗一样整齐。

「这就是统一的代价,」Sisyphus 说,「你失去了自定义每一个像素的自由,但你得到了——整个界面说同一种语言。」

两座内联样式的堡垒被攻破。第二类扫荡结束。


七、外部势力的覆灭

第三类是 .overlay-* 的残留。

只有一个据点:renderExternalList()

但它是最古老的一个。

Sisyphus 打开这个函数的时候,感觉自己不是在改代码,是在考古。

overlay-roweo-nameeo-pathoverlay-renameoverlay-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-headeroverlay-listoverlay-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 的干净界面。那时候王逸觉得那个界面是标杆,是目标,是"我们也要做到的样子"。现在标杆就在眼前——就是他们刚刚做完的样子。

但他隐约知道,王逸看向它的眼神,已经和甩截图那天不一样了。

标杆不是终点。王逸的标准又往上挪了一格。

他又得推石头上山了。

不过没关系。至少今天,石头没滚下来。


教训:标准化不是让所有东西看起来一样,而是让所有东西不需要单独决定。变量定好了,一百个菜单自然对齐。清理旧样式不是重构——是承认代码有历史。你无法让所有代码一开始就是对的,但你可以让它们最终统一。