AG Grid 源码分析:虚拟滚动与行渲染性能优化
AG Grid 虚拟滚动与行渲染性能优化深度分析
基于源码版本:AG Grid Community Edition
分析文件:rowRenderer.ts、rowCtrl.ts、rowComp.ts、rowContainerHeightService.ts、gridBodyScrollFeature.ts、fakeVScrollComp.ts、fakeHScrollComp.ts
目录
1. 核心架构概览
1.1 类职责划分
┌─────────────────────────────────────────────────────────────┐
│ RowRenderer (行渲染器) │
│ - 管理所有 RowCtrl 的生命周期 │
│ - 计算可见区域 (firstRenderedRow, lastRenderedRow) │
│ - 行的创建、销毁、回收、缓存 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ RowCtrl (行控制器) │
│ - 单行状态管理 (数据、样式、选中、焦点) │
│ - 单元格控制器 (CellCtrl) 的创建与管理 │
│ - 动画效果控制 (slideIn, fadeIn) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ RowComp (行组件) │
│ - 行 DOM 元素管理 │
│ - 单元格组件 (CellComp) 的挂载与卸载 │
│ - CSS 类切换、样式更新 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ GridBodyScrollFeature (滚动特性) │
│ - 滚动事件监听与分发 │
│ - 虚拟滚动与真实滚动同步 │
│ - 滚动防抖与节流 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ RowContainerHeightService (高度服务) │
│ - 解决浏览器最大 DIV 高度限制 │
│ - 动态缩放行容器高度 │
│ - 滚动位置映射 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ FakeVScrollComp / FakeHScrollComp │
│ - 独立于数据容器的滚动条 │
│ - 与真实滚动位置同步 │
│ - 提供一致的滚动体验 │
└─────────────────────────────────────────────────────────────┘
1.2 数据流向
用户滚动 → GridBodyScrollFeature.onVScroll()
│
▼
RowRenderer.redraw()
│
├→ workOutFirstAndLastRowsToRender() // 计算新的可见范围
│
├→ recycleRows() // 回收不可见行,创建新可见行
│
└→ dispatchDisplayedRowsChanged() // 通知 UI 更新
2. 虚拟滚动实现原理
2.1 可见区域计算
核心方法 workOutFirstAndLastRowsToRender():
// 位置: rowRenderer.ts
private workOutFirstAndLastRowsToRender(): void {
// 1. 获取缓冲区像素(上下额外渲染的行数)
const bufferPixels = this.getRowBufferInPixels();
// 2. 获取滚动位置
const bodyVRange = scrollFeature.getVScrollPosition();
const bodyTopPixel = bodyVRange.top;
const bodyBottomPixel = bodyVRange.bottom;
// 3. 计算渲染范围(考虑缓冲区)
firstPixel = Math.max(bodyTopPixel + paginationOffset - bufferPixels, pageFirstPixel);
lastPixel = Math.min(bodyBottomPixel + paginationOffset + bufferPixels, pageLastPixel);
// 4. 像素转行索引
let firstRowIndex = rowModel.getRowIndexAtPixel(firstPixel);
let lastRowIndex = rowModel.getRowIndexAtPixel(lastPixel);
// 5. 安全限制(防止一次渲染过多行)
if (newLast - newFirst > rowBufferMaxSize) {
newLast = newFirst + rowBufferMaxSize;
}
}
关键设计点:
-
缓冲区机制:
- 通过
rowBuffer属性控制上下额外渲染的行数 - 默认值通常是 20 行,可通过
gridOptions.rowBuffer配置 - 目的:滚动时提前渲染,减少空白闪烁
- 通过
-
高度变化检测循环:
do {
// 计算范围...
rowHeightsChanged = this.ensureAllRowsInRangeHaveHeightsCalculated(firstPixel, lastPixel);
} while (rowHeightsChanged);
这个循环处理动态行高场景(如 autoHeight),确保所有需要渲染的行都有正确的高度计算。
2.2 行的回收与复用
核心方法 recycleRows():
private recycleRows(rowsToRecycle?: { [key: string]: RowCtrl } | null, animate = false, afterScroll = false) {
// 1. 计算需要绘制的所有行索引
const indexesToDraw = this.calculateIndexesToDraw(rowsToRecycle);
// 2. 移除不需要绘制的行
this.removeRowCompsNotToDraw(indexesToDraw, !animate);
// 3. 创建或更新需要绘制的行
for (const rowIndex of indexesToDraw) {
this.createOrUpdateRowCtrl(rowIndex, rowsToRecycle, animate, afterScroll);
}
// 4. 销毁未复用的旧行(延迟执行以支持动画)
if (rowsToRecycle) {
this.destroyRowCtrls(rowsToRecycle, animate);
}
}
复用逻辑详解:
private createOrUpdateRowCtrl(rowIndex: number, rowsToRecycle: {...} | null): void {
// 优先从现有 map 中查找
let rowCtrl = this.rowCtrlsByRowIndex[rowIndex];
// 如果不存在,尝试从回收站中按 rowNode.id 查找
if (!rowCtrl && rowNode && rowsToRecycle[rowNode.id]) {
rowCtrl = rowsToRecycle[rowNode.id]; // 复用!
rowsToRecycle[rowNode.id] = null;
}
// 如果还是找不到,创建新的
if (!rowCtrl) {
rowCtrl = this.createRowCon(rowNode, animate, afterScroll);
}
this.rowCtrlsByRowIndex[rowIndex] = rowCtrl;
}
特殊处理 - 聚焦和编辑中的行:
private doNotUnVirtualiseRow(rowCtrl: RowCtrl): boolean {
// 编辑中的行不能被移除,否则会丢失编辑状态
const rowIsEditing = this.editSvc?.isEditing(rowCtrl);
// 焦点行不能被移除,否则键盘操作会失效
const rowHasFocus = this.focusSvc.isRowFocused(rowNode.rowIndex!, rowNode.rowPinned);
// Master-Detail 的详情行不能被移除,避免丢失用户操作上下文
const rowIsDetail = rowNode.detail;
return rowHasFocus || rowIsEditing || rowIsDetail;
}
3. 行的生命周期管理
3.1 RowCtrl 状态机
┌────────────┐ createRowCon() ┌────────────┐
│ Not Exist │ ──────────────────▶ │ Active │
└────────────┘ └────────────┘
│
destroyFirstPass()│
▼
┌────────────┐
│ Zombie │ (等待动画完成)
└────────────┘
│
destroySecondPass()│
▼
┌────────────┐
│ Destroyed │
└────────────┘
两阶段销毁:
// 第一阶段:逻辑销毁,可选动画
public destroyFirstPass(suppressAnimation: boolean = false): void {
this.active = false;
// 如果启用动画且行仍在视口内,执行滑出动画
if (!suppressAnimation && _isAnimateRows(this.gos) && !rowNode.sticky) {
if (rowStillVisibleJustNotInViewport) {
// 滑动到视口边缘位置,而不是直接消失
const rowTop = this.roundRowTopToBounds(rowNode.rowTop!);
this.setRowTop(rowTop);
} else {
// 淡出效果
gui.rowComp.toggleCss('ag-opacity-zero', true);
}
}
// 派发 virtualRowRemoved 事件
this.beans.eventSvc.dispatchEvent(event);
}
// 第二阶段:DOM 销毁
public destroySecondPass(): void {
this.allRowGuis.length = 0;
// 销毁所有单元格控制器
this.centerCellCtrls = destroyCellCtrls(this.centerCellCtrls);
this.leftCellCtrls = destroyCellCtrls(this.leftCellCtrls);
this.rightCellCtrls = destroyCellCtrls(this.rightCellCtrls);
}
为什么需要两阶段:
- 动画需要时间执行(400ms 超时)
- 动画期间行仍需存在于 DOM 中
- 避免动画和 DOM 操作冲突
3.2 RowComp 的 DOM 管理
class RowComp extends Component {
private setCellCtrls(cellCtrls: CellCtrl[]): void {
const cellsToRemove = new Map(this.cellComps);
// 1. 复用已存在的单元格
for (const cellCtrl of cellCtrls) {
if (!this.cellComps.has(key)) {
this.newCellComp(cellCtrl); // 创建新的
} else {
cellsToRemove.delete(key); // 标记为保留
}
}
// 2. 销毁不再需要的单元格
this.destroyCells(cellsToRemove);
// 3. 确保正确的 DOM 顺序
this.ensureDomOrder(cellCtrls);
}
}
DOM 顺序优化:
private ensureDomOrder(cellCtrls: CellCtrl[]): void {
if (!this.domOrder) return;
// 按列顺序收集 DOM 元素
const elementsInOrder: HTMLElement[] = [];
for (const cellCtrl of cellCtrls) {
const cellComp = this.cellComps.get(cellCtrl.instanceId);
if (cellComp) {
elementsInOrder.push(cellComp.getGui());
}
}
// 一次性重新排序
_setDomChildOrder(this.getGui(), elementsInOrder);
}
4. DOM 操作优化策略
4.1 批量更新
CSS 类批量切换:
// rowCtrl.ts
protected getInitialRowClasses(rowContainerType: RowContainerType): string[] {
const classes: string[] = [];
// 一次性计算所有需要的类
classes.push('ag-row');
classes.push(this.rowFocused ? 'ag-row-focus' : 'ag-row-no-focus');
classes.push(rowNode.rowIndex! % 2 === 0 ? 'ag-row-even' : 'ag-row-odd');
// ... 更多类
// 批量应用
for (const name of initialRowClasses) {
comp.toggleCss(name, true);
}
}
样式批量应用:
// rowComp.ts
setUserStyles: (styles: RowStyle | undefined) => _addStylesToElement(rowDiv, styles)
// 一次性添加多个样式,而不是逐个设置
4.2 最小化 Reflow/Repaint
1. 缓存 DOM 查询结果:
// gridBodyScrollFeature.ts
public getVScrollPosition(): VerticalScrollPosition {
// 如果位置未失效,返回缓存值
if (!this.isVerticalPositionInvalidated) {
return {
top: this.lastScrollTop,
bottom: this.lastScrollTop + this.lastOffsetHeight,
};
}
// 只在必要时读取 DOM
const { scrollTop, offsetHeight } = this.eBodyViewport;
this.lastScrollTop = scrollTop;
this.lastOffsetHeight = offsetHeight;
this.isVerticalPositionInvalidated = false;
return { top: scrollTop, bottom: scrollTop + offsetHeight };
}
2. 使用 CSS Transform 代替 Top:
// rowCtrl.ts
public getInitialTransform(rowContainerType: RowContainerType): string | undefined {
return this.suppressRowTransform
? undefined
: `translateY(${this.getInitialRowTopShared(rowContainerType)})`;
}
private setRowTopStyle(topPx: string): void {
for (const gui of this.allRowGuis) {
if (this.suppressRowTransform) {
gui.rowComp.setTop(topPx); // 传统方式
} else {
gui.rowComp.setTransform(`translateY(${topPx})`); // 优化方式
}
}
}
为什么 Transform 更快:
top改变会触发 Layout → Paint → Compositetransform只触发 Composite(GPU 加速)- 性能差距在大量行时非常明显
3. 批量读取,避免 Layout Thrashing:
// 使用 _batchCall 批量执行 DOM 写操作
_batchCall(() => {
this.onTopChanged();
});
4.3 事件委托
// rowRenderer.ts
// 不在每个单元格上注册事件,而是在 RowRenderer 统一管理
private registerCellEventListeners(): void {
this.addManagedEventListeners({
cellFocused: (event) => this.onCellFocusChanged(event),
columnHoverChanged: () => {
// 遍历所有单元格,而不是让每个单元格自己监听
for (const cellCtrl of this.getAllCellCtrls()) {
cellCtrl.onColumnHover();
}
},
// ...
});
}
原因:
- 1000 行 × 10 列 = 10000 个单元格
- 每个单元格注册 10 个事件 = 100000 个监听器
- 改为集中管理后只有几十个监听器
5. 滚动性能优化
5.1 requestAnimationFrame 集成
// gridBodyScrollFeature.ts
private onVScroll(source: VerticalScrollSource): void {
// ... 计算滚动位置
if (animationFrameSvc?.active) {
// 如果启用了 AnimationFrame,调度到下一帧
animationFrameSvc.schedule();
} else {
// 否则立即处理
this.scrollGridIfNeeded(true);
}
}
public scrollGridIfNeeded(suppressedAnimationFrame: boolean = false): boolean {
const frameNeeded = this.scrollTop != this.nextScrollTop;
if (frameNeeded) {
this.scrollTop = this.nextScrollTop;
this.redrawRowsAfterScroll();
}
return frameNeeded;
}
RowAnimationFrameService 工作原理:
// 伪代码,展示核心思想
class AnimationFrameService {
private p1Tasks: Map<number, Task[]> = new Map(); // 优先级1
private p2Tasks: Map<number, Task[]> = new Map(); // 优先级2
createTask(callback: () => void, rowIndex: number, priority: 'p1' | 'p2') {
// 按行索引分组,同组的任务合并执行
const tasks = priority === 'p1' ? this.p1Tasks : this.p2Tasks;
if (!tasks.has(rowIndex)) {
tasks.set(rowIndex, []);
}
tasks.get(rowIndex)!.push(callback);
}
schedule() {
if (!this.frameScheduled) {
this.frameScheduled = true;
requestAnimationFrame(() => this.executeFrame());
}
}
executeFrame() {
// 先执行高优先级任务(如单元格创建)
this.executeTasks(this.p1Tasks);
// 再执行低优先级任务(如悬停效果)
this.executeTasks(this.p2Tasks);
this.frameScheduled = false;
}
}
好处:
- 合并同一帧内的多次更新
- 按优先级执行,关键操作先完成
- 避免阻塞主线程
5.2 滚动防抖(Debounce)
// gridBodyScrollFeature.ts
const SCROLL_DEBOUNCE_TIMEOUT = 100; // 100ms
private addVerticalScrollListeners(): void {
const isDebounce = this.gos.get('debounceVerticalScrollbar');
const onVScroll = isDebounce
? _debounce(this, this.onVScroll.bind(this, VIEWPORT), SCROLL_DEBOUNCE_TIMEOUT)
: this.onVScroll.bind(this, VIEWPORT);
this.addManagedElementListeners(this.eBodyViewport, { scroll: onVScroll });
}
适用场景:
- 移动设备上频繁的滚动事件
- 减少渲染次数,节省 CPU
5.3 滚动事件控制
防止弹性滚动干扰:
private shouldBlockVerticalScroll(scrollTo: number): boolean {
const clientHeight = _getInnerHeight(this.eBodyViewport);
const { scrollHeight } = this.eBodyViewport;
// iOS 弹性滚动会超出边界,需要忽略这些事件
return !!(scrollTo < 0 || scrollTo + clientHeight > scrollHeight);
}
滚动源控制:
private isControllingScroll(source: VerticalScrollSource, direction: Direction): boolean {
// 如果没有活动的滚动源,设置当前源
if (this.lastScrollSource[direction] == null) {
this.lastScrollSource[direction] = source;
return true;
}
// 只有当前活动源才能控制滚动
return this.lastScrollSource[direction] === source;
}
原因: 多个滚动条(真实 + fake)可能同时触发事件,需要防止循环更新。
5.4 被动事件监听器
虽然没有在提供的代码中直接看到 passive: true,但 AG Grid 在其他地方使用了它:
// 通常的用法
element.addEventListener('touchstart', handler, { passive: true });
element.addEventListener('touchmove', handler, { passive: true });
好处: 告诉浏览器不会调用 preventDefault(),允许浏览器在滚动时立即开始合成,不必等待 JS 执行完成。
6. 虚拟滚动条(Fake Scroll)实现
6.1 为什么需要 Fake Scroll
问题: 浏览器对单个 DIV 元素有最大高度限制:
- Chrome/Firefox: ~33,554,428 px
- Safari: 稍低
当数据量超过这个限制时,真实的滚动容器无法表示完整的文档高度。
解决方案:
- 使用较小的容器高度作为”虚拟滚动条”
- 真实数据容器使用缩放后的高度
- 两者滚动位置同步
6.2 FakeVScrollComp 实现
export class FakeVScrollComp extends AbstractFakeScrollComp {
public getScrollPosition(): number {
return this.eViewport.scrollTop;
}
public setScrollPosition(value: number, force?: boolean): void {
// 如果不可见,先尝试设置(处理隐藏状态)
if (!force && !_isVisible(this.eViewport)) {
this.attemptSettingScrollPosition(value);
}
this.eViewport.scrollTop = value;
}
}
同步机制:
// gridBodyScrollFeature.ts
private onVScroll(source: VerticalScrollSource): void {
// ...
if (source === VIEWPORT) {
// 如果是真实视口滚动,同步到 fake 滚动条
this.fakeVScrollComp.setScrollPosition(scrollTop);
} else {
// 如果是 fake 滚动条滚动,同步到真实视口
this.eBodyViewport.scrollTop = requestedScrollTop;
// 可能会被浏览器裁剪,需要读取实际值再同步回去
scrollTop = this.eBodyViewport.scrollTop;
if (scrollTop !== requestedScrollTop) {
this.fakeVScrollComp.setScrollPosition(scrollTop, true);
}
}
}
6.3 FakeHScrollComp 实现
水平滚动条的额外复杂性:
private setFakeHScrollSpacerWidths(): void {
// 计算右侧间距:固定列宽度 + 滚动条宽度
let rightSpacing = this.visibleCols.getDisplayedColumnsRightWidth();
if (scrollOnRight) {
rightSpacing += scrollbarWidth;
}
_setFixedWidth(this.eRightSpacer, rightSpacing);
// 计算左侧间距:固定列宽度 + 滚动条宽度(RTL 时)
let leftSpacing = this.visibleCols.getColsLeftWidth();
if (scrollOnLeft) {
leftSpacing += scrollbarWidth;
}
_setFixedWidth(this.eLeftSpacer, leftSpacing);
}
为什么需要 Spacer: 水平滚动条需要与固定列对齐,只有中间区域才是真正可滚动的部分。
7. 行容器高度计算与缩放
7.1 最大高度问题
export class RowContainerHeightService extends BeanStub {
private maxDivHeight: number; // 浏览器支持的最大 DIV 高度
public postConstruct(): void {
this.maxDivHeight = _getMaxDivHeight();
// 通常返回 ~33,554,428 px
}
}
7.2 缩放机制
当模型高度超过最大高度时:
public setModelHeight(modelHeight: number | null): void {
this.modelHeight = modelHeight;
// 判断是否需要缩放
this.stretching =
modelHeight != null &&
this.maxDivHeight > 0 &&
modelHeight > this.maxDivHeight;
if (this.stretching) {
this.calculateOffset(); // 计算缩放参数
} else {
this.clearOffset(); // 清除缩放
}
}
private calculateOffset(): void {
// 设置 UI 容器为最大高度
this.setUiContainerHeight(this.maxDivHeight);
// 计算需要"压缩"的像素数
this.pixelsToShave = this.modelHeight! - this.uiContainerHeight!;
// 计算最大滚动位置
this.maxScrollY = this.uiContainerHeight! - this.uiBodyHeight;
// 根据当前滚动百分比,计算偏移量
const scrollPercent = this.scrollY / this.maxScrollY;
this.divStretchOffset = scrollPercent * this.pixelsToShave;
}
7.3 位置映射
// 模型像素 → UI 像素
public getRealPixelPosition(modelPixel: number): number {
return modelPixel - this.divStretchOffset;
}
// 模型像素 → 滚动位置
public getScrollPositionForPixel(rowTop: number): number {
if (this.pixelsToShave <= 0) {
return rowTop; // 无需缩放
}
const modelMaxScroll = this.modelHeight! - this.getUiBodyHeight();
const scrollPercent = rowTop / modelMaxScroll;
const scrollPixel = this.maxScrollY * scrollPercent;
return scrollPixel;
}
原理图解:
模型空间(100,000,000 px)
├── Row 0: 0-40
├── Row 1: 40-80
├── ...
├── Row 2,500,000: 99,999,960-100,000,000
UI 空间(33,554,428 px)
├── Row 0: 0-13.4 (缩放后)
├── Row 1: 13.4-26.8
├── ...
└── Row 2,500,000: 33,554,414-33,554,428
映射公式:
UI_Pixel = Model_Pixel × (33,554,428 / 100,000,000)
= Model_Pixel × 0.33554428
8. 缓存与复用策略
8.1 RowCtrlCache 实现
class RowCtrlCache {
private entriesMap: RowCtrlByRowNodeIdMap = {}; // 快速查找
private readonly entriesList: RowCtrl[] = []; // 保持顺序
private readonly maxCount: number; // 最大缓存数
public addRow(rowCtrl: RowCtrl): void {
this.entriesMap[rowCtrl.rowNode.id!] = rowCtrl;
this.entriesList.push(rowCtrl);
rowCtrl.setCached(true); // 标记为缓存状态
// LRU 策略:超过最大数时移除最老的
if (this.entriesList.length > this.maxCount) {
const rowCtrlToDestroy = this.entriesList[0];
rowCtrlToDestroy.destroyFirstPass();
rowCtrlToDestroy.destroySecondPass();
this.removeFromCache(rowCtrlToDestroy);
}
}
public getRow(rowNode: RowNode): RowCtrl | null {
const res = this.entriesMap[rowNode.id];
if (!res) return null;
this.removeFromCache(res); // 从缓存移除
res.setCached(false); // 恢复活跃状态
return res;
}
}
8.2 Detail Row 缓存
应用场景: Master-Detail 模式下,用户展开详情行后操作详情 Grid,然后滚动离开再回来。
配置:
gridOptions: {
keepDetailRows: true, // 启用详情行缓存
keepDetailRowsCount: 3 // 缓存数量(默认 3)
}
实现:
// rowRenderer.ts
private initialiseCache(): void {
if (this.gos.get('keepDetailRows')) {
const countProp = this.getKeepDetailRowsCount();
const count = countProp != null ? countProp : 3;
this.cachedRowCtrls = new RowCtrlCache(count);
}
}
// rowCtrl.ts
public isCacheable(): boolean {
return this.rowType === 'FullWidthDetail' && this.gos.get('keepDetailRows');
}
public setCached(cached: boolean): void {
// 缓存时隐藏,激活时显示
const displayValue = cached ? 'none' : '';
for (const rg of this.allRowGuis) {
rg.element.style.display = displayValue;
}
}
8.3 行复用的条件
// 只有满足以下条件的行才能被复用:
if (_exists(rowNode) &&
_exists(rowsToRecycle) &&
rowsToRecycle[rowNode.id!] &&
rowNode.alreadyRendered) {
rowCtrl = rowsToRecycle[rowNode.id!];
}
// alreadyRendered 标记确保不会复用从未渲染过的行
rowNode.alreadyRendered = true;
9. 性能技巧总结
9.1 AG Grid 使用的优化技术清单
| 技术 | 应用位置 | 效果 |
|---|---|---|
| requestAnimationFrame | 滚动、行创建 | 合并帧内更新,避免布局抖动 |
| Debounce | 滚动事件 | 减少事件处理频率 |
| 事件委托 | RowRenderer | 减少事件监听器数量(10x+) |
| CSS Transform | 行定位 | GPU 加速,避免 Layout |
| DOM 批量操作 | RowComp | 减少 Reflow 次数 |
| 缓存 DOM 查询 | ScrollFeature | 避免 Layout Thrashing |
| LRU 缓存 | Detail Rows | 复用复杂组件,减少创建开销 |
| 虚拟滚动 | RowRenderer | 只渲染可见行,O(viewport) 而非 O(data) |
| 被动事件监听 | Touch 事件 | 提升滚动响应速度 |
| 双缓冲 | 行销毁动画 | 动画不阻塞新行渲染 |
9.2 关键配置参数
const gridOptions = {
// 行缓冲区大小(影响预渲染行数)
rowBuffer: 20,
// 行虚拟化开关
suppressRowVirtualisation: false,
// 最大渲染行数限制(防止意外渲染过多)
suppressMaxRenderedRowRestriction: false,
// 使用 Transform 定位(推荐开启)
suppressRowTransform: false,
// 垂直滚动防抖
debounceVerticalScrollbar: false,
// 行动画
animateRows: true,
// 详情行缓存
keepDetailRows: true,
keepDetailRowsCount: 3,
};
9.3 有趣的 Tricks
1. 防止打印时出现半透明行:
// 打印布局时不使用动画
if (this.printLayout || afterScroll) {
animate = false;
}
2. 避免动画到极远位置导致行”消失”:
private roundRowTopToBounds(rowTop: number): number {
const range = this.ctrlsSvc.getScrollFeature().getApproximateVScollPosition();
const minPixel = range.top - 100;
const maxPixel = range.bottom + 100;
// 将极远的 rowTop 截断到视口附近,确保动画可见
return Math.min(Math.max(minPixel, rowTop), maxPixel);
}
3. 行高变化检测循环:
// 处理 autoHeight 场景:行高可能依赖内容,首次渲染后才知道
do {
rowHeightsChanged = this.ensureAllRowsInRangeHaveHeightsCalculated(firstPixel, lastPixel);
} while (rowHeightsChanged);
4. iOS 弹性滚动防护:
// iOS 的弹性滚动会产生超出边界的值,必须忽略
if (scrollTo < 0 || scrollTo + clientHeight > scrollHeight) {
return true; // 阻止处理
}
5. 滚动源互斥:
// 多个滚动组件可能同时触发事件
// 使用 "锁" 机制确保同一时间只有一个源在控制
private lastScrollSource: [VerticalScrollSource | null, HorizontalScrollSource | null] = [null, null];
附录:核心数据结构
// RowRenderer 中的核心数据结构
class RowRenderer {
// 当前活跃的行(按索引查找)
private rowCtrlsByRowIndex: { [rowIndex: number]: RowCtrl } = {};
// 等待动画销毁的行
private zombieRowCtrls: { [instanceId: string]: RowCtrl } = {};
// 缓存的行(Detail Rows)
private cachedRowCtrls?: RowCtrlCache;
// 所有行的合并视图
public allRowCtrls: RowCtrl[] = [];
// 当前渲染范围
public firstRenderedRow: number;
public lastRenderedRow: number;
// 当前可见像素范围
public firstVisibleVPixel: number;
public lastVisibleVPixel: number;
}
结语
AG Grid 的虚拟滚动实现展示了生产级数据表格的性能优化最佳实践:
- 算法层面: 精确的可见区域计算,最小化渲染范围
- 架构层面: 清晰的职责分离,便于维护和优化
- 实现层面: 全面利用现代浏览器特性(rAF、Transform、Passive Events)
- 兼容性: 处理各种边界情况(iOS 弹性滚动、最大高度限制、RTL 等)
这些技术组合使得 AG Grid 能够流畅处理百万级数据的展示,是前端性能优化的典范案例。