Skip to content

议会的三路分流

背景:SlideMenu 每次 reRender 全量重建 innerHTML,20+ 面板重复销毁重建。

过程:拆为增量渲染三路分流,跳过不必要重置,只 patch 变化部分。


一、汇报

外交官站在议会的讲台上,面前摊开一本账。

"诸位," 他说,"我要说的话可能不太好听。"

议长坐在最高的座位上,双手交叠。审判长靠在后排的阴影里。织工放下了手中的布料。连调度员都从主控室探出头来。

"我们一直在拆房子," 外交官说,"每次有人敲门,我们就推倒整栋楼重建。"

沉默。

"模型库打开一次——innerHTML = ''。设置页开关切换一次——innerHTML = ''。环境菜单点一个预设——innerHTML = ''。我们不是在盖房子,我们是在用'摧毁并重建'来替代'开门'。"

议长开口了:"那你有什么提议?"

"让我改议会本身。"


二、三路分流

外交官在白板上画了三条路。

"以前的路只有一条:reRender() 永远调用 buildPanel()buildPanel() 永远执行 this.panel.innerHTML = ''。不管改动有多小——哪怕只是切换一个开关——整块面板从头来过。"

"现在我要拆成三条路。"

他画了一个三角形。

第一条路:reRenderCustom

"有些面板知道自己在做什么。" 外交官说,"性能模式的面板——点击'平衡',它知道只需把'质量优先'的 check-circle 图标改成 circle,把'平衡'的 circle 改成 check-circle。它不需要重建整个面板。"

"设置页的主题色面板——用户选中'樱花粉',它知道只需找到 data-theme-color#f74a6c 的行,更新它的图标和 class。其余 5 个主题色行纹丝不动。"

他把这叫做"精准手术刀"。

第二条路:patchPanel

"有些面板纯粹由标准行组成——items 数组 + createRow()。这些面板也不需要全量重建。它们的每一行都有一个 data-row-key——相当于每栋房子的门牌号。"

"当 reRender() 被调用时,patchPanel 会做四件事:"

