[P1][CR-F9/F10] 前端上帝对象、重复常量与事件监听器只增不减 #533

Open
opened 2026-09-29 06:44:00 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 F9、F10|基线 e475c2e

问题描述

  • 上帝对象:前端的几个核心类职责过多,其中 WorldPanelsHud 一个类有约 2700 行、78 个方法。
  • 重复常量:城镇 ID 这类领域常量散落在多处,已经有了具名常量也没人用。
  • 监听器泄漏:多个模块只注册事件监听器、从不移除,也没有 dispose()。

目前大多数场景切换都会刷新页面,所以监听器泄漏还不明显。但在 HMR、测试,以及今后不刷新页面的场景切换(#526 的 SceneController)中,监听器会成倍累积,同一个事件会被处理多次。

证据

F9:上帝对象与重复

F10:监听器生命周期

模块 addEventListener removeEventListener 备注
ui/hud-manager.ts 8 0 没有 dispose()
scene/net-scene.ts 5 0
scene/frontend/toolbar-chat-ui.ts 4 0 另有 7 处 .onXxx = 赋值
frontend/bnet-main.ts 4 0
scene/frontend/mouse-controller.ts 8 8 对称,可作为范例
render/renderer.ts 2 2 对称,可作为范例

目前只有在 beforeunload 时才做清理。

根因

  • 功能按"往现有的类里加方法"的方式增长,没有按领域拆分。
  • 模块没有统一的生命周期约定:谁创建、谁销毁,都没有定义。

修复指南

  1. 统一的资源回收工具:
    export class Disposables {
      private readonly controller = new AbortController()
      private readonly fns: (() => void)[] = []
      get signal(): AbortSignal {
        return this.controller.signal
      }
      add(fn: () => void): void {
        this.fns.push(fn)
      }
      dispose(): void {
        this.controller.abort()
        for (const fn of this.fns.splice(0).reverse()) fn()
      }
    }
    
    • 注册监听器统一写成 target.addEventListener(type, handler, { signal: d.signal }),dispose() 时一次性全部移除;
    • .onXxx = 赋值改为 addEventListener(同时解决 #534 中"同一元素挂两个 change 处理器"的问题);
    • 纹理、AudioContext、定时器等资源也通过 add() 登记。
  2. 每个前端模块都实现 dispose():HudManager、net-scene、toolbar-chat-ui、bnet-main 优先。SceneController(#526)销毁时,按创建顺序的逆序调用各模块的 dispose()。
  3. 拆分 WorldPanelsHud:按领域拆成 StashPanel、CubePanel、VendorPanel、WaypointPanel、QuestPanel、NpcMenu、TooltipFormatter 等。交易、存取金币等规则通过引擎命令完成(#520),面板只负责展示和发出命令。
  4. 合并重复常量和逻辑:
    • 城镇判断统一为 game/ 中的一个 isTownLevel(levelId),其他地方都引用它;删除 5 处内联数组、多余的常量,以及 slug.includes('town');
    • safe-zone / rebudget 和"帧元组 → AtlasFrame"各自抽成一个函数。
  5. 分批进行:先做第 1、2 步(低风险,收益明确),再逐个面板拆分 WorldPanelsHud,每拆一个面板单独一个 PR。

验收标准

  • 上表中的每个模块,监听器的注册和移除都对称(或者统一使用 signal),并且都有 dispose()。
  • 新增测试:创建再 dispose() 场景 3 次后,window / document 上的监听器数量回到基线(可以用包装后的 addEventListener 计数)。
  • src/ 中城镇 ID 的内联数组为 0,只保留一个 isTownLevel。
  • WorldPanelsHud 拆分后,单个类不超过 800 行。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #526:SceneController 的生命周期。
  • #520:交易和金币规则移进引擎。
  • #528、#529:AudioContext 和纹理的回收。
  • #534:#act 上重复挂载的 change 处理器。
> 来源:#516|优先级 P1|评审编号 F9、F10|基线 `e475c2e` ## 问题描述 - **上帝对象**:前端的几个核心类职责过多,其中 `WorldPanelsHud` 一个类有约 2700 行、78 个方法。 - **重复常量**:城镇 ID 这类领域常量散落在多处,已经有了具名常量也没人用。 - **监听器泄漏**:多个模块只注册事件监听器、从不移除,也没有 `dispose()`。 目前大多数场景切换都会刷新页面,所以监听器泄漏还不明显。但在 HMR、测试,以及今后不刷新页面的场景切换(#526 的 `SceneController`)中,监听器会成倍累积,同一个事件会被处理多次。 ## 证据 ### F9:上帝对象与重复 - **`WorldPanelsHud`**([world-panels.ts:618-3355](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/ui/world-panels.ts#L618-L3355)):约 2700 行、78 个方法,混合了仓库、赫拉迪克方块、商店、传送小站、任务、NPC 菜单和 tooltip 本地化。它还直接修改金币(见 #520)。 - **`HudManager`**:同时持有背包、仓库、技能树和职业配置。 - **城镇 ID**:`[1, 40, 75, 103, 109]` 在 5 处内联: - [acts.ts:120](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/acts.ts#L120) - [engine.ts:910](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L910) - [act-scene.ts:339](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L339) - [pack-loader.ts:551](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L551)(还额外加了 `entry.slug.includes('town')` 这种按名字的判断) - [pack-loader.ts:1032](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L1032) 而具名常量已经有 3 个: - `ACT_TOWNS`([acts.ts:291](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/acts.ts#L291)) - `TOWN_LEVEL_IDS`([scene-population.ts:56](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/scene-population.ts#L56)) - `drlg-dump.ts` 里的另一个 `TOWN_LEVEL_IDS` - **其他重复**:safe-zone / rebudget 逻辑有 3 份;"帧元组 → `AtlasFrame`"的映射约有 6 份(其中一份见 [missile-overlay-renderer.ts:82-91](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/missile-overlay-renderer.ts#L82-L91))。 ### F10:监听器生命周期 | 模块 | `addEventListener` | `removeEventListener` | 备注 | |---|---|---|---| | `ui/hud-manager.ts` | 8 | 0 | 没有 `dispose()` | | `scene/net-scene.ts` | 5 | 0 | | | `scene/frontend/toolbar-chat-ui.ts` | 4 | 0 | 另有 7 处 `.onXxx =` 赋值 | | `frontend/bnet-main.ts` | 4 | 0 | | | `scene/frontend/mouse-controller.ts` | 8 | 8 | 对称,可作为范例 | | `render/renderer.ts` | 2 | 2 | 对称,可作为范例 | 目前只有在 `beforeunload` 时才做清理。 ## 根因 - 功能按"往现有的类里加方法"的方式增长,没有按领域拆分。 - 模块没有统一的生命周期约定:谁创建、谁销毁,都没有定义。 ## 修复指南 1. **统一的资源回收工具**: ```ts export class Disposables { private readonly controller = new AbortController() private readonly fns: (() => void)[] = [] get signal(): AbortSignal { return this.controller.signal } add(fn: () => void): void { this.fns.push(fn) } dispose(): void { this.controller.abort() for (const fn of this.fns.splice(0).reverse()) fn() } } ``` - 注册监听器统一写成 `target.addEventListener(type, handler, { signal: d.signal })`,`dispose()` 时一次性全部移除; - `.onXxx = ` 赋值改为 `addEventListener`(同时解决 #534 中"同一元素挂两个 change 处理器"的问题); - 纹理、AudioContext、定时器等资源也通过 `add()` 登记。 2. **每个前端模块都实现 `dispose()`**:`HudManager`、`net-scene`、`toolbar-chat-ui`、`bnet-main` 优先。`SceneController`(#526)销毁时,按创建顺序的逆序调用各模块的 `dispose()`。 3. **拆分 `WorldPanelsHud`**:按领域拆成 `StashPanel`、`CubePanel`、`VendorPanel`、`WaypointPanel`、`QuestPanel`、`NpcMenu`、`TooltipFormatter` 等。交易、存取金币等规则通过引擎命令完成(#520),面板只负责展示和发出命令。 4. **合并重复常量和逻辑**: - 城镇判断统一为 `game/` 中的一个 `isTownLevel(levelId)`,其他地方都引用它;删除 5 处内联数组、多余的常量,以及 `slug.includes('town')`; - safe-zone / rebudget 和"帧元组 → `AtlasFrame`"各自抽成一个函数。 5. **分批进行**:先做第 1、2 步(低风险,收益明确),再逐个面板拆分 `WorldPanelsHud`,每拆一个面板单独一个 PR。 ## 验收标准 - [ ] 上表中的每个模块,监听器的注册和移除都对称(或者统一使用 `signal`),并且都有 `dispose()`。 - [ ] 新增测试:创建再 `dispose()` 场景 3 次后,`window` / `document` 上的监听器数量回到基线(可以用包装后的 `addEventListener` 计数)。 - [ ] `src/` 中城镇 ID 的内联数组为 0,只保留一个 `isTownLevel`。 - [ ] `WorldPanelsHud` 拆分后,单个类不超过 800 行。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #526:`SceneController` 的生命周期。 - #520:交易和金币规则移进引擎。 - #528、#529:AudioContext 和纹理的回收。 - #534:`#act` 上重复挂载的 change 处理器。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#533
No description provided.