Appearance
议会的三路分流
背景: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 会做四件事:"
外交官竖起四根手指:
- 删除多余的行(如果列表变短了)
- 逐行比较门牌号(
data-row-key) - 门牌号变了 → 替换这行
- 门牌号没变 → 跳过(连 DOM 都不碰)
他把这叫做"按门牌号送信"。
第三条路:buildPanel
"剩下的,那些有 renderCustom 但没有 reRenderCustom 的面板——它们确实需要全量重建。但它们是少数。"
外交官合上笔记本。
"这就是三路分流。每个 reRender() 自己判断走哪条路。不需要调用方操心,不需要改任何一个点击事件。"
三、织工的测试用例
织工站了起来。
"我举个例子," 他说,"设置页的'性能模式'。"
他展开一卷羊皮纸,上面画着四行选项:
◯ 自动 — 监控帧率,自动降低质量
◯ 质量优先 — 最高质量,不自动降级
◯ 平衡 — 中等质量,适合大多数设备
◯ 性能优先 — 最低质量,确保流畅"以前,选'平衡':"
织工做了个"摧毁"的手势:
"innerHTML='' → 四行消失 → 四行重新创建 → DOM 重建 → 事件重新绑定 → 全部。"
"现在,选'平衡':"
他平静地说:
"找到 [data-perf-key="balanced"] → 更新 class 为 slide-item slide-focused → 更新 iconify-icon 的 icon 属性从 lucide:circle 到 lucide: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.ts | PopupLevel.reRenderCustom 类型 + getMenuWrapper() 包装器管理 |
frontend/src/menus/menu.ts | reRender() 三路分流 + patchPanel() 增量方法 + updateRow() + refreshHeader() |
frontend/src/menus/settings.ts | 4 面板添加 reRenderCustom |
frontend/src/menus/env-menu.ts | 4 面板添加 reRenderCustom |
frontend/src/menus/env-feature-levels.ts | 3 面板添加 reRenderCustom |
frontend/src/menus/scene-camera-levels.ts | 1 面板添加 reRenderCustom |
frontend/src/menus/scene-procmotion-levels.ts | 1 面板添加 reRenderCustom |
frontend/src/menus/scene-render-levels.ts | 2 面板添加 reRenderCustom |
frontend/src/menus/model-detail.ts | 1 面板添加 reRenderCustom |
frontend/src/menus/motion-popup.ts | 2 面板添加 reRenderCustom + wrapper 缓存 |
frontend/src/menus/motion-cloth-levels.ts | 1 面板添加 reRenderCustom |
frontend/src/menus/library-core.ts | wrapper 缓存 |
frontend/src/menus/scene-menu.ts | wrapper 缓存 |
| 总计 | 20 面板 + 5 菜单缓存 |
从"每次交互推倒整栋楼"到"只修墙面裂缝",联邦议会完成了一次静默的架构革命。
没有发版公告,没有用户感知。
但在代码的世界里,一切都不一样了。