Skip to content

议会的黑板

背景:菜单控件增长到 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 函数脆弱性低一个数量级。值变化用更新,结构变化用重建——分清楚这两件事,架构就清晰了。