Appearance
第 4 章 · 代码的镜厅
背景:工具链缺规范,前端质量无基线
过程:审计工具链、引静态检查与格式化
前三波审计把联邦的核心城邦翻了个底朝天。渲染之城的过渡保存风暴、物理引擎的静默谎言、议会之墙的学舌伤——每一个问题都像一颗埋在地基里的石子,平时看不见,踩上去才知疼。
外交官合上笔记本,望向窗外。天还没有亮,但他知道今天的工作不会比昨天少。
今天的主题是工具链——不是某座城邦,而是建造这些城邦的脚手架本身。
镜厅的入口
工具链审计有一个特殊的名字,联邦的老人们叫它"镜厅"。
因为工具会反射。
Vite 的配置映出 Babylon.js 的影子,TypeScript 的 strict 映出两万个 any,Playwright 的 worker 数映出 CI 管道的宽度。如果工具链有裂缝,那裂缝会照进每一座城邦——你修完这个模块的 bug,下一个模块的 bug 已经在排队了。
外交官在议会的档案室里找到了那个入口。
他推开门。
第一面镜子:死 import
vite.config.ts 是联邦的入口文件——每个开发者的第一次 npm run dev,都是从这里开始的。
外交官打开它,看到了一面还算整洁的镜子。alias 正确、optimizeDeps 排除项正确、build.target 是 es2020、manualChunks 策略合理。
但是——
plugins 数组是空的。
@vitejs/plugin-vue 被引入了,但在 plugins 数组里是空的。这个插件没有被使用。
他查了 index.html——没有 Vue 组件。没有 SFC。没有 <script setup>。联邦的前端是纯 TypeScript + HTML。
import vue from '@vitejs/plugin-vue' 是一条死 import。它不会报错,因为 TypeScript 允许未使用的 import;它也不会造成运行时问题,因为插件没被使用。但它在那里,像一根没有点燃的蜡烛,占着烛台的位置。
外交官在笔记本上写下第一个标记:#dead-import。
小问题。真的很小。小到大多数人会直接忽略。
但外交官没有忽略。因为他知道——工具链的问题,都是从小裂缝开始的。 一根没用的蜡烛不可怕,可怕的是"没用的东西也可以留在那里"这个习惯。今天是一个死 import,明天是两个未使用的变量,后天就是一整段没人维护的代码。
镜厅的第一面镜子,照出的不是某个具体的 bug。是松懈。
第二面镜子:孤岛
optimizeDeps.exclude: ['babylon-mmd'] 是一个精心设计的孤岛策略。
babylon-mmd 是联邦里最特殊的居民——它不是 npm 安装的标准 ESM 包,而是被手动放进了 public/lib/ 目录下的 WASM 运行时。外面的 Vite 世界看不见它的 node_modules,它也进不去预构建优化器的领地。
外交官检查了这个孤岛的边界。
public/lib/ 下有:
babylon.mmd.loader.js—— 主加载器babylon.mmd.1.0.7.wasm—— WASM 二进制babylon.mmd.1.0.7.worker.js—— Worker 线程版本
他注意到了第三个文件。
Worker。
联邦里有一个 Worker,但没有人知道它。
外交官翻遍了 src/ 下的所有文件,没有找到 new Worker(...) 调用。没有 postMessage,没有 addEventListener('message')。babylon.mmd.worker.js 躺在 public/lib/ 里,像一扇没人推过的门。
联邦的 MMD 模型加载走的是同步路径——MMDLoader 在主线程上读取 ArrayBuffer、解算骨骼、设置 Morph Targets。每次加载一个 5MB 的 PMX 模型,主线程就冻结 200-800ms。
外交官在笔记本上重重地写下:MMD 加载无 Worker 并行化。
这不是一个 bug。这是一个架构决策。将 MMD 加载迁移到 Worker 需要:
- 将
MMDLoader整个实例化到 Worker 上下文 - 将 PMX ArrayBuffer 通过
postMessage传输到 Worker - 将骨骼数据、Morph 数据通过
postMessage传回主线程 - 重新设计
scene-loader.ts的加载流程,将"数据准备"和"场景组装"分离
这是一个 Phase 10 的任务。外交官在笔记本上画了一个虚线方框,标注:P10 待定。
镜厅的第二面镜子,照出的不是 bug。是选择。 你选择了简单的同步加载,就得接受主线程冻结。没有对错,只有取舍。
第三面镜子:线程的边界
外交官转而检查测试工具链。
playwright.config.ts 里有一行:
typescript
workers: process.env.CI ? 1 : undefined,CI 环境中只启动 1 个 worker,本地可以并行。合理。联邦的 E2E 测试目前很少,不需要手动限制。
但他注意到另一个配置:
typescript
fullyParallel: true,测试文件之间也并行。每一个 .spec.ts 里的测试用例会在不同的 worker 中同时运行。
外交官皱起了眉。
两个 worker 同时操作同一个 WebView2 实例,会发生什么?
一个 worker 在 openLibraryPopup(),另一个在 clickModelRow()。两个操作共享同一个 DOM 状态。两个 timer 在同一时刻触发。同一个文件对话框可能被打开两次。
WebView2 实例不是线程安全的。它的 COM 接口要求所有调用必须在同一个 STA 线程上序列化。如果两个 Playwright worker 同时操作,会出现不可预测的 DOM 竞态。
外交官在笔记本上写下:E2E 测试并发风险。
修复方案是简单的:本地也只用一个 worker。workers: 1。或者在测试用例之间加互斥锁。
但他没有立刻改。
因为这又是一个选择的问题。现在 E2E 测试用例还很少,没有并发执行的机会。改了也看不出效果。不改,等用例多了,某天就会炸。
就像碰撞体从未更新——你不会立刻发现,但它一直在那里。
镜厅的第三面镜子,照出的是未来的隐患。 你现在看不见它,因为还没到触发它的时候。但等你看见的时候,可能已经晚了。
第四面镜子:沉默的 TypeScript
外交官最后打开了 tsconfig.json。
这是工具链审计中最沉重的一面镜子,因为它照出的不是某个文件的问题,而是整个联邦的 TypeScript 质量基线。
json
"strict": false,他停在了这一行。
strict 模式是一组 TypeScript 规则的集合:严格空值检查、严格函数类型、严格类属性初始化。关闭它们意味着 TypeScript 允许 null 和 undefined 在任何地方流通,编译器不会发出警告。
联邦目前有 2 万多行代码,几乎全部运行在 strict: false 的宽松环境中。
外交官打开 ESLint,跑了第一次完整扫描。
21,128 个问题。
数字像瀑布一样从屏幕上倾泻下来。
其中绝大多数是:
no-unused-vars:大量 re-export 的函数和变量no-explicit-any:在strict: false环境下,TypeScript 不检查类型,所以一切都流向anyno-non-null-assertion:大量!操作符绕过类型检查
他深吸了一口气。
这不是一夜之间能修复的。如果强行开启 strict: true,联邦会立刻陷入编译错误的海洋——数百个 TS2322: Type 'string | undefined' is not assignable to type 'string'。
但这也不是可以一直拖着的问题。
strict: false 是什么?是另一种静默的谎言。
TypeScript 假装在检查类型,实际上什么都没查。你以为有类型安全,实际上和写 JavaScript 没区别。代码能跑,类型检查通过,你以为一切正常——直到某个 undefined 流到了不该去的地方,炸在用户脸上。
和物理引擎的碰撞体一模一样。和 shadowBias 滑块一模一样。和程序化动作的状态错位一模一样。
都是看起来在工作,实际上什么都没做。
镜厅的第四面镜子,照出了联邦最大的裂缝。
降级的艺术
外交官没有直接修改 tsconfig.json。他做了一件更聪明的事:引入 ESLint 作为质量门槛。
不能一下子把 strict: true 打开——那样所有代码都会报错,开发就没法进行了。但也不能什么都不做——那样 21,128 个问题永远都在那里。
正确的路径是渐进式推进。
eslintrc.cjs 中,他设置了以下规则:
javascript
'@typescript-eslint/no-explicit-any': 'warn', // 不崩溃,降级为警告
'@typescript-eslint/no-non-null-assertion': 'off', // 暂不检查
'@typescript-eslint/ban-ts-comment': ['error', { // 但 ts-ignore 需要理由
'ts-expect-error': 'allow-with-description',
'ts-ignore': 'allow-with-description',
}],
'@typescript-eslint/no-unused-vars': ['warn', { // 未使用的变量降为警告
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],然后他运行了 npm run lint:fix。
21,128 → 272。
格式化问题被自动修复了——单引号、缩进、trailing comma。剩下的 272 个问题是代码质量相关的——真正需要人来看的问题。
外交官看着这个数字,想了很久。
这就是降级的艺术。
最佳方案是 strict: true —— 百分之百的类型安全。但做不到。 次佳方案是 ESLint + warn —— 不阻断开发,但让问题可见。 保底方案是 strict: false —— 什么都不查,眼不见为净。
你不能一步到位的时候,就选第二好的方案。至少它在前进。
这让他想起了第 10 章的那个标题——给模型拍证件照。双 rAF 还是 whenReadyAsync?最佳方案不可靠的时候,选次佳的。选能工作的。
退化的艺术,无处不在。
Prettier 的裁决
外交官在配置 Prettier 时遇到了一个选择。
项目现有的代码用的是单引号。Prettier 的默认配置是双引号。
如果强制双引号,lint:fix 会修改所有 2 万行代码的引号——从 21,128 个问题里刨去单引号/双引号的部分,剩下的格式问题会大幅减少。
他盯着那两行配置看了很久。
javascript
// 选项 A:强制双引号(Prettier 默认)
singleQuote: false
// 选项 B:保持单引号(与现有代码一致)
singleQuote: true最后他选择了 B。
原因是:这不是格式问题,这是破坏性变更。把 2 万行代码的所有引号都改一遍,会让 git blame 变得毫无意义——每一行都会显示"这个文件在上周被修改",而实际上只是引号变了。
"工具应该服务于代码,"他在笔记本上写下,"而不是让代码去讨好工具。"
他配置了 prettierrc:
json
{
"singleQuote": true,
"trailingComma": "es5",
"semi": true,
"printWidth": 100,
"tabWidth": 4,
"arrowParens": "always",
"endOfLine": "lf"
}4 空格缩进与项目现有风格一致。LF 行尾符合 Unix 传统——即使在 Windows 上。
镜厅的倒影
工具链审计结束时,外交官站在镜厅的中央,面前是四面镜子的倒影。
| 镜子 | 发现 | 处理 |
|---|---|---|
| Vite 配置 | @vitejs/plugin-vue dead import | 记录,暂不移除(未来可能引入 Vue) |
| babylon-mmd 孤岛 | WASM 加载在主线程,Worker 文件存在但未使用 | 标注 P10 |
| Playwright Worker | E2E 测试无并发限制,与 WebView2 STA 不兼容 | 记录约束,待 E2E 扩展时修复 |
| TypeScript strict | strict: false,2 万行代码无类型保护 | 引入 ESLint 作为渐进式门槛 |
他想起了一件事。
学舌伤。
为什么同一个 bug 会在不同模块反复出现?因为没有工具在拦住它。如果 ESLint 规则里有"禁止直接拼接 innerHTML",那 settings.ts、model-preset.ts、motion-popup.ts 里的 XSS 风险,根本就不会出现。
工具链是什么?工具链就是把"人容易忘的事"变成"机器会提醒的事"。
你靠人工检查,就会有学舌伤——同一个坑踩一百遍。 你靠工具检查,踩一次,工具记住了,下次就拦下来了。
这才是镜厅真正的意义。不是为了找出多少个问题。是为了——让问题不再重复出现。
石子的影子
外交官走出镜厅的时候,天快亮了。
他的笔记本上又多了十几项标记。dead import、Worker 并行化、E2E 并发风险、strict 模式推进、ESLint 规则补充……
哪些重要?哪些不重要?哪些现在做?哪些以后做?
他不知道。
这些问题和物理引擎的碰撞体、和 shadowBias 滑块、和程序化动作的状态错位一样——有些用户能感知,有些用户永远感知不到。有些炸起来很疼,有些炸起来也没人知道。
他想起了前几天一直在想的那个问题:优先级该怎么分?
红的?黄的?绿的?
镜厅的这些问题,算哪一种?
dead import —— 用户永远看不见,绿的? E2E 并发风险 —— 现在不炸,以后炸,黄的? strict 模式 —— 类型不安全迟早要出事,红的?
他摇了摇头。现在想不清楚。
等所有问题都摆到桌面上,用红黄绿三种石子来分吧。
反正——审计还没结束。
附录:本章技术决策记录
| ADR | 内容 | 状态 |
|---|---|---|
| ESLint + Prettier 引入 | 渐进式质量门槛,零 error,以 warning 为主 | 已实施 |
| Prettier 单引号策略 | 不做破坏性引号替换,保持 singleQuote: true | 已实施 |
| ESLint error → warn | 第一阶段引入,以 warning 为主,不阻断开发 | 已实施 |
| E2E Worker 约束文档 | 记录 WebView2 STA 线程安全约束 | 待实施(P10 阶段) |
| TypeScript strict 渐进推进 | 不急于开启 strict: true,通过 ESLint 规则逐步清理 | 进行中 |
教训:工具链是质量的放大器。好的工具链让 bug 难以诞生,坏的工具链让 bug 四处复制。