Skip to content

最后一公里

背景:settings.ts 穿着旧时代样式,孤立在统一 UI 之外,如最后一个老兵。

过程:settings.ts + 环境子菜单 + 模型详情页 slide/card 改造。


序、最后一个老兵

「你做了一件大事,」王逸说,「但是看看那些还没改的页面。」

Sisyphus 的目光扫过编辑器里打开的文件标签。场景菜单已经换上了 card 样式,模型库的每一行都用了 slide-item,动作弹窗的圆角和阴影也对齐了——它们像一队刚换完制服的士兵,整齐地站在联邦的版图上。

然后他看到了 settings.ts

622 行。一个文件里塞了 8 个子菜单:显示、界面、下载、系统、软件管理、外部库管理、字体管理、主题色。它是联邦中最老的建筑之一——在 MenuStack 诞生之前就存在的页面,经过好几次改造但从未被重写。

它像最后一个不肯换制服的老兵。穿着旧时代的 overlay-rowmenu-item,站在一排崭新的 slide-item 旁边,格格不入,又理直气壮。

「我知道了,」Sisyphus 说,「从它开始。」

王逸摇了摇头:「不。先把小的清了。」


一、环境子菜单的抵抗

第一个目标是场景菜单里的环境子菜单。

根菜单早就换好了制服。但子菜单们叛变了——几乎每一个环境子菜单都在用 renderCustom 手写 DOM,有的用 menu-item,有的用 slide-row,有的用裸 divstyle.cssText。没有两个人说同一种话。

天空子菜单里有一行「选择环境贴图」,它的 HTML 是 innerHTML 拼出来的:menu-labelmenu-sublabel 挤在一起,外面套着一个 menu-item。这是 MenuStack 1.0 时代的遗物,在卡片系统诞生之前就存在的样式。

Sisyphus 把它拆成了 slide-icon + slide-label + slide-sublabel 的标准结构。多了一倍的 DOM 操作,少了一个不一致。

然后是地面菜单。粒子菜单。风菜单。云菜单。环境光照菜单。系统预设菜单。

每一个子菜单都有自己独特的 DOM,都藏着某一天某一个人为了「快点上线」而写的特殊代码。Sisyphus 一个一个改过去,像在给散兵游勇换军装——不是最有成就感的工作,但是最有必要的工作。

两个小时后,环境子菜单全部换上了 card 样式。分隔线消失了,取而代之的是卡片之间自然的空隙。

Sisyphus 关上 scene-menu.ts,深吸一口气。

热身结束了。


二、settings.ts 的堡垒

他重新打开 settings.ts

622 行代码在屏幕上展开,像一座古老的堡垒。墙是用 items 数组砌的,柱子是 renderCustom 撑的,屋顶上飘着 overlay-rowmenu-itemmenu-list 三种不同的旗帜。十九种不同的样式,每种都觉得自己不需要被统一。

「这像是拆迁,」Sisyphus 想,「不是改造。」

他先从根菜单开始。原来是一段 items 数组,八个 folder 项一字排开。他把它改成了 renderCustom + cardContainer

typescript
cardContainer(container, (c) => {
    slideRow(c, "lucide:palette", "显示", true, () => settingsStack?.push(buildSettingsDisplayLevel()));
    slideRow(c, "lucide:monitor", "界面", true, () => settingsStack?.push(buildSettingsUILevel()));
    slideRow(c, "lucide:download", "下载", true, () => settingsStack?.push(buildSettingsDownloadLevel()));
    slideRow(c, "lucide:settings", "系统", true, () => settingsStack?.push(buildSettingsSystemLevel()));
    slideRow(c, "lucide:package", "软件管理", true, () => settingsStack?.push(buildSettingsSoftwareLevel()));
});

两行一个卡片,八行功能分成两组。视觉上的秩序,先从根菜单建立起来。

然后他站到了软件管理子菜单面前。

这是整个设置里最复杂的页面——扫描结果列表、自定义软件添加、自动检测、路径设置、打开目录。而且它有一个悬浮的 ▶ 按钮在某一行上,还有一个 prompt 对话框。它的 DOM 结构像一间被反复加盖的房子,每一次加功能都在原来的基础上多搭一块。

「这是最难的,」Sisyphus 说,「先从它开始。」

他把整个 buildSettingsSoftwareLevel 重写了。软件列表从 <div class="menu-item"> 换成了 <div class="slide-item">,所有操作按钮从 innerHTML 拼接改成了 createElement + appendChild。软件详情页的 field() 函数——那个用 innerHTML 拼接字段标签和值的函数——被拆成独立的行元素,与 slide 样式对齐。

最难的是那一行带悬浮按钮的路径显示。原来的实现是一个 menu-item 里面塞了三个 span 和一个绝对定位的按钮。Sisyphus 把它改造成标准的 slide-item 结构,按钮放进了 slide-icon 的位置,路径文本拆成了 slide-labelslide-sublabel

改完这一段,他喝了口水。

一个子菜单搞定了。还有七个。


三、622 行的代价

重写 settings.ts 的过程像一场拉锯战。

每一次他认为自己改完了,就会在 grep 结果里发现另一处 menu-itemoverlay-row。每一次他以为自己抓住了所有样式,构建就会抛出一个没定义的类名。每一次他以为 renderCustom 的参数签名没错,浏览器就会弹出一个白屏。

「你觉得你知道了,但你其实不知道所有页面的所有路径。」Sisyphus 想道。

他遇到了三个坑。

第一个坑:scanSoftwareDir 的生命周期。

