Skip to content

所见即所得

背景:模型行点击行为不一致、收藏星标和相机图标不实时更新。

过程:数据与视图同步机制统一 + 新增/聚焦按钮拆分 + getLevel/setLevel API。


桌面壳最近在烦恼一件事。

"我骗用户。"它说。

MenuStack 正在清点自己的层级数,听到这话停下手:"什么东西?"

"收藏。用户点了一下那个 ☆,后端 ToggleFavorite 确实把 libraryRef 写进了 config.json。但前端——我——什么反应都没有。星标不变。计数不变。就好像什么都没发生过。"

MenuStack 想了想:"那就是什么都没发生过。在用户眼里。"


桌面壳翻出自己身上那套"旧"逻辑,追溯到 buildModelDetailLevel

这个函数是模型详情页的构造函数。每次调用它,它会问 favorites.has(libRef)——此刻模型收藏了没有?然后根据答案,返回一行 "☆ 收藏""★ 取消收藏"

"这很对。"桌面壳盯着代码说。

"错就错在这里。"它自己反驳自己。

它顺着调用链往下走:用户点击 ☆ → onItemClickToggleFavorite → 数据变了 → modelStack.reRender()

reRender() 是议会引以为傲的全量刷新机制——清除所有浮层,用存储的 PopupLevel 数组重建 DOM。

"问题就在这里。"桌面壳把光标戳在 reRender 那行代码上。

"我重建 DOM,用的却是旧数据。"

buildModelDetailLevel 只在用户进入详情页时调用一次。返回的 PopupLevel 里,items 数组写死了 label: "☆ 收藏"reRender 照着这张旧地图重建界面——地图标着"☆",领土已经变成"★"了。

"议会,你的 reRender 是重新画墙,不是重新测量。"桌面壳说。


MenuStack 沉默了很久。

"你说得对,"它终于承认,"我的 levels 数组只存结果,不存公式。保存的时候是什么数据,刷新的时候就是什么数据。它不会自己再算一遍。"

"那你怎么不存公式?"桌面壳问。

"因为性能。每层都存一个工厂函数,每次 reRender 都重新执行一遍——深度导航的时候卡死你。"

"那就给我一个改数据的入口。"

"什么入口?"

"让我能替换某一层的数据。不是全量重建,是精准换掉某一层的地图。"


MenuStack 在它的宪法里加了三行 API:

getLevel(index) → PopupLevel | undefined
setLevel(index, level) → void
levelCount → number

"第一行让外界读我的层。第二行让外界换我的层。第三行让外界知道我有多深。"

"我不喜欢暴露内部。"它说。

"我也不喜欢数据对不上。"桌面壳说。

它们对视了三秒钟。这是议会第一次承认:为了准确,可以牺牲封装。


桌面壳拿着新 API 回去修 detail:fav 的 bug。

以前:

typescript
modelStack?.reRender();

现在:

typescript
if (modelStack) {
  // 更新根层的收藏计数
  const root = modelStack.getLevel(0);
  if (root) {
    const favFolder = root.items.find(i => i.target === "__favorites__");
    if (favFolder) {
      favFolder.sublabel = favorites.size > 0
        ? `${favorites.size} 个模型`
        : "暂无收藏";
    }
  }
  // 替换详情层为全新数据
  const detailIdx = modelStack.levelCount - 1;
  modelStack.setLevel(detailIdx, buildModelDetailLevel(id));
  modelStack.reRender();
}

"你在改议会的数据?"StartFileServer 从端口二十三探头。

"我在让地图追上领土。"桌面壳说。


但它没有停。

它想起刚才跟 MenuStack 的对话里提过另一个症状——相机模式切换后,✓ 不挪窝

它打开 scene-menu.ts,看到 buildCameraLevel 在做一模一样的事:

typescript
const currentMode = getCameraMode();
return {
  label: "相机模式",
  items: [
    { label: "轨道", icon: currentMode === "orbit" ? "check" : "circle" },
    { label: "自由飞行", icon: currentMode === "freefly" ? "check" : "circle" },
    ...
  ],
};

"一模一样。算一次,用永远。议会那套 reRender 照着旧快照画界面,✓ 当然不动。"

它用同样的 API 修了:

