Skip to content

瘦身日

背景:35 个 .ts 文件平铺在 src/ 下,scene.ts 2639 行横跨 8 个职责域——结构失控。 过程:Go 后端 7 条裂缝修复 + scene.ts 拆出 800 行 + 5 子目录重组 + 构建 2062KB→153KB + 5 份文档同步。


一、清算

六月二十八日,联邦迎来了它的清算日。

不是因为什么紧急事故。没有崩溃,没有 Issue,没有用户投诉。天花板没有漏水,地基没有裂缝。恰恰相反——联邦运行得很好。模型加载稳定,换装流畅,环境系统在用户面前铺开了一片可以自由呼吸的天空。

但 Sisyphus 知道:一个系统在最好的时候也是最脆弱的时候。因为最好的时候没人会去修它。

他刚刚完成了连续几周的功能开发——环境系统、弹窗大修、UI 改版、发布讨论。代码库像一间被派对占据了三天的客厅:沙发上有空酒瓶,茶几上堆着外卖盒,地毯上散落着薯片渣。每一件单独看都不算什么——但当你站在门口扫视整个房间,你知道不打扫不行了。

他的目光落在 src/ 目录上。

35 个 .ts 文件。

全部平铺在一个目录里。

没有 subdirectory,没有模块边界,没有"这个文件夹是干什么的"标识。35 个文件像 35 个陌生人站在同一个房间,各说各的话。config.ts 既管类型定义又管 DOM 引用又管工具函数,scene.ts 横跨场景初始化、模型管理、材质调节、环境光照、VMD 加载、程序化动作、序列化——两千九百行代码,八十七个导出符号,十一个消费者文件。

「这不是文件,」Sisyphus 想,「这是失控。」

清理日从清算开始。他列了一份清单,不是 Todo,是「罪状」。


二、Go 城的七条裂缝

他没有从最显眼的开始。他选择了最深的。

联邦的 Go 后端——app.gohttpserver.golibrary.gopmx.go——这些是地基,埋在桌面壳的最底层,用户看不到它们,但没有它们联邦连站都站不起来。Sisyphus 花了整整一轮审查这些文件,像一个法医一样逐行检视。

他发现了七条裂缝。

第一,integration.go 第 194 行有一行死代码:_ = existing。某个程序员在某次重构后忘了清理,把一行无意义的赋值留在了那里。编译器不报错——Go 的 _ 本来就是用来吞值的。但这行代码像一个被遗忘在冰箱里的饭盒,你知道它不会伤害你,但它让你对这个厨房的卫生水平产生了怀疑。

第二,httpserver.gocopyDir 函数在复制文件失败时保持沉默。不是故意沉默——是觉得"反正失败了也做不了什么"。但沉默的失败是更糟糕的失败,因为它让你以为成功了。

第三,library.goGetConfig 函数把所有的错误一视同仁地吞掉了。文件不存在、权限不足、JSON 损坏——三种完全不同的失败原因,走的是同一条沉默的路径。

第四,pmx.go 中有一段手写的 UTF-16 解码函数,一百多行,处理边界情况、代理对、无效序列——一整套微型标准库实现。但它不是必要的。Go 的标准库 unicode/utf16 在 Go 1.23 中已经有了完全相同的功能,经过更多测试,由标准库维护者持续维护。这就像用手搓绳子来系鞋带——你有那个手艺,但不代表你应该这么做。

第五和第六,app.goshutdown() 函数几乎是空的。当一个桌面壳程序需要关闭时,它应该关闭它的 HTTP 服务器,应该停止它的文件监控器,应该释放它持有的所有资源。但 shutdown() 什么都没做。这意味着每次用户关闭程序,StartFileServer 留下的 goroutine 都像无人认领的行李一样在后台孤独运行。

第七,PMXPath 这个字段名具有误导性——它实际上也用来存储 VMD 路径。

Sisyphus 逐一修复了这七条裂缝。不是大手术——有些只是一行代码的修改。但当你把一条有着百年历史的街道上所有松动的砖都重新铺好时,这条街道虽然没有变宽,却变得可靠了。

Go 的构建通过了。测试通过了。静态分析通过了。

但这是最小的胜利。

