17 KiB
static-renderer 协调逻辑拆分设计
日期:2026-06-17
状态:待用户复核
分支:spark/static-renderer-coordination-split(基于 main f8a834d)
路线:B —— 抽出 Controller,并把渲染侧按职责拆到位
目的
上一轮"图谱六层架构"重构(已合并进 main)把图谱的决策权收敛到了正确的模块:手势归 gestures.ts、坐标归 viewport.ts/geometry.ts、命中归 spatial-index.ts/hit-testing.ts、状态归 state.ts,画图零件也拆成了 nodes.ts/edges.ts/community-washes.ts/minimap.ts 等独立模块。
但有一块没收尾:packages/graph-engine/src/render/static-renderer.ts 仍有 2481 行,同时承担了四种不同职责——组装、调度指挥、画图编排、对外接口。它名叫"renderer"(渲染器),实际却是事实上的"总指挥"。这是六层设计原本想达到、但没做到的一块(设计原文要求"GraphRenderer 只负责画图")。
本设计的目标:把这块"既指挥又画图"的混合体,按职责拆成边界清楚的模块,让"做决定的"和"画图的"彻底分开,且以后新增交互时不会再把逻辑塞回画图层。
这不是性能优化。当前图谱性能良好(见下方性能基线)。这是纯粹的代码结构整理,不改变任何用户可见行为。
现状(事实,带证据)
static-renderer.ts(2481 行)内部混着四类东西:
- 组装:
createStaticGraphRenderer()创建 root 元素、gestures 控制器、runtime state、hit resolver、pin state、simulation、resize observer,并把它们接线。 - 调度指挥:
applyGestureIntents()把手势意图派发给handleNodeClick/handleNodeDragStart/handleNodeDragMove/handleNodeDragEnd/handleNodeDragCancel/handleBlankClick;以及语义命令selectCommunity/focusCommunity/resetViewState/retreatFocusedView/openSearch/applySearchQuery/closeSearch/clearInteractionState;还有键盘意图路由handleDocumentKeydown。 - 画图编排:
render()(重建渲染模型→重绘→挂控件→提交相机→画 overlay→重启模拟)、paint()(把画图零件拼成 DOM 树)、mountSearchControl/mountGraphToolbar/mountCommunityLegend、commitViewport/updateMinimapViewport/updateEffectiveDensity、diff 动画markDiffElements/settleDiffElements/animateDiff。 - hover / 阅读器 / 选择面板:
scheduleHoverPreview/showEdgeHoverPreview/clearHoverPreview/renderHoverPreview/positionHoverPreview/positionEdgeHoverPreview/renderReader/renderSelectionPanel。 - 对外接口:返回的对象(
render/applyDiff/setData/setTheme/setPins/focusNode/focusCommunity/resetView/select/clearSelection/clearInteraction/resetLayout/destroy),由facade.ts包装成公开 API。
三个必须正视的结构现实(自审时对回代码确认,决定了拆分怎么做才不返工):
- 调度函数会直接操作 DOM 元素:如
dom.nodeElements.get(id)?.focus()、.classList.add("is-dragging")、社区 hover 高亮遍历dom.nodeElements。这些是"交互附着物",不是画图——所以铁律必须区分"画图"与"附着",而不是简单说"调度不碰 DOM"。 render()是三合一:它同时"把传入的改动写进 state(setPins/setFocus/setSelection/setPositions)+ 重建渲染模型(buildRenderableGraph/new PinState/hitTargetResolver.refresh)+ 绘制(paint/mount*)"。拆分时要把"写状态"和"重建并画"分开,不能整块塞给 render-pipeline 还说它"只画图"。- 存在共享可变状态:
graph、pinState、dom、simulation(以及data/theme/typeFilters)现在是闭包变量,被调度和画图两边读写、还会被render()重新赋值。拆成多文件后,这些必须变成一个显式的"共享渲染上下文"对象(见下节),否则模块间无法干净协作。
基线(实测):单元测试 265 个全部通过;密集图(200 节点 / 231 边)连续缩放稳定约 50.5 fps。
核心原则(这是"不会再返工"的关键)
- 铁律(精确版,已对回真实代码):
- Controller 不构建、不绘制图:不调用
paint/mount*、不创建节点/边/色块 DOM、不计算渲染模型、不做布局。 - Controller 允许在已经画好的元素上切换"交互附着物":给节点加/去
is-dragging、设置focus、hover 高亮。这类是"对交互的附着",不是"画图"。(真实代码里这些操作本就存在,硬禁会一上手就违规——见"现状"第 2 点。) - Render-pipeline / Overlays-presenter 只重建模型并绘制:不判断手势意义、不决定 selection/focus/pin 等语义。
- Controller 不构建、不绘制图:不调用
- 停手线:模块就定下面这几个,每个对应一个明确职责。拆到这个粒度就停手,不再为了凑文件数继续切碎。这是成熟标准——按职责拆到位,既不留 god 文件,也不拆成 confetti 碎片。
- 纯搬家:本次只移动和重组代码,不改逻辑、不改行为。任何"顺手优化"都不在范围内。
- 共享状态集中:
graph/pinState/dom/simulation等"连接组织"集中放进一个"共享渲染上下文",由组装根持有,传给各模块;不让任意模块各自藏一份平行状态(详见下节)。
目标模块结构
gestures.ts / viewport.ts / state.ts / spatial-index.ts / hit-testing.ts 以及所有画图零件模块(nodes.ts / edges.ts / community-washes.ts / minimap.ts / controls.ts / offline-reader.ts / hover-card.ts / toolbar.ts / legend.ts / search.ts / preview.ts)保持不动。本次只新增/重组下面的协调与编排层:
| 模块(文件) | 职责(一句话) | 拥有的代表函数 | 允许依赖 | 禁止 |
|---|---|---|---|---|
controller.ts(新) |
总指挥:把手势意图和 API 命令翻译成"该改什么状态/模拟/相机,然后请求重画" | applyGestureIntents、handleNode*、handleBlankClick、语义命令(select/focus/reset/search/clear)、键盘意图路由、node-drag 计算 helper |
state、viewport、gestures、simulation-bridge、node-drag-lifecycle、hit-testing、keyboard |
创建/绘制图 DOM、调用 paint/mount*、计算渲染模型(允许在已画元素上切换 is-dragging/focus 等交互附着物) |
render-pipeline.ts(新) |
画图编排:照当前状态把图重绘出来 | render、paint、mount*、commitViewport、update*、diff 动画、restartSimulation/applyMotionFrame 等绘制编排 |
所有画图零件模块、state(只读)、viewport(投影) |
判断手势意义;写入交互决策 |
overlays-presenter.ts(新) |
hover 卡片 / 边预览 / 阅读器 / 选择面板的"贴位置 + 显示" | scheduleHoverPreview、showEdgeHoverPreview、clearHoverPreview、renderHoverPreview、position*Preview、renderReader、renderSelectionPanel |
overlays.ts(锚点)、preview.ts、hover-card.ts、viewport、state(只读) |
判断手势意义;决定状态 |
graph-renderer-root.ts(由 static-renderer.ts 改名瘦身) |
组装根:创建上述模块、接线,返回供 facade 包装的引擎对象 | createGraphRenderer()(原 createStaticGraphRenderer)、destroy、对外方法的委派 |
上述所有协调/编排模块 | 自己实现调度或画图细节(只做组装与委派) |
说明:controller.ts 同时拥有"手势触发的处理"和"API 命令(如 focusCommunity/resetView)",因为它们是同一职责——"对一个动作做出决定"。把它们合在一个模块,正是为了守住"停手线",不再过度拆分。
共享渲染上下文(本设计最关键、最容易做错的一块)
调度和画图不是"井水不犯河水",它们之间有一坨连接组织:graph(当前渲染模型)、pinState(固定状态)、dom(已画出的元素引用)、simulation(力导模拟)、以及 data/theme/typeFilters 等当前输入。现在它们是 static-renderer.ts 里的闭包变量,谁都能直接读写。
拆成多文件后,必须把它们收进一个显式的共享上下文对象(暂名 GraphRenderContext),规则:
- 由组装根(
graph-renderer-root.ts)创建并持有这个上下文,传给 controller、render-pipeline、overlays-presenter。 - 上下文里"会被重建/重新赋值"的字段(
graph、pinState、dom)通过上下文对象的方法或属性更新,只有 render-pipeline 在重建时写它们;controller 和 presenter 只读。 runtimeState(已有的 state 模块)仍是 selection/focus/hover/pins/viewport 的唯一权威;上下文不复制这些,只持有"渲染产物"(graph/dom)和"运行期协作对象"(pinState/simulation)。render()拆成两步:(a) 应用传入改动到 state(归 controller / 根的命令侧)+ (b) 重建模型并绘制(归 render-pipeline,写回上下文的 graph/dom/pinState)。
这一节是整份设计的重点。若不先定清楚共享上下文,"按职责拆"会在落地时卡住或被迫返工——这正是要避免的。具体字段和方法签名在实现计划阶段定稿。
数据流
拖动节点:
你拖节点
-> gestures 翻译成"拖拽意图"
-> controller 决定:更新该节点位置、固定它、改 state、通知 simulation
-> controller 请求重画
-> render-pipeline 照新状态把节点画到新位置
-> overlays-presenter 把 hover 卡片贴回正确锚点
API 命令(如宿主调用 focusCommunity):
facade.focusCommunity(id)
-> graph-renderer-root 委派给 controller
-> controller 改 focus state、请求重画
-> render-pipeline 重绘聚焦视图
每一层只干自己那段;出问题能立刻定位是"指挥错了"还是"画错了"。
命名决策
- 文件
render/static-renderer.ts→render/graph-renderer-root.ts(它以后只管组装,名字应说明这一点)。 - 对外函数
createStaticGraphRenderer→createGraphRenderer("static" 是早期遗留词,已无意义)。 - 影响面(已精确核对):
facade.ts(import + 调用)、render/index.ts(re-exportcreateStaticGraphRenderer与类型StaticGraphRenderer,39/40 行)、architecture.ts(entrypoints 写死路径,43 行)、renderer-boundary.test.ts(按路径字符串读文件,117/124 行),外加文件自身。(注:sim/index.ts/sim/pins.ts并不引用它,之前的宽松 grep 误命中,已剔除。) - 注意:其中两处不是"测试自动接住",而是必须手动改——
renderer-boundary.test.ts按路径字符串读render/static-renderer.ts,architecture.ts的entrypoints也写死了该路径。改名必须同步更新这两处,否则测试会因找不到文件而失败。 architecture.ts中renderer层的entrypoints需相应更新;新增controller层条目(见下)。
architecture.ts 调整:当前六层 owner map 把 static-renderer.ts 归在 renderer 层。重组后:
renderer层 entrypoints 指向render-pipeline.ts、overlays-presenter.ts及画图零件;不再把组装/调度算作 renderer。- 视实现情况,将"调度"明确为
gestures层的下游协调,或在 owner map 中体现controller.ts的归属(实现时确定:要么并入gestures层描述,要么新增一层条目)。原则是 owner map 必须如实反映"谁做决定、谁画图"。
迁移计划(一次搬一块,每块跑全套测试才提交)
沿用上一条分支的纪律:每个工作单元完成并验证通过才提交,红了不提交,不自动 push/merge/amend。
- Phase 0 基线:跑全套单元测试 + 4 套浏览器回归 + 帧率测试,记录"改之前是绿的、约 50fps"。不写功能代码。
- Phase 1 建立共享渲染上下文 + 抽 Controller:先把
graph/pinState/dom/simulation等闭包变量收进显式的GraphRenderContext,由根持有;再把调度/命令/键盘路由搬进controller.ts(通过上下文只读这些、用 state 模块改语义状态、用现有dom引用切换交互附着物)。跑测试。 - Phase 2 抽 Render-pipeline:把
render()拆成"应用改动到 state"与"重建模型并绘制"两步,后者连同paint()/挂控件/相机提交/diff 动画搬进render-pipeline.ts,并由它写回上下文的graph/dom/pinState。跑测试。 - Phase 3 抽 Overlays-presenter:把 hover/边预览/阅读器/选择面板搬进
overlays-presenter.ts。跑测试。 - Phase 4 收尾与改名:
static-renderer.ts缩成薄组装根并改名为graph-renderer-root.ts;函数改名createGraphRenderer;更新引用与architecture.ts;补充清晰注释。跑测试。 - Phase 5 边界测试与总验收:新增/扩展边界测试锁死铁律;跑完整验收命令集 + 帧率对比基线。
测试与安全策略
- 安全网:现有 265 个单元测试 + 4 套浏览器回归(workbench / offline / community-wash / stage-4.5 perf)每个 phase 都必须全绿。因为是纯搬家,"行为不变"由这些既有测试保证。
- 性能门槛:收尾重跑 stage-4.5 密集图帧率测试,fps 必须 ≥ 基线(约 50fps,允许 10% 波动);低于则不通过。
- 新增边界测试(锁死铁律,防止以后回潮):
controller.ts不引用画图零件模块(nodes/edges/community-washes/minimap等的create*)、不调用paint/mount*、不计算渲染模型;但允许通过上下文的dom切换is-dragging/focus等交互附着物(这是被明确允许的,不算违规)。render-pipeline.ts/overlays-presenter.ts不调用 gesture 分类、不写 selection/focus/pin 等语义决策。- 尽量包含一个运行时探针(仿照现有
renderer-boundary.test.ts),不只做源码正则扫描。
- 进度记录从简:上一轮"每个代码提交都配一个进度记账提交"过重。本次按 phase 记一次即可,不要求逐提交记账。
性能基线(实测)
| 场景 | 数据 | 结果 |
|---|---|---|
| 密集图连续缩放 | 200 节点 / 231 边,桌面 1440×960,采样 3 秒 | 50.5 fps(空闲基线 12.6 仅因无动画时不刷帧,属正常) |
来源:tests/graph-browser-stage-4-5.regression-1.sh --target offline。
不在范围内
- 不改任何用户可见行为。
- 不做性能优化(当前不卡)。
- 不动画图零件模块、
gestures.ts、viewport.ts、state.ts、spatial-index.ts。 - 不动
facade.ts的公开 API 形状(只更新它对内部函数的引用名)。 - 不引入新依赖、新测试框架。
- 不顺手重构无关代码。
- 不切 WebGL / 不改图谱数据结构 / 不改知识库 markdown。
- 不为了凑文件数把模块继续拆碎(守"停手线")。
风险与缓解
| 风险 | 缓解 |
|---|---|
| 搬家时漏接一根线导致行为变化 | 一次只搬一块,每块跑全套测试;红了不提交 |
| 画图搬错导致视觉/帧率退化 | stage-4.5 帧率门槛 + 浏览器回归把关 |
| 改名牵动多处引用 | 影响面已知(约 6 处);其中 2 处需手动改(boundary test 的路径字符串 + architecture.ts 的 entrypoints),其余由编译/测试兜底;改名集中在 Phase 4 一次做完 |
| 以后又把调度塞回画图层 | 新增边界测试 + owner map 如实更新 |
| 模块边界划得不够干净(仍互相伸手) | 铁律 + 边界测试强制"只读状态 / 请求重画"的交互方式 |
验收标准
static-renderer.ts已改名为graph-renderer-root.ts,且只剩组装与委派,不含调度决策或画图编排细节。controller.ts/render-pipeline.ts/overlays-presenter.ts三个模块各自职责单一、边界清楚。- 265 个单元测试 + 4 套浏览器回归全绿。
- 密集图帧率 ≥ 基线(约 50fps)。
- 新边界测试通过,证明 Controller 不画图、Render-pipeline/Overlays 不做决定。
architecture.tsowner map 如实反映新结构(含路径/entrypoints 更新)。- 共享渲染上下文
GraphRenderContext已建立:graph/pinState/dom只由 render-pipeline 写,controller/presenter 只读;render()已拆成"应用改动到 state"与"重建并绘制"两步。 - 全程行为零变化(由既有测试保证)。
开放问题(实现时确定,不阻塞本设计)
controller.ts在architecture.ts里是并入gestures层描述,还是新增独立层条目。- 语义命令(select/focus/reset/search)全部归
controller.ts,还是其中纯 UI 面板开关(如 toolbar 折叠)留在render-pipeline.ts——以"是否构成交互决策"为判据,实现时按铁律归位。