typescript
if (sceneStack) {
  sceneStack.setLevel(sceneStack.levelCount - 1, buildCameraLevel());
  sceneStack.reRender();
}

这次 ✓ 动了。


"两个 bug,一个根因。"桌面壳对 MenuStack 说。

"我身上的所有 renderCustomitems 数组——只要里面出现了条件判断——都是在创建时快照的。"

"什么意思?"

"意思是你写了 currentMode === "orbit" ? "check" : "circle" 或者 favorites.has(libRef) ? "★" : "☆",这个三元表达式只在 buildCameraLevelbuildModelDetailLevelonFolderEnter 调用时执行一次。之后不管怎么 reRender,它都不重算了。"

"所以条件渲染在议会里是单次的。"

"对。"桌面壳说。"要么你不要状态依赖,要么你每次状态变了手动替换层。"

"我没有第三个选项。"

"没有。"


但修完状态时隐时现的 bug 后,桌面壳发现了一个更深的问题。

它翻出三天前自己改的另一段代码——模型行的点击行为

以前点击行是"新增一个模型到场景"。后来改成了"替换当前焦点模型"。还在行右边加了个「+」按钮,点它才是"新增"。

"这其实跟收藏/相机是同一个道理。"桌面壳自言自语。

"什么道理?"

"用户看到一个按钮,以为它做 A,它做 B。收藏的 ☆ 点了没反应,是用户看不到状态的变化。📄 按钮点了进详情——用户以为它只是聚焦模型。"

它打开 menu.ts,看着「📄」按钮的点击事件:

typescript
detailBtn.addEventListener("click", (e) => {
  e.stopPropagation();
  const next = this.onFolderEnter?.(row, this);
  if (next) this.push(next);
});

"这跟用户直接点行有什么区别?行也是 onFolderEnter,📄 也是 onFolderEnter。两个按钮做同一件事。"

"那用户为什么要点 📄?"

"因为他们以为 📄 是别的功能。"桌面壳说,"他们以为 📄 是'聚焦这个模型'。不是'进入详情页'。"


桌面壳在 PopupRow 上加了一个可选字段:onDetailClick

"如果行设置了 onDetailClick,📄 按钮就执行它,不执行 onFolderEnter。如果没有设置——回退到老行为。"

showPopup 里,所有已加载模型行的 📄 按钮变成了:

typescript
onDetailClick: () => {
  setFocusedModelId(id);
  focusModel(id);
  setStatus(`🎯 聚焦: ${inst.name}`, true);
};

"这才对。"桌面壳说。"📄 是聚焦,行是详情,+ 是新增。三个按钮,三个行为,互不打架。"


那天晚上,桌面壳坐回终端前,看着今天改完的所有代码。

17 个文件修改。3 个 TypeScript 编译全部通过。0 个运行时警告。

它改了:

  1. 模型行的点击行为——「替换」取代「新增」,因为用户习惯把点击理解成"换一个"
  2. 新增「+」按钮——给需要"再多一个"的用户留了出口
  3. 「📄」按钮——从"进详情"改成"聚焦模型",因为用户想快速在场景里跳转
  4. 收藏的 ☆/★ 状态——现在点了立刻变
  5. 相机模式的 ✓ 图标——现在切换了立刻动

前三条是关于行为的语义——让每个操作对应一个明确的预期。 后两条是关于状态的可见性——让每个变化呈现在界面上。

"联邦'行为地图'的更新频率比我想象的高。"它说。

"你又画了新地图。"MenuStack 说。

"是更正。旧地图标的'这里有一座山',实际是一片湖。我只不过把山的图标换成湖了。"


联邦地图上多了一行注脚。

不是新的城邦入伙。不是新的能力收编。不是新的边界。

旧地图的修正声明

以下地标与实际地理不符:

  • 📄 按钮标注为"详情入口",实际为"聚焦入口"
  • 收藏星标标注为"静态装饰",实际为"可交互状态"
  • 相机模式切换标注为"瞬间生效",实际为"状态残留"

"这不算收编。"桌面壳说。

"那算什么?"

"算地图质量委员会的第一条决议。不修边界,修标注。不添能力,修误解。"

MenuStack 沉默了一拍。

"等你成立第二条决议的时候,叫我。"


教训:地图不是领土,但必须追上领土。