外交官竖起四根手指:

  1. 删除多余的行(如果列表变短了)
  2. 逐行比较门牌号(data-row-key
  3. 门牌号变了 → 替换这行
  4. 门牌号没变 → 跳过(连 DOM 都不碰)

他把这叫做"按门牌号送信"。

第三条路:buildPanel

"剩下的,那些有 renderCustom 但没有 reRenderCustom 的面板——它们确实需要全量重建。但它们是少数。"

外交官合上笔记本。

"这就是三路分流。每个 reRender() 自己判断走哪条路。不需要调用方操心,不需要改任何一个点击事件。"


三、织工的测试用例

织工站了起来。

"我举个例子," 他说,"设置页的'性能模式'。"

他展开一卷羊皮纸,上面画着四行选项:

◯ 自动     — 监控帧率,自动降低质量
◯ 质量优先 — 最高质量,不自动降级
◯ 平衡     — 中等质量,适合大多数设备
◯ 性能优先 — 最低质量,确保流畅

"以前,选'平衡':"

织工做了个"摧毁"的手势:

"innerHTML='' → 四行消失 → 四行重新创建 → DOM 重建 → 事件重新绑定 → 全部。"

"现在,选'平衡':"

他平静地说:

"找到 [data-perf-key="balanced"] → 更新 class 为 slide-item slide-focused → 更新 iconify-icon 的 icon 属性从 lucide:circlelucide:check-circle → 结束。"

"其他三行?碰都不碰。"

议长微微点头。


四、意外

改革并非一帆风顺。

环境菜单的"天空预设"点击后,面板直接空白了。

外交官赶到现场时,织工已经在 patchPanel 函数里加了断点。

"问题在这," 织工指着代码说:

typescript
// 从后往前删除多余的行
for (let i = oldChildren.length - 1; i >= items.length; i--) {
    oldChildren[i].remove();
}

"items 是空数组——items.length === 0。所以这个循环从 oldChildren.length - 1 删到了 0。全部删光。"

"然后 reRenderCustom 被调用了——但环境根面板的 reRenderCustom 是空函数,因为预设按钮没有视觉状态需要更新。"

"结果就是:按钮还在,但它们的 DOM 容器被 patchPanel 误杀了。"

外交官修正了 patchPanel

typescript
if (items.length === 0) return; // 空 items → 啥也别碰

"零长度的 items 意味着不需要 patch," 他说,"保留现有的所有子节点。让 reRenderCustom 决定怎么更新。"


五、影子副本

还有一件事。

"我们以前每次打开菜单,都创建一个全新的 SlideMenu 实例," 织工说,"dispose() + new SlideMenu() + reset()。这不是改个面板——这是杀掉整届议会,换一拨新人。"

他摊开一份审计报告:

操作dispose 做了什么代价
清除 keydown 监听移除键盘事件重新注册
清除 transitionend移除动画回调重新绑定
levels = []清空导航栈全部丢失
_cachedExtraBtns = null丢弃缓存的额外按钮重建

"关键是 dom.sceneOverlay.innerHTML = ''。这一行把整个议会大厅夷为平地——包括议会的桌子和椅子。"

外交官提出了解决方案:

为每个菜单保留一个持久化的影子副本。

"模型弹窗一个包装器(wrapper),环境菜单一个包装器,设置页一个包装器。它们都在 dom.sceneOverlay 里,但一次只显示一个。切换菜单类型时,把上一个的 display 设为 none,把下一个设为空。"

织工在 config.ts 里加了三条函数:

typescript
const _menuWrapperRegistry = new Map<string, HTMLElement>();

function getMenuWrapper(menuId: string): HTMLElement { ... }
function disposeMenuWrapper(menuId: string): void { ... }
function clearAllMenuWrappers(): void { ... }

"第一次打开模型弹窗——创建 model-popup 包装器,在里面建议会。第二次打开——议会还在原地,直接 reRender()。"

"跨菜单切换——隐藏旧的包装器,显示新的。零次 dispose,零次 innerHTML=''。"


六、大图

当天结束时,外交官在白板上画了最终架构图:

showModelPopup()
  └── 首次:create wrapper → new SlideMenu → reset → buildPanel
  └── 后续:reRender()
         ├── reRenderCustom 存在 → 精确 patch(20 面板)
         ├── 纯 items 面板      → patchPanel 逐行 diff
         └── 复杂 renderCustom  → buildPanel 全量重建(少数)

织工站在一旁,看着白板上的三条线。

"所以," 他说,"我们不再是每次敲门就拆房子了。"

"不拆了。" 外交官说。

"房子还在,只是开关一下灯。"

沉默了一秒。

"这很好。" 织工说。

他转身走向布料机。

"明年这时候," 他头也不回地说,"可能连灯都不用开关了——灯自己知道什么时候亮。"


尾注:技术债务的数字

当天修改的文件:

文件改动
frontend/src/core/config.tsPopupLevel.reRenderCustom 类型 + getMenuWrapper() 包装器管理
frontend/src/menus/menu.tsreRender() 三路分流 + patchPanel() 增量方法 + updateRow() + refreshHeader()
frontend/src/menus/settings.ts4 面板添加 reRenderCustom
frontend/src/menus/env-menu.ts4 面板添加 reRenderCustom
frontend/src/menus/env-feature-levels.ts3 面板添加 reRenderCustom
frontend/src/menus/scene-camera-levels.ts1 面板添加 reRenderCustom
frontend/src/menus/scene-procmotion-levels.ts1 面板添加 reRenderCustom
frontend/src/menus/scene-render-levels.ts2 面板添加 reRenderCustom
frontend/src/menus/model-detail.ts1 面板添加 reRenderCustom
frontend/src/menus/motion-popup.ts2 面板添加 reRenderCustom + wrapper 缓存
frontend/src/menus/motion-cloth-levels.ts1 面板添加 reRenderCustom
frontend/src/menus/library-core.tswrapper 缓存
frontend/src/menus/scene-menu.tswrapper 缓存
总计20 面板 + 5 菜单缓存

从"每次交互推倒整栋楼"到"只修墙面裂缝",联邦议会完成了一次静默的架构革命。

没有发版公告,没有用户感知。

但在代码的世界里,一切都不一样了。