Skip to content

第九城

背景:scene.ts 450 行仍含 20+ 模型操作函数——入口文件的引力让什么都想往里放。 过程:拆出 scene-model-ops.ts(20+ 转发函数)→ scene.ts 174 行 + 修复一个过度设计(resolver 假默认值炸掉模型加载)。

Jieling的报告标题很长。

「进一步拆分的判断与建议:从 B+ 到 A-」

Riku盯着这个标题看了三秒。他刚把scene.ts从790行砍到450行,砍了四成,以为已经够狠了。

"从B+到A-,"他念出声,"她怎么不直接说要SSS。"

他点开报告。


入口的引力

Jieling列了两个隐患。

第一条:export *作为API中转站,破坏Tree Shaking。

Riku想了两秒。Tree Shaking?联邦是单bundle桌面应用,整个应用从头到尾只有一个chunk,摇不摇有区别吗?他回了一句:"不用改。"

第二条:模型操作函数还留在scene.ts,职责边界再次模糊。

今天20个,明天21个,后天22个——温水煮青蛙,等你发现的时候又成神模块了。

这条他没立刻回。

因为他知道是对的。

scene.ts从第一卷的200行开始涨。第二卷末500行,第三卷800行,第四卷破了千。每次加功能,开发者都会想"这个跟场景有关,放scene.ts里吧"。一次加10行,100次就是1000行。

入口文件有一种天然的引力——什么东西都想往这里放。

除非你把门口焊死。


一道辩题

Riku在白板上列了两个提议:

提议决定
模型操作函数迁出✅ 做
export *改显式导出❌ 不做
创建index.ts门面❌ 不做

他只做了高价值的事。

Tree Shaking对单bundle应用没有收益,显式导出是破坏性变更,创建index门面收益为零。不追求A级干净,追求的是——下次加功能的时候,开发者不会下意识往scene.ts里塞。

他给Jieling回了消息:

"模型操作函数迁出去。scene.ts降到200行以内。"


第九城:scene-model-ops.ts

这是最容易拆的一块。

二十多个函数,几乎全是转发:

typescript
export function setModelVisibility(id, visible) {
    modelManager?.setVisibility(id, visible);
}
export function setModelOpacity(id, opacity) {
    modelManager?.setOpacity(id, opacity);
}
export function setModelWireframe(id, wireframe) {
    modelManager?.setWireframe(id, wireframe);
}
// ... 二十多个

一行一个函数。一行一个转发。

那为什么不直接让外部调用modelManager

因为门面的意义——外部代码不需要知道modelManager是什么,不需要知道它在哪里,只需要知道"我要设置模型可见性"。未来如果modelManager的API变了,只改一处就行。

所以它们值得有自己的房间。

scene-model-ops.ts

第九城。

专门管模型实例的操作——可见性、透明度、线框、骨骼线、物理、变换、VMD停止、Morph预览……所有"对一个模型做什么"的操作,都在这里。


拆完

二十多个函数搬走之后,scene.ts瘦到了174行。

从1381到790到450到174。

四刀,砍了87%。

Riku打开这个174行的文件。里面只有:

  1. engine/scene/modelManager(核心对象)
  2. focusedMmdModel() / focusedModel() / getScene()(快捷getter)
  3. initScene()(唯一的装配入口)
  4. re-export(API兼容层)

纯得不能再纯的组装器。

他靠在椅背上,看着这个文件,有一种奇怪的感觉——

它太空了。

就像一个刚搬完家的房子,墙壁雪白,地板光亮,但没有生活气息。

以前的scene.ts乱糟糟的,但什么都有——你想找任何跟场景有关的东西,直接打开它就行。

现在的scene.ts干干净净,但你想找什么,得先想"它在哪个子模块里"。

这就是拆分的代价——认知成本从"读一个大文件"变成了"知道该打开哪个文件"。

值不值?

Riku想了想,点头。

值。

因为大文件的认知成本是线性增长的——从500行到1000行,难度不是两倍,是三四倍。因为上下文太多了,你得记住所有东西之间的关系。

而多文件的认知成本是对数增长的——从5个文件到10个文件,难度只增加一点点。因为你只需要记住"光照在lighting里,渲染在renderer里,加载在loader里"。

文件数增加的边际成本,远低于单文件行数增加的边际成本。

这是八城之盟、十一城之盟、十三城之盟的意义。


第二个雷

