AG Grid 源码分析:虚拟滚动与行渲染性能优化

2026/3/28
AG GridReact虚拟滚动性能优化前端

AG Grid 虚拟滚动与行渲染性能优化深度分析

基于源码版本:AG Grid Community Edition
分析文件:rowRenderer.tsrowCtrl.tsrowComp.tsrowContainerHeightService.tsgridBodyScrollFeature.tsfakeVScrollComp.tsfakeHScrollComp.ts


目录

  1. 核心架构概览
  2. 虚拟滚动实现原理
  3. 行的生命周期管理
  4. DOM 操作优化策略
  5. 滚动性能优化
  6. 虚拟滚动条(Fake Scroll)实现
  7. 行容器高度计算与缩放
  8. 缓存与复用策略
  9. 性能技巧总结

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;
    }
}

关键设计点:

  1. 缓冲区机制:

    • 通过 rowBuffer 属性控制上下额外渲染的行数
    • 默认值通常是 20 行,可通过 gridOptions.rowBuffer 配置
    • 目的:滚动时提前渲染,减少空白闪烁
  2. 高度变化检测循环:

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);
}

为什么需要两阶段:

  1. 动画需要时间执行(400ms 超时)
  2. 动画期间行仍需存在于 DOM 中
  3. 避免动画和 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 → Composite
  • transform 只触发 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;
    }
}

好处:

  1. 合并同一帧内的多次更新
  2. 按优先级执行,关键操作先完成
  3. 避免阻塞主线程

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: 稍低

当数据量超过这个限制时,真实的滚动容器无法表示完整的文档高度。

解决方案:

  1. 使用较小的容器高度作为”虚拟滚动条”
  2. 真实数据容器使用缩放后的高度
  3. 两者滚动位置同步

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 的虚拟滚动实现展示了生产级数据表格的性能优化最佳实践:

  1. 算法层面: 精确的可见区域计算,最小化渲染范围
  2. 架构层面: 清晰的职责分离,便于维护和优化
  3. 实现层面: 全面利用现代浏览器特性(rAF、Transform、Passive Events)
  4. 兼容性: 处理各种边界情况(iOS 弹性滚动、最大高度限制、RTL 等)

这些技术组合使得 AG Grid 能够流畅处理百万级数据的展示,是前端性能优化的典范案例。

📝 文章反馈