Go后端是联邦的地基。不是地面建筑——用户看不到它,摸不到它,不会说"这个按钮好漂亮"或"这个菜单好奇怪"。地基的作用只有一个:不能裂。

地面建筑裂了,你会看到裂缝,你会去修。地基裂了,地面建筑看起来还是好好的——直到某天整个结构开始倾斜。

Sisyphus花了整整一轮审查Go端,像一个法医一样逐行检视。七条裂缝。七行修复。没有新功能,没有用户可见的变化。

然后他转向了地面建筑。


三、两千九百行的怪物

Sisyphus 转向了 scene.ts

他不是第一次面对这个文件了。在过去几个月的开发中,他无数次打开它、搜索某个函数、找到它、修改、关闭。每一次他都觉得这个文件太长了,但每一次他都有更紧急的事情要做。

今天没有更紧急的事情了。

他调出了 scene.ts 的结构分析:2639 行纯代码(不含空行和注释),87 个导出符号,11 个消费者文件。内部依赖图像一张蛛网——serializeScene() 调用 getLightState()getRenderState()deserializeScene() 调用 setLightState()setEnvState()loadProp()setEnvState() 又调用 setLightState()triggerAutoSave() 调用 serializeScene()redoEnvAutoLink() 调用 setLightState()

整个文件是一个服务网点——所有功能都从这里经过,但没有一个功能属于这里。

Sisyphus 开始标记边界。

他找出了六个可以独立生存的模块:

  • 材质的尊严。材质调节系统——_catOf_applyAllsetMatParams——这些函数操作的是模型的视觉表面,与场景的其余部分只有浅层关联。它们只需要 config.ts 中的类型定义,没有理由挤在 scene.ts 里。
  • 模型的意志。模型注册表、生命周期、属性管理——这些是 ModelManager 的工作。260 行代码管理 modelRegistryfocusedModelId、骨架覆盖层、物理类别——一个完整的领域对象等待被封装。
  • VMD 的通道。四个加载函数——loadVMDMotionloadVMDFromPathloadCameraVmdFromPathloadVPDPose——它们把 ArrayBuffer 转化为 VMD 动画对象。高度耦合,边界清晰。
  • 播放的面板。进度条和时间显示——updatePlaybackUIseekFromEvent。UI 逻辑,与场景无关。
  • 序列化的地图。场景的"保存"和"恢复"逻辑——serializeScenedeserializeScene。它们遍历整个场景的状态,把它转化为 JSON,再从 JSON 还原。
  • 程序化的节奏。Idle Motion、Auto Dance、LipSync——这些是自动生成的动作系统,独立于用户加载的 VMD。

他最先提取的是最干净的:scene-material.ts。261 行代码,只依赖 config.ts,像一颗完全没有根须纠缠的牙齿,轻轻一拔就出来了。

然后是 ModelManager。541 行代码封装了模型状态的全部逻辑——注册、移除、聚焦、排列、可见性、不透明度、线框模式、骨骼显示、物理开关、缩放、旋转、位置变换、形态键控制、缩略图捕获。当他把这 541 行从 scene.ts 中移走时,他感觉到 scene.ts 像松了一口气的巨人,身体轻了一些。

但他也被上了一课。

当他试图提取 scene-lighting.ts——光照状态、渲染管线、阴影参数——时,他撞上了一堵看不见的墙。_envSys 是异步初始化的,在 initScene() 完成后才从核心模块加载,而光照代码与 _envSys 有着隐蔽的契约:setLightState 会触发环境系统的重新链接,而 redoEnvAutoLink 反过来又设置了光照。这不是函数调用,这是依存关系——两个模块已经长在了一起。

他放弃了 scene-lighting.ts。不是所有模块都可以被干净地提取,承认这一点比强行拉扯更明智。

接着提取了 scene-vmd.tsscene-playback.ts

到中午时分,scene.ts 从 2639 行降到了 1849 行——移出了将近八百行代码。不完美——他想要的目标是 1200 行——但足够让这个巨兽变得可以对话。


四、三十五人的集体宿舍

下午,Sisyphus 面对的是文件夹结构。