拆完第九城,Riku照例跑了一遍构建。

tsc零错误。 vite build通过。

174行。

完美。

他打开应用想加载个模型验证一下。

模型没出来。控制台一片红:

Not allowed to load local resource: file:///C:/Users/.../梦野樱loli4.0.pmx

不允许加载本地资源。

Riku心里咯噔一下。

这个错误他见过——当浏览器试图直接用file://协议加载本地文件的时候,安全策略会拦截。

正常流程应该是:

  1. resolveFileUrl(filePath)
  2. 它启动HTTP文件服务器
  3. 返回http://localhost:端口/...的URL
  4. Babylon.js用HTTP URL加载

那为什么会走到file://

他翻到scene-loader.tsloadPMXFile

然后他看到了自己写的代码:

typescript
const resolver = resolveFileUrl ?? (() => Promise.resolve({ url: filePath, port: 0, dir: "" }));

这个默认值。

Phase 3拆loader的时候,为了"让模块更独立",他把resolveFileUrl做成了参数。然后给了一个假的fallback。

他当时想:"反正正常调用都会传真实的resolveFileUrl。"

但他忘了——initLoader里有没有传?

他翻到scene.tsinitLoader调用。

typescript
initLoader(
    scene, modelRegistry, runtime, modelManager,
    refreshWaterRenderList,
    tryAutoApplyPreset,
    (id) => loadOutfits(id).then(() => {}),
);

七个参数,resolveFileUrlnormPath都没传。

所以fallback生效了。 所以url直接等于filePath。 所以Babylon.js试图用file://加载本地PMX文件。 所以被浏览器安全策略拦了。 所以模型加载失败。

一个假的默认值,炸掉了整个模型加载。


不要为了解耦而解耦

修复很简单。直接从fileservice.ts导入,不要做成参数。

typescript
import { resolveFileUrl, normPath } from "../core/fileservice";

就这样。

Riku坐在那里,盯着这行代码看了很久。

他想起一句话:

"所有的设计原则,走到极端都是错的。"

依赖注入是对的。 解耦是对的。 可测试性是对的。

但当你为了这些原则,把一个永远不会有第二种实现的工具函数做成参数,还给了一个假的默认值——

那不是解耦。 那是埋雷

真实的代码里,大部分依赖是单向的、确定的、不会变的。

resolveFileUrl永远是那个resolveFileUrlnormPath永远是那个normPath

它们不会有"测试用的mock实现"——因为真的要测试的话,你应该mock HTTP响应,而不是mock路径解析。

为了"理论上的可测试性",给实际运行埋雷,是最蠢的过度设计。

他把这个雷从代码里删了。 把假的默认值删了。 直接import。

简单、直接、不会错。


最终

修复完第二个雷,Riku重新构建。

应用启动。 模型加载正常。 VMD播放正常。 环境切换正常。 Auto Dance + LipSync正常。 序列化/反序列化正常。

一切如初。

他打开文件列表,数了数:

scene.ts                    174 行
scene-model.ts              ~200 行
scene-material.ts           ~300 行
scene-vmd.ts                ~250 行
scene-playback.ts           ~150 行
scene-lighting.ts           ~220 行
scene-renderer.ts           ~195 行
scene-loader.ts             ~175 行
scene-proc-motion.ts        ~300 行
scene-lipsync.ts            ~120 行
scene-props.ts              ~150 行
scene-serialize.ts          ~300 行
scene-env-bridge.ts         ~200 行
scene-env-impl.ts           ~480 行
scene-env-water.ts          ~450 行
scene-env-clouds.ts         ~400 行
scene-env-particles.ts      ~300 行

17个文件。

从2个大文件,变成了17个小文件。

总代码量没变。甚至多了几十行import和re-export。

但不一样了。

每个文件有自己的职责。每个文件有自己的状态。每个文件有自己的进出规则。

你改光照,不会碰坏渲染。 你改水面,不会碰坏云层。 你改模型加载,不会碰坏序列化。

每一次修改,都只碰它该碰的东西。

这就是拆分的全部意义。

不是为了变少。 不是为了变干净。 不是为了某种设计原则。

是为了——当你伸手去改一行代码的时候,你知道自己在碰什么,也知道不会碰坏什么。


教训:完美的架构不存在,恰到好处的分寸感才是终点。入口文件的引力是天然的——焊死门口,才能防止再次膨胀。