- 前端
- UI组件
【免费下载链接】ui-grid
UI Grid: a Data Grid
本指南以projects/ui-grid/AGENTS.md为骨架,结合仓库源码与测试,剖析@ornery/ui-grid(Angular 数据网格封装包)的完整架构:它如何把 vanilla<ui-grid-element>自定义元素挂载进 Angular 应用、如何把 Angular 的ng-template通过 slot 机制投影进 Shadow DOM、以及围绕 Zone、变更检测与渲染性能建立的一系列工程约定。读完本文,你将掌握该封装器的生命周期、模板投影六步流程、数据只更新路径与性能关键约束,并能在真实项目中正确使用与排查问题。
包定位:monorepo 中的"薄封装"职责
@ornery/ui-grid是整个 ui-grid monorepo 中面向 Angular 的接入层。它的定位在 AGENTS.md 第一行就已点明——Thin Angular wrapper(薄封装):自身不做渲染、不持有网格状态,只做两件事:
- 挂载来自
@ornery/ui-grid-vanilla的<ui-grid-element>自定义元素; - 把 Angular 的
ng-template视图通过slot 门户系统(slot-based portal system)投影进元素内部。
依赖关系(见 package.json):
@ornery/ui-grid-core(5.0.1):类型、选项与网格核心逻辑;@ornery/ui-grid-vanilla(5.0.1):无框架依赖的自定义元素与渲染;- 运行时 peerDependencies:
@angular/core、@angular/common(均>=17.3.0)与rxjs(^6.5.3 || ^7.4.0)。
官方建议的最小依赖组合为
@ornery/ui-grid搭配 Angular 17.3+,参见 docs/getting-started.md 的安装与最小示例。
Angular 包通过 ng-package.json 以src/public-api.ts为入口用 ng-packagr 构建;public-api.ts 同时 re-export@ornery/ui-grid-core的全部类型,方便 Angular 开发者只依赖一个包即可获得GridOptions、UiGridApi等类型。tsconfig.lib.json通过路径映射@ornery/ui-grid-core、@ornery/ui-grid-vanilla指向源码目录,构建产物与源码依赖通过 tsconfig paths 解耦——这也是 AGENTS.md 中"不要引用 dist/ 路径"约定的落地方式。
构建与测试:先构建两个底层包
仓库中针对该包的构建与测试命令如下:
ng build uiGridPackage # ng-packagr → dist/ui-grid ng test --watch=false # vitest via Angular CLI(48 个测试)关键前置条件:由于@ornery/ui-grid依赖@ornery/ui-grid-core与@ornery/ui-grid-vanilla,必须先构建这两个底层包,否则路径映射解析不到可用产物。测试通过 Angular CLI 驱动 vitest,项目级单测位于 ui-grid.component.spec.ts。
组件设计:Standalone + OnPush + Signal 输入
UiGridComponent的装饰器配置(ui-grid.component.ts)直接体现了三条关键设计决策:
@Component({ selector: 'app-ui-grid', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, schemas: [CUSTOM_ELEMENTS_SCHEMA], styleUrl: './ui-grid.component.scss', host: { ngSkipHydration: 'true' }, template: ``, })| 决策 | 含义 | 源码依据 |
|---|---|---|
| Standalone | 无需 NgModule,直接imports: [UiGridComponent]即可使用 | standalone: true |
CUSTOM_ELEMENTS_SCHEMA | 允许在模板中声明自定义元素(此组件模板本身为空,schema 服务于下游使用者的兼容场景) | schemas: [...] |
| OnPush 变更检测 | 封装器自身渲染工作最小化,"vanilla element 自己负责渲染"是 AGENTS.md 明确说明的职责边界 | changeDetection: ChangeDetectionStrategy.OnPush |
ngSkipHydration | 宿主上跳过 Angular 水合,避免服务端渲染与客户端 DOM 冲突 | host: { ngSkipHydration: 'true' } |
组件的公开接口非常收敛:
- Signal 输入
options = input<GridOptions>({ id, data, columnDefs }),默认值为__ui-grid-pending__占位 id; - 输出
apiReady = output<UiGridApi>(),在 vanilla 控制器创建后把包装后的 API 实例交给应用层。
构造器中用effect()订阅options信号,在elementReady且元素已创建时调用applyOptions()把最新配置推给元素(ui-grid.component.ts)。结合 ui-grid.component.spec.ts 的reacts to options changes测试可以看到:修改options信号并detectChanges()后,gridApi.core.getVisibleRows()的行数随之从 3 变为 1,说明 options 响应链路是真实可验证的。
生命周期:在 zone 之外创建元素
ngAfterViewInit是挂载的核心时序(ui-grid.component.ts):
async ngAfterViewInit(): Promise<void> { if (!isPlatformBrowser(this.platformId)) return; // SSR 下直接跳过 await this.zone.runOutsideAngular(async () => { await defineStandaloneUiGridElement(TAG_NAME); // 惰性注册自定义元素 const el = document.createElement(TAG_NAME) as UiGridStandaloneElement; this.gridElement = el; this.hostRef.nativeElement.appendChild(el); this.applyOptions(this.options()); this.elementReady = true; }); }要点:
- 平台守卫:
isPlatformBrowser(PLATFORM_ID)保证服务端渲染时不执行 DOM 相关逻辑; - zone 外创建:元素创建、options 应用全部包在
zone.runOutsideAngular中——AGENTS.md 明确说明"vanilla element 的 DOM 写入不需要 zone"; - 惰性定义:通过
defineStandaloneUiGridElement注册标签名ui-grid-element(该函数由 vanilla 包导出,注册实现见 ui-grid-standalone.element.ts)。
销毁路径(ngOnDestroy,ui-grid.component.ts)同样完整:先销毁全部 slot 视图、退订 benchmark 转发、移除cellSlotsChanged监听并remove()元素。spec 的cleans up the element on destroy测试断言销毁后ui-grid-element节点从 DOM 中消失。
模板投影流程:cellTemplate 如何进入 Shadow DOM
这是整个封装器最核心的机制,AGENTS.md 将其概括为六步:
- Angular
columnDefs中某列可携带cellTemplate(类型为TemplateRef<GridCellTemplateContext>); applyOptions()提取模板列,在把 options 传给元素前剥离cellTemplate字段(vanilla 元素不认识 Angular 的TemplateRef);- 结构发生变化时调用
el.setFrameworkRenderedSlots({ cells: templateColumnNames }); - 监听 vanilla 元素派发的
cellSlotsChanged(CustomEvent); - 对每个新增 slot:
templateRef.createEmbeddedView(context)→appRef.attachView(view)→ 将根节点挂进 light DOM 中带slot="..."的<span>; - 对每个移除的 slot:detach + destroy 视图。
提取与剥离
applyOptions()中的提取逻辑(ui-grid.component.ts):
if (opts.columnDefs) { for (const col of opts.columnDefs) { if (col.cellTemplate?.createEmbeddedView) { cellSlotColumns.push(col.name); newTemplateColumns.set(col.name, col.cellTemplate); } } }判断"是否模板列"的依据是col.cellTemplate?.createEmbeddedView——这与 core 中GridTemplateRefLike<Context>的类型定义一致(grid.models.ts),它只要求一个结构化的createEmbeddedView方法,从而同时兼容 AngularTemplateRef与其他框架的模板句柄。
随后剥离cellTemplate再传给元素(ui-grid.component.ts):
const cleanedColumnDefs: GridColumnDef[] | undefined = opts.columnDefs?.map((col) => { if (col.cellTemplate?.createEmbeddedView) { const { cellTemplate: _removed, ...rest } = col; return rest as GridColumnDef; } return col; });结构性变化检测与 setFrameworkRenderedSlots
columnsChanged判定采用数组长度 + 逐列名称比较(ui-grid.component.ts):
const columnsChanged = cellSlotColumns.length !== this.currentSlotColumnNames.length || cellSlotColumns.some((name, i) => name !== this.currentSlotColumnNames[i]);仅当columnsChanged为真时才调用setFrameworkRenderedSlots({ cells: cellSlotColumns })——这正是 AGENTS.md"不要在每个 applyOptions 都调用 setFrameworkRenderedSlots,只在列集合变化时调用"约束的实现来源。
该方法的底层实现在 vanilla 元素的 ui-grid-standalone.element.ts:配置经由FrameworkSlotBridge.configure()比较集合是否变化,若变化则清空行级指纹缓存并触发结构性重渲染,把相应渲染位点切换为<slot name="...">占位符。
Slot 命名与增量 diff
vanilla 侧的 slot 命名约定定义在 framework-slots.ts:
cell-<columnName>-<rowId>:每个可见单元格一个 slot;header-<columnName>、filter-<columnName>:每列一个、单例;group-row-<groupId>、expandable-row-<rowId>、empty:分组、展开行、空态。
FrameworkSlotBridge在一次渲染中通过stage*方法记录pending集合,结束时flush()将 pending 与上一次的last做add/remove 增量 diff,再派发*SlotsChanged事件(framework-slots.ts)。封装器只需消费detail.added/detail.removed两个数组即可精确增删视图,而无需在每次渲染时全量重建。
onCellSlotsChanged 处理器:EmbeddedViewRef 的增删管理
该处理器以箭头函数字段保存(private readonly onCellSlotsChanged = ...),保证addEventListener/removeEventListener使用的是同一引用。其行为(ui-grid.component.ts):
- removed 分支:按
slotName查slotViewsMap,appRef.detachView+view.destroy(),并移除 light DOM 中:scope > [slot="..."]节点; - added 分支:
- 从
templateColumns取模板,createEmbeddedView(slot.context)创建视图; appRef.attachView(view)——这一步不可省略,否则视图不会进入 Angular 变更检测队列;- 新建
<span slot="slotName">包装,把viewRef.rootNodes逐个移入,再appendChild到元素 light DOM; - 写入
slotViewsMap(key 为 slotName,值为视图 + 列名 + rowId)。
- 从
一个值得注意的实现细节:added 分支刻意跳过了detectChanges()。源码注释给出了明确原因——视图创建时已携带正确的初始 context,滚动过程中若对每个新增 slot 都做一次detectChanges(),在 10 万行数据下会产生每帧数百次同步变更检测循环;数据真正变化时统一由updateSlotViewContexts()接管(ui-grid.component.ts)。这与 AGENTS.md"不要在 cellSlotsChanged 处理器里包 zone.run()"的告诫同源,都属于对渲染热路径的刻意避让。
Context 从哪来
slot.context的类型是GridCellTemplateContext(grid.models.ts):$implicit(单元格值)、value、row、column、rowIndex。它由 vanilla 渲染路径中的stageCell()构造(framework-slots.ts),值取getCellValue(row.entity, column);core 侧还有对应的buildGridCellContext()(grid.core.display.ts)负责普通(非 slot)路径的 context 构建,两处结构保持一致。
数据只更新路径:原地修补,避免 slot 增删
当options变化但模板列集合未发生结构变化时,applyOptions走updateSlotViewContexts()分支(ui-grid.component.ts):
entry.view.context.$implicit = value; entry.view.context.value = value; entry.view.context.row = row; viewsNeedingCheck.push(entry.view); // ...收集后统一在 zone.run 中批量 detectChanges流程要点:
- 用
rowIdentity语义(代码中按row['id']建 Map)把新数据与既有视图匹配; - 通过
findColumnDef+getNestedValue(支持field: 'account.owner'这类点路径)解析列值与嵌套字段; - 批量变更检测:把所有需要检查的视图收集起来,在
zone.run()中一次性循环detectChanges(),避免滚动过程中零散的同步检测周期——注释明确写道"这能显著提升虚拟滚动期间的性能"。
这条路径让模板单元格在虚拟滚动下"原地刷新 context",而不是频繁地销毁重建视图,是 10 万行场景下保持流畅的关键设计之一。
Zone 约定与"不要做"清单
AGENTS.md 把工程纪律浓缩为两条 zone 约定与五条禁令,均可与源码一一对应:
约定
| 约定 | 源码位置 |
|---|---|
zone.runOutsideAngular包裹 vanilla 元素创建与 options 应用 | ui-grid.component.ts |
zone.run()包裹apiReady派发,确保 Angular 变更检测能感知 | ui-grid.component.ts |
禁令(Do NOT)及对应原因
- 不要在
cellSlotsChanged处理器中包zone.run()——vanilla 在渲染期间派发该事件,Angular 视图的初始创建无需 zone; - 不要引入
@angular/elements——该构建目标已被移除; - 不要引用
dist/路径——使用 tsconfig 路径映射(见 tsconfig.lib.json); - 不要在每个
applyOptions都调用setFrameworkRenderedSlots——仅在列集合结构变化时调用; - 不要跳过
appRef.attachView()——否则创建的视图不会获得变更检测。
API 包装与 benchmark 转发
applyOptions在把 options 赋给元素前,会替换onRegisterApi回调(ui-grid.component.ts):底层 API 经createWrappedGridApi包装后,在zone.run()内通过apiReady.emit()派发,同时透传用户自己的opts.onRegisterApi。
createWrappedGridApi额外做了两件事(ui-grid.component.ts):
- benchmark 事件转发:订阅底层
api.core.on.benchmarkComplete并转发给封装器自己的监听器集合,卸载时退订; - benchmark 包装:把底层
api.core.benchmark(1)循环执行iterations次(默认取opts.benchmark?.iterations ?? 25),汇总为{ iterations, totalMs, averageMs, visibleRows, renderedItems }。
这套设计让 Angular 侧既能拿到完整UiGridApi(排序、筛选、行读取等能力全部透传),又能在 API 实例上直接执行可重复的渲染基准。
实战验证:仓库中的完整使用范式
仓库内的浏览器试验台(browser harness)组件 grid-browser-harness.component.ts 是这套封装器的最佳实践样例:
// 声明模板列 <ng-template #status let-value>...</ng-template> <ng-template #renewalDateCell let-value let-row="row">...</ng-template> // 在 columnDefs 中引用 { name: 'status', cellTemplate: this.statusTemplate() ?? undefined, ... } { name: 'renewalDate', type: 'date', cellTemplate: this.renewalDateTemplate() ?? undefined, ... } // 模板中使用 <app-ui-grid [options]="options()" (apiReady)="onApiReady($event)" />其中options通过computed信号按模式切换(templated/pinning/pagination等),正是 Signal 输入 + effect 响应链路的真实用例;对应测试 grid-browser-harness.component.spec.ts 断言切换templated模式后status列的cellTemplate存在。展开行模板expandableRowTemplate(TemplateRef<GridExpandableTemplateContext>)同样通过 slot 机制工作,参见 spec 中的ExpandableHostComponent(ui-grid.component.spec.ts)。
小结
@ornery/ui-grid的架构核心可以浓缩为一句话:渲染归 vanilla,视图归 Angular,slot 是两者的协议。它通过setFrameworkRenderedSlots+cellSlotsChanged事件完成结构性同步,通过updateSlotViewContexts完成数据性同步,通过 zone 内外分流保证变更检测开销最小化。理解这份薄封装的设计,也就理解了如何在自己的 Angular 应用中安全地组合复杂模板列、展开行与百万级虚拟滚动。
- 前端
- UI组件
【免费下载链接】ui-grid
UI Grid: a Data Grid
相关推荐
UI Grid 框架投影运行时(Framework Projection Runtime)规范详解:统一 Angular / React / Solid 适配器的插槽投影生命周期
UI Grid 框架投影运行时(Framework Projection Runtime)规范详解:统一 Angular / React / Solid 适配器
前端UI组件ui-grid 框架适配层运行时整合:Angular / React / Solid 投影架构统一迁移指南
ui grid 框架适配层运行时整合:Angular / React / Solid 投影架构统一迁移指南 导读 :本文围绕 ui grid 仓库中一项重要的架
前端UI组件Avue.js:基于Element-UI的二次封装框架
Avue.js:基于Element UI的二次封装框架 Avue.js 是一个基于 Vue.js 和 Element UI 的二次封装框架,主要使用 JavaS
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考