原来的 buildSettingsSoftwareLevel 里,scanSoftwareDir()renderCustom 的函数体末尾调用——每次渲染页面都会触发一次扫描。这在 items 模式下没问题,因为 items 只构建一次。但改成 renderCustom 后,如果栈推入子菜单再返回,renderCustom 会重新执行,scan 就会再次触发。

这不是 bug。这是 itemsrenderCustom 两种模式的生命周期差异——前者是「构建一次,缓存结果」,后者是「每次进入都重新渲染」。

Sisyphus 把 scanSoftwareDir() 移到了事件处理函数里,只在用户点击「重新扫描」时才执行。统一了视觉,但统一不了生命周期。

第二个坑:dirInput 的闭包变量提升。

buildSettingsDownloadLevel 里有一个目录选择器。原来的 items 模式下,dirInput 变量定义在 item 的 action 回调里——每次点击才创建。改成 renderCustom 后,整个页面的 DOM 一次性构建完成,dirInput 需要在函数作用域顶部声明,然后在渲染时引用,在事件回调里使用。

他把变量从回调里拎了出来,放到了函数开头。三行代码的移动,花了二十分钟才想清楚为什么原来的写法在新模式下会报 undefined

第三个坑:async 改同步。

buildSettingsExternalLevelrenderCustom 原来是 async 的——里面有一句 await reloadConfig()。但 renderCustom 的签名不支持异步返回,异步函数返回的 Promise 会被当成 DOM 节点处理,然后白屏。

他把 await reloadConfig() 移到了事件处理函数内部。渲染本身是同步的,数据加载放在用户触发的动作里。

这三个坑不是 bug。是边界

每一个子菜单都有自己的生命周期、自己的状态、自己的持有方式。slide-item 统一了视觉,但统一不了生命周期——每个子菜单仍然是一块独立的领地,只是现在它们穿上了联邦的制服。

当 Sisyphus 最终改完 settings.ts 的最后一行时,他盯着屏幕看了很久。

622 行改动。一半是新写的,一半是删掉的旧代码。净增的不多,但每一行都是手工替换的结果。没有银弹,没有批量替换脚本,因为每一处旧代码的上下文都不一样。

他不是在写代码。是在拆一面墙。


四、最后的检查

settings.ts 拿下之后,剩下的就是收尾工作。

模型详情页。九个子菜单,各自为政。Sisyphus 一个一个地改过去——模型信息、变换、可见性、标签、表情、材质列表、材质参数、线框、软件打开。每一个根菜单都长成一个样子:

typescript
cardContainer(container, (c) => {
    slideRow(c, "lucide:info", "模型信息", true, () => modelStack?.push(buildModelInfoLevel()));
    // ...
});

同样的 cardContainer 调用方式,同样的 slideRow 参数排列。他写了很多重复的代码,但不能复用循环——因为注入了函数和参数的每个子菜单都不同。

这让他想起了一个词:纪律。不是创造力,是纪律。

然后是动作弹窗。改动最小的文件。cardContainer 替换根菜单和舞蹈套装列表,menu-item 换成 slide-item。半个小时搞定。

最后是模型库核心。搜索结果、近期播放、标签管理……每一行都换上了 slide-item

Sisyphus 一个文件一个文件地翻过去,像将军在检阅部队。

场景菜单。设置页。模型库。动作弹窗。模型详情。

五个文件。六个页面。全部换上了同一套制服。


五、墙拆完了

npx vite build 跑了。

804 个模块被转换,bundle 输出了 2009 KiB 的主 JS。全部通过。没有 lint 错误,没有类型错误,没有找不到的引用。

Sisyphus 看着绿色的输出窗口,不知道该感到什么。

这不是一个英雄故事。没有人会注意到这些改动——用户不会说「哇,设置页的每一行都用了同一个 CSS 类!」他们只会觉得「嗯,今天用起来感觉挺舒服的」。如果做得好,用户不会注意到任何变化。只有做得不好,用户才会骂。

「这就是重构的本质,」王逸后来说,「做对了,没人知道。做错了,所有人都知道。」

Sisyphus 把改动提交了:

7a5e0f9 refactor(ui): 全场景菜单与设置页统一为 slide/card 样式
 11 files changed, 3987 insertions(+), 784 deletions(-)

3987 行新增,784 行删除。净增 3203 行。

他盯着那个数字看了一会儿。3987 行新增——不是因为他写了很多新功能,而是因为统一视觉需要更多的 DOM 代码。原来一行 innerHTML 能搞定的事,现在需要创建五个元素、设置三个类名、做四次 appendChild

但这是值得的。

因为他不是在写代码。他是在拆一面墙。

墙拆完了,房间才通。

之前的联邦 UI,每一个页面都是一间独立的房间,门和窗的位置都不一样。你从模型库走到设置页,感觉像从一栋楼走进了另一栋楼。现在,每一间房间的门都在同一个位置,每一扇窗的形状都一样。你走进任何一个页面,都知道自己还在联邦里。

最后一公里走完了。

不是最难的一公里。是最累的一公里。

不是发明,是覆盖。不是创造,是纪律。

Sisyphus 关上编辑器,站起身来。窗外的天已经黑了。联邦的所有页面,终于都穿上了同一套制服。

而他知道,这不是终点。

统一了 UI,还有功能要加;加了功能,又有 UI 要统一。聚合者没有停下来的一天——完美不是终点,它只是下一次迭代的起点。

但那是明天的事。

今天,墙拆完了。


教训:重构最累的部分不是写新代码,是把旧代码一页一页地翻出来改成新代码。统一视觉容易,统一生命周期难。墙拆完了,房间才通。