Skip to content

第 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 需要:

  1. MMDLoader 整个实例化到 Worker 上下文
  2. 将 PMX ArrayBuffer 通过 postMessage 传输到 Worker
  3. 将骨骼数据、Morph 数据通过 postMessage 传回主线程
  4. 重新设计 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 允许 nullundefined 在任何地方流通,编译器不会发出警告。

联邦目前有 2 万多行代码,几乎全部运行在 strict: false 的宽松环境中。

外交官打开 ESLint,跑了第一次完整扫描。

21,128 个问题。

数字像瀑布一样从屏幕上倾泻下来。

其中绝大多数是:

  • no-unused-vars:大量 re-export 的函数和变量
  • no-explicit-any:在 strict: false 环境下,TypeScript 不检查类型,所以一切都流向 any
  • no-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 WorkerE2E 测试无并发限制,与 WebView2 STA 不兼容记录约束,待 E2E 扩展时修复
TypeScript strictstrict: 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 四处复制。