Appearance
第九城
背景: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行的文件。里面只有:
- engine/scene/modelManager(核心对象)
- focusedMmdModel() / focusedModel() / getScene()(快捷getter)
- initScene()(唯一的装配入口)
- 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://协议加载本地文件的时候,安全策略会拦截。
正常流程应该是:
- 调
resolveFileUrl(filePath) - 它启动HTTP文件服务器
- 返回
http://localhost:端口/...的URL - Babylon.js用HTTP URL加载
那为什么会走到file://?
他翻到scene-loader.ts的loadPMXFile。
然后他看到了自己写的代码:
typescript
const resolver = resolveFileUrl ?? (() => Promise.resolve({ url: filePath, port: 0, dir: "" }));这个默认值。
Phase 3拆loader的时候,为了"让模块更独立",他把resolveFileUrl做成了参数。然后给了一个假的fallback。
他当时想:"反正正常调用都会传真实的resolveFileUrl。"
但他忘了——initLoader里有没有传?
他翻到scene.ts的initLoader调用。
typescript
initLoader(
scene, modelRegistry, runtime, modelManager,
refreshWaterRenderList,
tryAutoApplyPreset,
(id) => loadOutfits(id).then(() => {}),
);七个参数,resolveFileUrl和normPath都没传。
所以fallback生效了。 所以url直接等于filePath。 所以Babylon.js试图用file://加载本地PMX文件。 所以被浏览器安全策略拦了。 所以模型加载失败。
一个假的默认值,炸掉了整个模型加载。
不要为了解耦而解耦
修复很简单。直接从fileservice.ts导入,不要做成参数。
typescript
import { resolveFileUrl, normPath } from "../core/fileservice";就这样。
Riku坐在那里,盯着这行代码看了很久。
他想起一句话:
"所有的设计原则,走到极端都是错的。"
依赖注入是对的。 解耦是对的。 可测试性是对的。
但当你为了这些原则,把一个永远不会有第二种实现的工具函数做成参数,还给了一个假的默认值——
那不是解耦。 那是埋雷。
真实的代码里,大部分依赖是单向的、确定的、不会变的。
resolveFileUrl永远是那个resolveFileUrl。 normPath永远是那个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。
但不一样了。
每个文件有自己的职责。每个文件有自己的状态。每个文件有自己的进出规则。
你改光照,不会碰坏渲染。 你改水面,不会碰坏云层。 你改模型加载,不会碰坏序列化。
每一次修改,都只碰它该碰的东西。
这就是拆分的全部意义。
不是为了变少。 不是为了变干净。 不是为了某种设计原则。
是为了——当你伸手去改一行代码的时候,你知道自己在碰什么,也知道不会碰坏什么。
教训:完美的架构不存在,恰到好处的分寸感才是终点。入口文件的引力是天然的——焊死门口,才能防止再次膨胀。