35 个文件平铺在 src/ 目录下,是联邦从零到一快速生长留下的印记。功能爆发时,没有人停下来问"这个文件应该放在哪里",因为"放在 src 根目录"永远是最快的选择。但选择省掉的那个思考,终有一天会在你寻找某个函数时加倍偿还。

Sisyphus 画了一张地图。

不是按字母排序的目录名,而是按功能领域的自然分群:

src/
├── core/          # 基础设施:config, main, fileservice, ui-helpers, icons
├── scene/         # 3D 核心:scene, model manager, material, VMD, playback, camera, env-lighting
├── menus/         # 弹窗 UI:library, model-detail, scene-menu, env-menu, settings...
├── motion/        # 运动系统:procedural, beat-detector, vpd-parser, vmd-writer, lipsync
└── outfit/        # 换装系统:outfit, audio

这个分类不是靠直觉决定的。他追踪了每一个导入路径,统计了哪些文件互相依赖,计算了耦合度的自然聚类。core/ 中的文件主要互相引用,很少引用其他目录;scene/ 是宇宙的中心,被几乎所有消费者引用但很少引用消费者;menus/ 中的文件主要引用 core/scene/,很少互相引用。这是一个清晰的层级结构,只是在 35 个文件平铺的遮蔽下,没有人看见它。

git mv 是枯燥的。35 个文件,每个文件需要移动到新的目录,然后更新所有引用它的导入路径。Sisyphus 写了一个 PowerShell 脚本来自动化路径替换——两遍扫描,第一遍处理根目录文件的导入前缀,第二遍处理跨子目录的 wailsjs 路径修正。

但脚本不能覆盖所有情况。scene.ts 中有一个动态 import:

typescript
const { serializeScene } = await import("./model-preset");

在移动到 scene/ 子目录后,这个路径应该是 ../model-preset——不对,model-preset 也被移动到了 menus/,所以实际路径是 ../menus/model-preset。这种分散在整个代码库中的手动例外,他一个接一个地找出来并修正了。

构建通过了。

835 个模块,零错误。

npx vite build 输出绿色时,Sisyphus 意识到这不仅是一次文件重组——这是他第一次看到自己代码库的完整拓扑结构。35 个文件组成了一张地图,而在此之前,他一直是在没有地图的情况下航行的。


五、脂 肪(四位数到三位数)

「构筑优化」是 Sisyphus 清单上的另一项。他想知道联邦被发送到用户电脑上时有多重。

Vite 的构建报告并不令人满意:主包 2062 KiB。这是两兆字节的 JavaScript——不是图片,不是字体,是代码——用户第一次打开页面就需要下载、解析、执行的两兆字节代码。

他开始搜索可以做的事。

第一件轻而易举:package.json 加一行 "sideEffects": false。告诉打包工具这个项目的模块是"纯"的——没有副作用 import,可以安全地 tree-shake。十秒的修改,可能节省 5% 到 15% 的体积。

第二件安装 rollup-plugin-visualizer,一个生成打包分析图的工具。不是优化本身,而是为后续优化提供依据。

但真正的突破来自 manualChunks

Vite 默认把所有 node_modules 中的代码打包成一个单独的 vendor chunk。这本身没问题,但它把 babylon-mmd@babylonjs/core——超过两兆字节的 3D 引擎代码——和所有其他依赖混在了一起。这意味着每次代码变更,用户的浏览器都要重新验证整个 vendor chunk。

Sisyphus 在 vite.config.ts 中写了一段配置,告诉打包工具把 babylon-mmd 及其相关包单独拆成一个 babylon-vendor chunk。

效果是戏剧性的:

主包:2062 KiB → 153 KiB

92% 的缩减。

不是因为他做了什么精巧的事情。只是因为他把物流分开了——3D 渲染引擎是一艘巨大的货轮,不频繁变动,应该单独停泊在它自己的码头。应用代码是一艘快艇,频繁进出港口,应该有自己的泊位。把它们绑在一起只会拖慢彼此。

然后他转向了动态加载。

settings.ts(783 行)和 scene-menu.ts(937 行)是两个巨大的弹窗模块,用户在启动时几乎从来不会马上用到——它们是被动触发的。Sisyphus 把它们从 main.ts 的静态导入中移除,换成了 await import("./menus/settings")await import("./menus/scene-menu")——只有在用户第一次点击设置按钮或场景按钮时才加载。

