☰
@ornery/ui-grid Angular 封装器架构解析:从 Custom Element 到 ng-template 的 Slot 投影管道
2026/10/13 2:45:02 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】ui-grid

UI Grid: a Data Grid

项目地址:https://gitcode.com/gh_mirrors/ui/ui-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(薄封装):自身不做渲染、不持有网格状态,只做两件事:

  1. 挂载来自@ornery/ui-grid-vanilla的<ui-grid-element>自定义元素;
  2. 把 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 将其概括为六步:

  1. AngularcolumnDefs中某列可携带cellTemplate(类型为TemplateRef<GridCellTemplateContext>);
  2. applyOptions()提取模板列,在把 options 传给元素前剥离cellTemplate字段(vanilla 元素不认识 Angular 的TemplateRef);
  3. 结构发生变化时调用el.setFrameworkRenderedSlots({ cells: templateColumnNames });
  4. 监听 vanilla 元素派发的cellSlotsChanged(CustomEvent);
  5. 对每个新增 slot:templateRef.createEmbeddedView(context)→appRef.attachView(view)→ 将根节点挂进 light DOM 中带slot="..."的<span>;
  6. 对每个移除的 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):

  1. removed 分支:按slotName查slotViewsMap,appRef.detachView+view.destroy(),并移除 light DOM 中:scope > [slot="..."]节点;
  2. 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

流程要点:

  1. 用rowIdentity语义(代码中按row['id']建 Map)把新数据与既有视图匹配;
  2. 通过findColumnDef+getNestedValue(支持field: 'account.owner'这类点路径)解析列值与嵌套字段;
  3. 批量变更检测:把所有需要检查的视图收集起来,在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)及对应原因

  1. 不要在cellSlotsChanged处理器中包zone.run()——vanilla 在渲染期间派发该事件,Angular 视图的初始创建无需 zone;
  2. 不要引入@angular/elements——该构建目标已被移除;
  3. 不要引用dist/路径——使用 tsconfig 路径映射(见 tsconfig.lib.json);
  4. 不要在每个applyOptions都调用setFrameworkRenderedSlots——仅在列集合结构变化时调用;
  5. 不要跳过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

项目地址:https://gitcode.com/gh_mirrors/ui/ui-grid
点击查看免费下载
上一篇:如何永久保存微信聊天记录:WeChatMsg完整数据留痕终极指南
下一篇:如何永久保存微信聊天记录:WeChatMsg完整数据留痕解决方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询