- 前端
- UI组件
【免费下载链接】ui-grid
UI Grid: a Data Grid
本文深入解读 ui-grid 开源仓库中
openspec/changes/consolidate-framework-adapter-runtime变更提案所定义的framework-projection-runtime规范。它描述了一套供 Angular、React、SolidJS 三个框架适配器共享的投影生命周期:结构性插槽对账、插槽增删交付、投影上下文刷新、API 回调与基准协调、确定性拆除。读完本文,你将掌握 ui-grid 的插槽命名约定、FrameworkSlotBridge的 add/remove 差分机制、三个 wrapper 当前各自实现投影的异同,以及规范如何将它们收敛为一个由 vanilla 包统一拥有的可复用运行时。
1. 规范背景:为什么需要统一的投影运行时
ui-grid 的三套框架 wrapper(Angular、React、以及新合并的 SolidJS)挂载的是同一个 vanilla 自定义元素<ui-grid-element>,并通过其插槽桥接(slot bridge)投影框架自有的渲染内容。但在此之前,每个 wrapper 都独立实现了以下几套编排逻辑:
- 选项(options)装配与传递;
- 插槽对账(slot reconciliation);
- 投影上下文刷新(context refresh);
- API 包装(API wrapping);
- 拆除清理(teardown)。
正如proposal.md所述,这种重复实现"已经造成能力漂移(capability drift),并让第四个适配器的构建与验证成本变得不必要地高昂"。规范的目标不是替换各框架的原生渲染能力,而是把与框架无关的 DOM 插槽编排抽出来共享,让 Angular 继续用 EmbeddedView、React 继续用 Portal、Solid 继续用其响应式 root,但不再各自重写一套 DOM 生命周期。
从源码结构看,当前 vanilla 包 framework-slots.ts 已经提供了最底层的能力:FrameworkSlotBridge在每次渲染后 diff 出added/removed增量并派发cellSlotsChanged等类型化事件;ui-grid-standalone.element.ts 暴露setFrameworkRenderedSlots()来切换"插槽占位"渲染路径。本规范正是在这一既有桥接之上,进一步统一"谁监听、谁挂载、谁刷新、谁拆除"的上层编排。
2. 规范总览:一个可复用的投影生命周期
规范在Purpose中定义了核心目标:
提供一个可复用的投影生命周期,使投影出的网格内容在各 UI 框架之间保持正确(correct)、高效(efficient)、可释放(disposable)。
规范以 openspec 的"需求 + 场景"格式,新增了5 条需求:
| 需求 | 关键词 | 核心约束 |
|---|---|---|
| Structural slot reconciliation | 结构性插槽对账 | 仅在插槽配置发生结构性变化时才触发插槽布局重渲染;插槽身份不变则保留已挂载的投影内容 |
| Projection lifecycle delivery | 投影生命周期交付 | 新增插槽交付给框架渲染器;移除插槽在 light-DOM 挂载点被丢弃前交付;活跃插槽在 vanilla 元素移除前保持稳定身份 |
| Current projected contexts | 当前投影上下文 | 排序/过滤/分组/分页/编辑/数据替换/虚拟化改变行、值、索引或插槽状态后,刷新活跃投影上下文 |
| API callback and benchmark compatibility | API 回调与基准兼容 | 组合 wrapper 与消费方提供的注册回调而不替换任何一方;保持公开 API 形态;基准完成后才汇报完成 |
| Deterministic teardown | 确定性拆除 | 拆除时解绑事件与 API 监听、释放全部活跃投影、移除框架自有的 light-DOM 挂载点、忽略挂起的投影工作 |
3. 需求一:结构化插槽对账(Structural slot reconciliation)
规范内容:系统必须将"框架声明的插槽配置"与"vanilla 网格元素"进行对账,并且只在该配置发生结构性变化时才请求插槽布局重渲染。当插槽身份保持不变时,必须保留已挂载的投影内容。
这一需求对应两个场景:
- 仅数据更新时保留已投影单元格:当 wrapper 替换数据、而某个可见投影单元格保持相同插槽身份时,该单元格继续挂载并接收到最新上下文。
- 渲染器列被移除时:当 wrapper 从声明的投影插槽集合中移除某列时,系统必须销毁该列的投影内容,并在后续渲染中恢复 vanilla 字符串渲染路径。
3.1 源码对照:configure()的"结构变化判定"
现有 framework-slots.ts 的configure()方法正是这条需求的现状雏形:它把新配置与当前标记集合做Set比较,任何cells/headers/filters集合或groupRow/expandableRow/emptyState布尔值的差异都会返回true(structureChanged),表示需要一次结构重渲染;否则返回false视为 no-op。
元素侧 setFrameworkRenderedSlots() 在收到true时强制进行一次结构重渲染,并清空lastRowStateFingerprints——注释明确说明原因:单元格外壳内容取决于hasCell()/hasHeader()等标记值,而这些值并未被行级指纹捕获。这就保证了"同一插槽身份的数据更新不会触发结构重渲染,列增删才会"。
3.2 数据更新不破坏投影的工程细节
vamilla 渲染路径有一个容易踩坑的细节:当行的指纹与上次渲染一致时,patch快速路径会跳过该行的渲染,若此时flush()直接拿 pending 与 last 做 diff,就会把"未重新登记的单元格"误判为removed,从而摧毁框架 wrapper 已投影的内容。为此FrameworkSlotBridge提供 carryRowCells(),把上一轮该行已知的单元格插槽携带进当前 pending 集合;该调用发生在 patch.ts。而纯数据更新的场景下则走 mergePendingIntoLast(),将新上下文合并进 last 集合而不派发任何事件——因为结构没有变化,只有单元格上下文刷新了。
4. 需求二:投影生命周期交付(Projection lifecycle delivery)
规范内容:系统必须把新增插槽交付给框架渲染器;必须在对应 light-DOM 挂载点被丢弃之前交付移除插槽;并且活跃插槽的身份要保持稳定,直到 vanilla 元素将其移除。
其典型场景是虚拟化行离开视口:
- 当 vanilla 网格在虚拟化过程中移除一个已投影的行插槽时;
- 框架渲染器恰好收到一次针对该插槽的移除通知,从而可以释放框架自有的视图或 root。
4.1 插槽命名约定与类型化增量
framework-slots.ts头部的注释明确定义了插槽命名约定,这也是投影身份的稳定基础:
cell-<columnName>-<rowId>—— 每行每列一个;header-<columnName>—— 每列单例;filter-<columnName>—— 每列单例;group-row-<groupId>—— 每个分组;expandable-row-<rowId>—— 每行;empty—— 单例。
FrameworkSlotDelta<TSlot>以{ added, removed }形式承载每个插槽族的变更,flush()(framework-slots.ts)在每次渲染结束时对六种插槽逐一 diff 并派发cellSlotsChanged、headerSlotsChanged、filterSlotsChanged、groupRowSlotsChanged、expandableRowSlotsChanged、emptyStateSlotChanged事件。disconnectedCallback中调用 flushRemovals() 让所有剩余插槽以removed形式交付——这正是"移除先于挂载点丢弃"的既有实现。
4.2 虚拟化滚动下的生命周期
虚拟化快速路径 renderVirtualBody() 只替换.grid-virtual-body的 innerHTML 并在末尾调用frameworkSlots.flush()。滚出视口的行对应的cell-<col>-<rowId>插槽从 pending 中消失,于是差分结果为removed,wrapper 收到单次移除并可销毁对应视图。规范要求运行时把这一机制统一为"每个插槽族的类型化生命周期",让 Solid 的 root、React 的 portal、Angular 的 EmbeddedView 都能在同一契约下处置。
5. 需求三:当前投影上下文刷新(Current projected contexts)
规范内容:当排序、过滤、分组、分页、编辑、数据替换或虚拟化改变了活跃投影内容所代表的行、值、索引或插槽状态时,系统必须在一次完整的网格渲染完成后刷新活跃投影上下文。
这条需求要解决一个根本局限:插槽 delta 事件无法表达"仅上下文变化"的更新。design.md 明确指出:"deltas alone do not identify every context-only update"。
两个场景说明了边界:
- 排序保留插槽名:排序改变了占据某个既有投影单元格插槽的可见行,但没有产生插槽 add/remove 增量——投影单元格仍须收到排序渲染完成后的行、值与行索引。
- 编辑更新展开详情:某行带有活跃投影详情(detail)时提交一次编辑——详情须收到该行提交后的值,而不必触发一次无关的网格更新。
5.1 现有 wrapper 如何自行处理"上下文刷新"
由于 vanilla delta 事件覆盖不到上下文更新,三个 wrapper 现在各自想办法:
- React:在 UiGrid.tsx 中用
useEffect([options.data])监听数据变化,按rowId重建Map<rowId, row>,对每个已挂载插槽重新计算value并setSlots(nextSlots),再通过 Portal 重新渲染。 - Angular:在 ui-grid.component.ts 的
updateSlotViewContexts()中更新EmbeddedViewRef的$implicit/value/row上下文,并把多个视图的detectChanges()批量放进一次NgZone.run(),注释特别说明这是为避免虚拟化滚动时每帧触发数百次同步变更检测。 - Solid:在 cellRenderers.ts 的
refresh()中通过api.core.getVisibleRows()获取当前可见行,重新计算 value 并调用entry.update(context);UiGrid.tsx的wrapApi()还为refresh、setFilter、sortColumn等 API 操作包上refreshAfter(),保证操作后刷新。
5.2 规范方案:post-render 投影同步边界
design.md 的第四项决策提出:vanilla 元素在 DOM patch 与 framework-slot flush 之后,暴露一个投影渲染完成通知(projection-render completion notification)。运行时将该信号合并(coalesce),然后从当前 API/options 状态读取活跃描述符,再要求驱动(driver)更新。该信号仅在配置了框架投影时才发出,刷新工作按渲染批次合并执行。这补上了 delta 事件表达不了的"排序后同一插槽换行、编辑后详情更新"等场景。
6. 需求四:API 回调与基准兼容(API callback and benchmark compatibility)
规范内容:系统必须组合(compose)wrapper 自有与消费方提供的 API 注册回调,而不是用一方替换另一方;必须保持公开网格 API 形态;并且必须在所需的投影渲染**安定(settled)**之后才报告基准完成。
两个场景:
- 两个注册回调同时存在:当 wrapper 回调与
options.onRegisterApi都提供了时,二者都收到同一个注册对应的兼容 API。 - 基准包含投影渲染:当基准在带有活跃框架投影的网格上运行时,其结果必须在框架完成该轮基准迭代触发的渲染工作之后才交付。
6.1 现有实现的回调组合方式
三个 wrapper 当前都已经在做回调组合:
- React applyOptions() 中
onRegisterApi先调用onRegisterApiRef.current?.(wrappedApi)再调用opts.onRegisterApi?.(wrappedApi); - Angular applyOptions() 同时
emit(apiReady)并调用opts.onRegisterApi; - Solid registerApi() 会把 wrapped API 分发给 cell/header/detail/editor 四个 bridge,再调用
props.onRegisterApi与props.options.onRegisterApi。
6.2 基准完成等待投影渲染
三个 wrapper 都重写了core.benchmark,把"每次迭代等待投影渲染安定"内嵌进去:
- React 通过
waitForScheduledWrapperRender()与useLayoutEffect中的版本计数等待 scheduled wrapper render 完成(UiGrid.tsx); - Angular 每次迭代后
await Promise.resolve()再继续(ui-grid.component.ts); - Solid 每次迭代后调用各 bridge 的
refresh()并await Promise.resolve()(UiGrid.tsx)。
规范将这三种机制统一抽象为驱动提供的"view settled"承诺:React 等待 commit、Angular 等待批量的变更检测、Solid 等待其调度渲染工作,从而既保持单一公开 API 外观,又把框架调度留在了框架本地。
7. 需求五:确定性拆除(Deterministic teardown)
规范内容:wrapper 或网格拆除后,系统必须解绑元素与 API 监听、释放所有活跃投影内容、移除框架自有的 light-DOM 挂载点,并忽略挂起的投影工作。
关键场景是延迟挂载期间卸载:
- 当 wrapper 在延迟的元素或编辑器投影工作执行前卸载时;
- 拆除后不得再挂载任何投影内容,也不得残留任何监听器或框架 root。
7.1 现有拆除路径
- vanilla 元素:
disconnectedCallback中依次unsubscribe、断开模板观察器、取消 rAF 帧、置空基准处理器、flushRemovals()派发全部移除事件(ui-grid-standalone.element.ts)。 - React:mount effect 的 cleanup 设置
disposed = true、解绑cellSlotsChanged、移除元素、清空 slots 状态,并在卸载createWrappedGridApi时取消底层基准订阅(UiGrid.tsx)。 - Angular:
ngOnDestroy调用destroyAllSlotViews()(appRef.detachView+view.destroy()并移除[slot]节点)、解绑基准订阅与事件监听、移除元素(ui-grid.component.ts)。 - Solid:
onCleanup置disposed = true、逐桥 dispose、清空基准监听、移除元素(UiGrid.tsx)。
规范把这些散布在各 wrapper 的清理逻辑收敛到运行时,并显式要求"忽略挂起的投影工作"——例如 Solid 的configureVersion微任务校验(if (disposed || version !== configureVersion) return)这种异步竞态防护,将成为运行时统一的默认行为。
8. 设计决策:共享运行时放在哪里、如何分层
design.md 记录了五项关键决策,直接决定了规范的实现形态:
- 共享运行时放在 vanilla 包:新增
@ornery/ui-grid-vanilla的一个 DOM 面向 helper 子路径,而不是第四个子包。理由:它依赖具体自定义元素契约及其插槽事件,且已是每个 wrapper 的既有依赖,可避免新包、新版本与构建顺序问题。其模块初始化不得访问浏览器全局对象,SSR 敏感的 wrapper 继续只在客户端生命周期中加载 DOM 行为。 - 编排与渲染分离:运行时负责请求的插槽配置、事件监听、稳定插槽记录、light-DOM 挂载点、API 注册组合、刷新合并与释放;框架提供的**投影驱动(projection driver)**负责创建、更新、释放某个插槽的框架自有内容。这保留了 Angular 原生变更检测、React commit 与 Solid 所有权,而不是在它们之上再做一层渲染抽象。
- 类型化插槽族 + 单一驱动形态:cell / header / detail / filter / group / empty-state 作为类型化家族,运行时统一其 add/remove 生命周期;各框架驱动只声明支持的家族。这样 React 与 Angular 可先迁移现有 cell 支持,Solid 保留其额外能力,未来 wrapper 渐进接入。
- post-render 投影同步边界:如前文需求三所述。
- 集中 API 与基准协调:运行时组合
onRegisterApi回调并接管底层基准订阅的替换;驱动提供 "view settled" 承诺。
9. 迁移计划与风险权衡
tasks.md 将实施拆为四个阶段:
- 基线与会约:构建 cells/headers/details/editors/filters/empty state/SSR-hydration/virtualization/benchmark/teardown 的 wrapper 奇偶校验矩阵;在支持的 Node 版本上复现并解决 Solid hydration 基线(验证
npm run build:solid && npm run test:solid);定义类型化投影驱动与插槽族接口(要求 TypeScript 拒绝不兼容的插槽/上下文配对)。 - 共享 vanilla 运行时:添加副作用无关的子路径与公开类型导出(验证消费方构建在未定义自定义元素时也能 import);实现结构化插槽对账、挂载点归属与监听清理;实现 post-render 同步边界与合并的活跃上下文刷新;实现共享 API 注册与基准协调;为每个插槽族、虚拟化移除、异步取消与拆除添加 conformance 测试。
- Wrapper 迁移:React cell portals 迁入共享运行时(不改
UiGridprops);Angular 模板 cell 投影迁移(不改app-ui-grid输入输出);Solid cell/header/detail/editor bridge 迁移到投影驱动;仅删除被共享运行时取代的 wrapper 本地编排。 - 奇偶校验与发布验证:三框架共用同一场景矩阵;决定并记录 React/Angular 的 header/detail/editor/filter/group/empty 支持顺序——不支持的家族要可预测地失败,而不是静默渲染过期内容;运行
npm run test:angular、npm run test:react、npm run test:vanilla、npm run test:solid及按依赖顺序的包构建。
风险与权衡(design.md 第五节)给出了四条防护:驱动保持小巧、类型化并在各 wrapper 内直接单测,运行时单独测试;post-render 刷新按完成渲染合并、只刷新被渲染视口界定的活跃插槽;vanilla 子路径保持导入时无副作用、DOM 操作只进客户端生命周期钩子;增量迁移期间以奇偶校验矩阵约束,一个 wrapper 一个 wrapper 地迁移。回滚被限制为回退单个 wrapper 的迁移——直到迁移完成前,现有 wrapper 本地桥接仍可用。
10. 如何验证与进一步阅读
规范从设计到落地,可以通过以下仓库文件追踪:
- 规范本体:framework-projection-runtime/spec.md、动机与能力清单见 proposal.md、设计决策见 design.md、任务分解见 tasks.md。
- 现状插槽桥接:vanilla 包 framework-slots.ts 与 ui-grid-standalone.element.ts(
setFrameworkRenderedSlots、frameworkSlots.flush()集成点),以及 patch.ts 中的carryRowCells/mergePendingIntoLast快速路径。 - 三框架现状实现:React wrapper(portal + benchmark 等待)、Angular wrapper(EmbeddedView + 批量变更检测)、Solid wrapper 与 cellRenderers.ts(响应式 root + refresh 桥)。
- 架构说明:vanilla 包 AGENTS.md 的 "Framework Slot System" 一节描述了当前
setFrameworkRenderedSlots → slot placeholder → flush → 事件 → wrapper 挂载的四步工作流,可作为理解规范的入口。
需要说明的是,本文所解析的 spec 属于 openspec spec-driven 变更提案,其 tasks 清单尚未勾选,即共享运行时属于计划中的架构演进而非已落地代码;文中标注的源码均来自迁移前的现有实现,用于印证规范所描述的问题与既有机制。
- 前端
- UI组件
【免费下载链接】ui-grid
UI Grid: a Data Grid
相关推荐
Frosted Glass Lite 主题:树莓派仪表盘帧率从 15 提到 30 FPS 的方法
Frosted Glass Lite 主题:树莓派仪表盘帧率从 15 提到 30 FPS 的方法 周日晚,你在树莓派 4B 上打开 Home Assistant
前端UI组件Elsa User Tasks 运行时契约解析:身份中立、书签投影与任务生命周期编排
Elsa User Tasks 运行时契约解析:身份中立、书签投影与任务生命周期编排 本文围绕 Elsa(The Workflow Engine for .NE
后端工作流自动化流程编排低代码Open Interpreter Windows完整安装指南:4步让开源代码智能体跑进终端
Open Interpreter Windows完整安装指南:4步让开源代码智能体跑进终端 Open Interpreter 是一个面向 Kimi K3、GLM
人工智能大模型AI Agent代码智能体AI 应用CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考