同样的逻辑也应用到了 library.tsmodel-detail.ts 中原本静态引用 scene-menu.ts 的地方。

联邦的启动不再等待每一个弹窗都就绪。用户点击按钮时才会去加载——用户感知不到加载延迟,但联邦的启动速度快了 90%。

Go 后端也有一项调整。scripts/release.ps1 中添加了 -ldflags="-s -w"——去掉调试信息和符号表。Go 的可执行文件从 12 MB 降到了 9.7 MB。

不是改变世界的数字。但当你发布一个桌面应用时,每一兆字节都是对用户带宽和磁盘空间的尊重。


六、地图的同步

Sisyphus 有一个习惯:再好的重构,如果没有更新文档,那就等于没有发生。

因为下一个走进代码库的人——可能是另一个 AI,可能是另一个开发者,可能是六个月后忘记了一切的他自己——他们不会知道 ModelManager 已经从 scene.ts 中提取出来了。他们不会知道文件已经从 src/ 平铺变成了五子目录结构。他们不会知道 bundle 已经优化过。

他们只会打开一个过时的文档,看到一个错误的路径,然后开始浪费时间。

Sisyphus 打开每一份文档,逐行扫描,寻找过时的路径引用。

reusables.md 中,他找到了 config.ts 的引用——应该改为 core/config.tsscene.ts——改为 scene/scene.tslibrary.ts——改为 menus/library.tsprocedural-motion.ts——改为 motion/procedural-motion.ts。每一个函数映射表都需要同步。

他加上了 ModelManager 的完整 API 表——三十多个方法,涵盖了模型从创建到销毁的完整生命周期。他加上了 scene-vmd.tsscene-playback.tsscene-material.ts 的函数索引。他更新了 camera.tsaudio.ts 的条目。

AGENTS.md——那份被戏称为「联邦宪法」的项目入口文档——他更新了函数映射表,修正了文件级互斥表中 config.tsscene.ts 的路径,改了关键文件速查表和任务触发索引中的每一个引用。

menu-architecture.md 中,他在设置页的章节里补充了「界面自定义」分类——缩放、字体、主题、模糊效果、动画开关——这些是在弹窗 UI 大改版中新增的功能。他添加了「环境弹窗」章节,枚举了十二个环境子菜单项。

troubleshooting.md 中,他把四处 frontend/src/main.ts 的引用改成了 frontend/src/core/main.ts

architecture.md 中,他把 UTF-16LE 编解码的描述从「手写解码」改成了「unicode/utf16.Decode 标准库」。

五份文档,全部同步。

文档不会让代码运行得更快。不会修复 bug。不会添加功能。但文档让下一个走进来的人少花十分钟搞懂"这是什么"——而十分钟在开发者的时间银行里是巨款。


七、聚合的代价

一天结束时,Sisyphus 看了 git log。

12 个 commit。从 Go 后端的七条裂缝修复,到 bundle 优化,到 scene.ts 的模块拆分,到文件夹归类,到文档同步。12 次提交,每一次都独立通过了构建验证。

他想起了一件有趣的事:今天他没有添加任何新功能。

没有任何用户可见的改变。用户熟悉的联邦——同样的模型库弹窗,同样的环境调节面板,同样的换装操作——一切功能都和昨天一样。唯一不同的,是代码的组织方式。

但这可能是最重要的一天。

因为一个好功能加在一个烂架构上,只是一个在未来会腐烂的功能。而一个好的架构,不管未来加什么功能,都不会加速腐烂。

联邦今天没有变胖,它变瘦了。它的 3D 场景模块从两千九百行降到一千八百行,它的代码文件从 35 个平铺变为 5 类有组织的子目录,它的启动包从两兆字节降到了 153 千字节。

聚合的悖论是:每收编一个城邦,你就引入了一整套边界和负担。但反过来也是真的——每一次厘清这些边界,你就不是在收编城邦,而是在理解它们。理解不会消除负担,但理解让负担变得可以承受。

今天联邦没有收编任何新城邦。

但它理解了已有的城邦们。



教训:最好的功能,有时是一条不增加功能的 commit。