Appearance
议会的黑板
背景:菜单控件增长到 120+ 后,"状态 + 手动 reRender + reRenderCustom" 模式维护成本爆发——改一个控件要改三处,全量重建导致焦点丢失。
过程:三层渐进响应式——L1 控件自更新注册表、L2 标准控件 bind 自动、L3 Proxy 拦截自动触发。增量刷新替代全量重建。
"这个大厅,每次有人发言就拆了重建。"
外交官和 AI 站在议会大厅里。大厅里坐满了人——确切地说,一百二十多个人。
"拆了重建?" AI 问,"每次?"
"每次。"外交官指着天花板,"有人站起来说一句'我觉得亮度太暗了'——轰,整个大厅推倒,重新盖一个一模一样的。"
"那刚才发言的那个人呢?他坐哪儿?"
"他本来坐在第三排第五个位置,重建之后……"外交官耸耸肩,"系统会尽量把他放回原来的位置。但如果重建的过程中有什么偏差,他可能就坐到第四排去了。或者干脆没座位了。"
"那他不就"
"他就消失了。发言权也没了。所以每次有人发言之后,议会都要重新点一遍名。"
AI 沉默了五秒钟。
"这……听起来不太对。"
"是不对,"外交官笑了笑,"但它就是这么运行的。"
三处
他们走到一个座位的旁边。座位上有一块铭牌,写着"开关·环境光"。
"'开关·环境光'要做一件事,"外交官指了指铭牌,"要把它加到议会里来,需要动三个地方。"
"第一个地方——创建。你得在这里建一个座位,摆好开关按钮。"
"第二个地方——回调。有人按了开关,环境光变了,你得告诉议会:'环境光变了,请大家重新发言。'"
"第三个地方——自定义更新。因为有些座位的显示逻辑不是标准的开关状态——比如'灯光列表'里的每一行,根据灯光的启用状态改变透明度。你得专门写一段代码,告诉议会这个座位要怎么更新自己。"
他顿了顿。
"第三个地方最脆弱。因为那段代码里写的是'取第一行,取第一列……'——如果有人在前面加了一行,所有的行号都对不上了。代码静静失效,没人知道。用户只知道某个开关显示不对。"
"改一个东西要动三处——这就是 120 个控件时的状态。"
第一层:每人的小黑板
"怎么治?" AI 问。
"第一件事——让每个座位自己知道自己怎么更新。"
外交官在桌上画了一张草图:
"每个座位带一块小黑板。议会有一个广播系统——'全体注意,环境光变了,请更新你们的小黑板。'收到广播之后,每个座位自己写自己的黑板。"
"开关节目的黑板:看看当前状态是开还是关,把按钮翻到对应位置。"
"滑条的黑板:看看当前值是多少,把滑块移到对应位置。"
"不用拆大厅了?"
"不用拆了。只是每个人更新一下自己面前的那块小板子。微调,不是重建。"
"听起来很简单。"
"写代码的时候也不复杂。每个控件在创建时注册一个 update() 函数,议会统一调用。"
外交官写了个简化的版本:
typescript
// 议会新建——控件注册表
class SlideMenu {
private controls: Array<{ update: () => void }> = [];
registerControl(update: () => void): void {
this.controls.push({ update });
}
updateControls(): void {
for (const c of this.controls) c.update();
}
}
// 创建座位时自动注册
function addSwitchRow(label, onChange) {
const menu = getCurrentRenderingMenu();
const el = createSwitchElement(label);
menu?.registerControl(() => {
el.checked = getCurrentState();
});
return el;
}"'开关·环境光'的创建和更新逻辑写在一起了,"AI 说。
"对。原来分散在三处的东西,现在集中在一处。"
第二层:标准板书
"那标准化的问题怎么解决?"
"什么意思?"
"A1 说,"开关'和'滑条'和'模式选择器'的更新方式其实是一样的——都是'取最新值,显示在对应位置'。为什么每个控件都要自己写一遍?"
"好问题,"外交官点头,"所以第二层——标准控件自动更新。"
他指着几种常见的座位类型:
"开关、滑条、模式选择器、颜色滑条——这些元件的'更新方式'是固定的。开关就是翻转勾选状态,滑条就是移动滑块位置。不需要每个人自己写黑板怎么画——议会知道这类座位该怎么更新。"
"只需要告诉议会:你的值从哪来。"
typescript
// 创建滑条时声明 bind——值从哪来
addSliderRow('亮度', value, onChange, {
bind: () => envState.brightness // 值从 envState.brightness 取
});"每次 updateControls() 的时候,控件内部重新调 bind() 取最新值,和上一次缓存的值比较——有变化才更新 DOM。"
"没变化就不更新?"
"没变化就不更新。同一个值赋值两次,DOM 不动。因为 JavaScript 里 === 比较一下就知道有没有变化,比 DOM 操作快三个数量级。"
"那特殊座位呢?"
"特殊座位——灯光列表、预设芯片组——它们走 onUpdate 手写更新逻辑。"
typescript
addPresetChip(group, light.name, false, onClick, {
onUpdate: (btn) => {
btn.classList.toggle('active', currentRes === value);
btn.style.opacity = light.enabled ? '1' : '0.5';
}
});"80% 的座位用 bind 自动更新,20% 的复杂座位用 onUpdate 手写。"
AI 想了想:"那谁调 updateControls()?每次状态变了,还是要有人广播吧?"
"对。广播这件事,是第三层解决的。"
第三层:有传感器的椅子
他们走到议会大厅的最前面,主席台旁边有一把普通的椅子。
"你认识这把椅子吗?"外交官问。
"这是……议会主席的椅子?"
"准确地说,这是 envState——议会的主席。主席说什么,议会就做什么。"
"那这把椅子有什么特别的?"
"这把椅子有传感器。"
外交官在椅子旁边蹲下来,指了指椅腿:
"主席一坐下——envState.brightness = 0.8——传感器就捕捉到了。自动触发广播:'全体注意,议会主席说了句话。'不用任何人手动按喇叭。"
"怎么做到的?"
"Proxy。"
他写了一段很短的代码:
typescript
const reactiveEnv = new Proxy(envState, {
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver);
scheduleRefresh(); // 自动广播
return true;
}
});"每次给 reactiveEnv.brightness 赋值——不管赋什么值——都会触发 scheduleRefresh()。同一帧内多次赋值,只广播一次。"
"RAF 去抖?"
"对。多次赋值合并到下一次渲染帧,不会重复广播。"
AI 看着那把椅子:"那其他座位呢?也有传感器吗?"
"不一定。"外交官指了指另一把椅子,"renderState——这把椅子不是直接坐上去的。你得通过 getRenderState() 从 Babylon.js 管道实时读数据。给它装传感器需要重构整个读写链路,不划算。"
"所以你在 setRenderState 函数入口手动调了 scheduleRefresh()?"
"对。手动按喇叭。不是全自动,但够用了。"
什么时候还拆房子?
"所以这个大厅不再拆了?"AI 问。
"大部分时候不拆了。"外交官拍了拍一根柱子,"但有时候还是要——比如加一排新座位。"
"或者换一种座位排列方式——'天空模式'从 procedural 切到 color,子控件集合完全变了。"
"或者删除一个座位时。"
"对。只要 DOM 结构变了——增、删、改结构——就必须全量重建。"
他总结:
值变了 →
updateControls()(增量更新,不重建)结构变了 →
reRender()(全量重建)
"那现在呢?"
"现在?"外交官看了看大厅,一百二十多个座位安安静静地在那里,每个人面前有一块小黑板,"现在大部分人发言的时候,大厅不用塌了。只有要加新座位的时候才重建。"
"那以前呢?"
"以前每次发言都塌。所以大家的发言稿都写得特别短——因为一发言大厅就塌,塌了就重建,重建完了刚才说到哪都忘了。"
AI 看了看那块写着"开关·环境光"的铭牌:
"原来那个开关每次关了之后,它的'开关状态'是记住了……但它的'座位位置'没有?"
"对。状态在,座位不在了。重建之后得重新找座位。"
"现在不用了?"
"现在不用了。"外交官笑了,"现在开关只是自己翻个面,不用搬椅子。"
成果
"修完之后怎么样了?"
"删了大概三百行 reRenderCustom 代码,"外交官说,"移除了 46 处手动 updateControls() 调用。之前新增一个控件要改三处——创建、回调、reRenderCustom——现在只要在一处加个 bind。"
"而且不容易出 bug 了——控件自管自己的显示,不用去 querySelector 选别人的 DOM。"
"那旧系统还有人在用吗?"
"reRender 没删,"外交官说,"结构变化的时候还是要靠它。但 80% 的情况已经不需要了。值变化让控件自己翻面,结构变化才拆大厅。"
"听起来很优雅。"
"听起来很简单,"外交官说,"但 120 个控件的时候,增量更新比全量重建难十倍。不是因为技术复杂——是因为你得想清楚'谁负责更新什么'。"
"现在想清楚了?"
"现在想清楚了。每个人负责自己的黑板。"
教训:控件多了之后,全量重建的成本指数增长。增量更新的难点不是技术——是想清楚"谁负责更新什么"。让控件自管自己的显示,比集中式的 render 函数脆弱性低一个数量级。值变化用更新,结构变化用重建——分清楚这两件事,架构就清晰了。