简介:ag-Grid Community 前端开源数据网格库的完整工程包,面向使用 JavaScript、React、Angular、Vue 及 Web Components 搭建中后台系统的前端开发者,用于应对以下难题:万级数据表格的高性能渲染、数据绑定与单元格编辑。压缩包约 2000 个文件、190.14MB,以 js 源码与 ts 类型定义为主体,辅以 html 示例页、css 与 scss 主题样式、json 配置、png 与 svg 界面素材,另有 php 服务端对接样例、sh 构建脚本及 md 说明文档,目录结构完整,便于直接运行调试。已有 1464 人学习下载。读者可拆解虚拟滚动渲染、列定义与自定义单元格渲染器、排序过滤分组、列冻结与分页、编辑校验、主题定制与运行时 API 等模块的写法,也可对照多框架集成示例理解组件封装与事件系统,适合需要实现复杂表格或二次封装网格组件的中高级前端开发者参考。
1. 一个表格组件为什么值得单独拎出来看:ag-grid-community 的边界在哪
后台系统里十张页面有八张是表格,选型时最常见的失误是把普通表格组件直接用在几万行数据上,然后看着浏览器主线程被渲染卡死。ag-grid-community 属于前端开源生态里另一条路数:它把虚拟滚动、列虚拟化、行模型这些底层机制做成了基础架构,业务侧只管喂数据和列定义。这份资源适合正在做中后台、需要处理大列表或复杂列交互的团队,也适合想拆开看看高性能表格到底怎么设计的开发者。它开源、MIT 许可,可以放心进商业项目,但不等于全能:行分组、聚合、透视表、Excel 导出这些都在企业版里,社区版的边界最好先摸清楚,否则做到一半发现缺口,返工最让人头疼。
2. 把表格跑起来:模块注册、列定义与数据源的三段式配置
ag-grid 的上手成本不在 API 数量,而在它对「模块」和「数据流」的划分。很多人第一次用会卡在两个地方:一是引了包但功能不生效,二是数据变了界面不动。这两件事本质上对应同一套设计——它把功能切成模块按需加载,把数据当成不可变快照处理。把这两条想通,后面所有调试都会顺很多。
2.1 安装与两种引入路线的取舍
社区版的包只有两个:核心库和框架适配层。React 用ag-grid-react,Vue 用ag-grid-vue3,Angular 用ag-grid-angular,纯 JS 直接用ag-grid-community的createGrid。不要装ag-grid-enterprise,那个是企业版入口,混装会导致包体暴涨且运行时弹授权提示。
# 核心库 + React 适配层,两者的版本号必须一致 npm i ag-grid-community ag-grid-react装完之后第一件事是注册模块。近几个大版本把功能拆成了独立模块,不注册就用不了对应能力,控制台会打出「Module X is not registered」这类提示,表格本身还是能渲染,所以容易被忽略。
// 应用入口,例如 main.jsx import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'; import { ClientSideRowModelModule, ValidationModule } from 'ag-grid-community'; // 路线一:全量注册社区版模块,上手最快,构建工具能摇掉未使用的部分 ModuleRegistry.registerModules([AllCommunityModule]); // 路线二:按需注册,对包体积敏感的项目走这条 // 少注册一个模块 = 少一块功能,报错信息会明确点名缺哪个 ModuleRegistry.registerModules([ ClientSideRowModelModule, // 客户端行模型,默认渲染方式 ValidationModule, // 列定义校验,开发期强烈建议保留 ]);registerModules是全局一次性动作,必须在创建任何表格实例之前执行。它的语义是「往运行时里挂载能力」,不是「给某个表格开开关」,所以放在入口文件里最稳。按需注册时建议先在开发环境开ValidationModule,它会把列定义里写错的字段、冲突的配置直接打在控制台,比手工排查快得多。上线前如果对体积有要求,再逐个确认模块是否真的需要。
2.2 columnDefs 的字段拆解:哪些是渲染层,哪些是数据层
列定义是整个表格里最容易写乱的部分,因为一个colDef对象上同时混着取数逻辑、渲染逻辑、交互逻辑。我一般按三层去理解它:
| 层 | 典型字段 | 作用 | 常见误用 |
|---|---|---|---|
| 取数层 | field、colId、valueGetter | 决定这一列的值从哪来 | field重复却没给colId |
| 渲染层 | valueFormatter、cellRenderer、cellClassRules | 决定值怎么显示 | 用cellRenderer做纯文本格式化 |
| 交互层 | editable、sortable、filter、pinned | 决定用户能做什么 | pinned写成fixed |
取数层的优先级是valueGetter>field。field支持嵌套路径写法,比如'customer.address.city',不需要额外写 getter。colId是列的唯一定位符,只要两列的field相同(比如同一字段既要显示原值又要格式化显示),就必须显式指定colId,否则 ag-grid 会认为是同一列并覆盖。渲染层里valueFormatter只做「值 → 字符串」的转换,拿到的params.value是原始值;cellRenderer返回的是一个组件或 DOM,用于放标签、按钮、进度条。两者的选择标准很简单:输出是纯文本就用 formatter,输出是结构化内容才用 renderer,用 renderer 做字符串拼接会白白多出一层组件开销。
const columnDefs = [ // pinned 只影响列位置,不改变数据;left/right 各留一个操作列是常见布局 { field: 'orderNo', headerName: '单号', width: 150, pinned: 'left' }, // numericColumn 会带上右对齐和数字筛选器,金额列基本都要 { field: 'amount', headerName: '金额', type: 'numericColumn', valueFormatter: (p) => (p.value == null ? '-' : `¥${Number(p.value).toFixed(2)}`), }, // 同一字段需要两种展示时,靠 colId 区分 { colId: 'amountRaw', headerName: '原始值', field: 'amount', hide: true, }, // editable 支持函数形式,按行数据决定能不能改 { field: 'remark', headerName: '备注', editable: (p) => p.data.status !== 'ARCHIVED', }, ];defaultColDef是省事的公共层,把resizable、sortable、filter这些每列都要写的项提出来,再在单列上覆盖。要注意defaultColDef里的对象会被合并到每个colDef上,函数类型的字段(比如valueFormatter)不会自动合并,单列写了就覆盖。列数量超过三四十列时,flex: 1加minWidth的组合比逐个写死width更好维护,屏幕变化时不会挤成一条缝。
2.3 数据喂入的两种姿势:全量 rowData 与事务式更新
数据进表格只有两条路。第一条是把一个新数组整体赋给rowData,React 里就是setState;第二条是拿到gridApi后调用applyTransaction。两条路的效果天差地别,但很多人从头到尾只用第一条。
import { AgGridReact } from 'ag-grid-react'; import { useRef, useCallback } from 'react'; function OrderGrid({ rows, columnDefs }) { const apiRef = useRef(null); const onGridReady = useCallback((params) => { apiRef.current = params.api; // 列宽自适应只适合列数不多的场景,列多时它会压到最小可读宽度 params.api.sizeColumnsToFit(); }, []); // 单行改动用事务,避免整表重绘 const updateRow = useCallback((next) => { apiRef.current?.applyTransaction({ update: [next] }); }, []); return ( <div style={{ height: 600 }}> <AgGridReact rowData={rows} columnDefs={columnDefs} defaultColDef={{ resizable: true, sortable: true, filter: true, flex: 1, minWidth: 120 }} getRowId={(p) => p.data.id} onGridReady={onGridReady} /> </div> ); }这里的关键是getRowId。不写它的时候,ag-grid 用内部生成的行号标识每一行,applyTransaction就无法判断「这条数据是哪一行」,更新会退化成整表刷新,滚动位置、选中态、正在编辑的单元格全部丢失。写了getRowId之后,表格认的是业务主键,update、add、remove三个动作才能精准落到具体行上。血泪经验是:只要你有任何一处需要局部更新,getRowId就从一开始写上,后期补会因为主键在数据里不唯一而返工。
整体替换rowData不是不能用,它的适用场景是「换一页数据」「换一个查询条件」这种全量语义。日常的行内编辑、状态流转、轮询刷新,全部走事务。行数过千之后这个差别非常明显:整体替换会让可见区域的 DOM 全部重建,滚动条位置跳动,用户正在输入的内容直接蒸发。
3. 大数据量下的性能:行模型选型、虚拟滚动与参数实测
表格慢,九成不是 ag-grid 本身慢,而是行模型选错了、或者某个参数把虚拟滚动关掉了。这一章解决三件事:什么数据量该用哪种行模型、渲染卡顿时先看哪几个参数、无限滚动怎么接后端分页。前提是先明确社区版的边界——服务端行模型(带分组聚合的那种)属于企业版,社区版能用的是客户端、无限、视口三种。
3.1 三种社区版行模型的适用边界
选型不看数据量大小,看「数据能不能一次拿全」和「数据会不会频繁变」。
| 行模型 | 配置值 | 数据来源 | 适用场景 | 主要代价 |
|---|---|---|---|---|
| 客户端行模型 | clientSide(默认) | 一次性全量 | 几千行以内,需要本地排序、筛选、快捷搜索 | 行数上万后内存和首次渲染明显变重 |
| 无限行模型 | infinite | 按块向后端请求 | 十万级以上,用户以浏览为主 | 不能本地全量排序,筛选要透传给后端 |
| 视口行模型 | viewport | 按视口请求 | 数据实时变化(行情、监控大屏) | 滚动时会有短暂空白,交互体验偏硬 |
客户端行模型的优势是排序、筛选、快捷搜索全在本地,交互零延迟,缺点是所有数据都要在内存里躺一份,并且首次渲染时要建完整的数据模型。经验值是五千行以内放心用,五万行开始首次加载有可感知的停顿。无限行模型把数据切成块,滚到哪请求到哪,视觉上是连续滚动,适合「用户只需要翻和看」的场景。视口行模型最激进,只保证视口附近的行有数据,滚动时旧行会被丢弃,适合数据一直在变的看板,用来做普通业务表格会觉得卡顿。
提示:行模型在表格创建时就固定了,运行中切换需要销毁重建。别指望通过改配置切过去,那是玄学。
3.2 渲染变慢时先看哪几个参数
排查性能问题别一上来就上 profiler,先把几个会「偷偷关掉虚拟滚动」的配置检查一遍。最常见的一类是布局设置:domLayout: 'autoHeight'会让表格撑开到能容纳所有行的高度,虚拟滚动直接失效,只适合行数固定在几十行以内的弹窗场景。另一类是suppressRowVirtualisation和suppressColumnVirtualisation,这两个是给调试用的开关,忘了关掉就会出现「行数越多越卡,卡得还很线性」的现象。
真正影响渲染量的参数是rowBuffer,默认值是 10,含义是视口之外额外渲染的行数。快速滚动时如果出现白屏闪一下再补上,把它调到 20 到 30 会有改善,代价是 DOM 节点变多;反过来如果内存紧张,降到 5 也能跑,只是滚动容错变小。rowHeight决定单行高度,降低它可以减少总像素高度,但对可读性有直接影响,不建议为性能牺牲。animateRows在行数很大时建议关掉,行插入删除的动画在密集刷新下反而干扰视觉。
测量手段比调参更重要。别凭感觉,直接在关键节点打点:
const gridOptions = { onGridReady: (params) => { const t0 = performance.now(); params.api.addEventListener('firstDataRendered', () => { // 从表格初始化到首屏数据画完,这个数才是用户真实感知的加载耗时 console.log('first paint ms:', (performance.now() - t0).toFixed(1)); }); params.api.addEventListener('modelUpdated', () => { // 每次排序/筛选/数据变更后触发,用来确认是不是数据模型在反复重建 console.count('modelUpdated'); }); }, };firstDataRendered是首屏可交互的时间点,modelUpdated触发次数暴露的是另一个常见问题:如果数据源是一个每次渲染都新建的数组常量,modelUpdated会被高频触发,表格反复重建数据模型,表现就是「明明没几行但一直在抖」。这种情况用useMemo把列定义和数据引用稳住即可。
3.3 无限行模型的接入与缓存块调参
无限行模型的核心是一个datasource对象,表格会在滚动、排序、筛选时回调getRows,业务侧负责去后端取对应区间的数据。这里最容易差一行。
const gridOptions = { rowModelType: 'infinite', cacheBlockSize: 100, // 每块多少行,和分页 pageSize 不是一个概念 maxBlocksInCache: 10, // 内存里最多保留几块,超出丢弃最远的 infiniteInitialRowCount: 1, // 首次渲染先占位多少行,别设太大 maxConcurrentDatasourceRequests: 2, // 同时最多几个块请求在飞 blockLoadDebounceMillis: 200, // 快速滚动时的请求防抖,防止一口气打十几个请求 datasource: { getRows(params) { const { startRow, endRow, sortModel, filterModel } = params; // 注意:endRow 是闭区间语义,取数时要 +1,后端大多是左闭右开 const limit = endRow - startRow + 1; fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ startRow, limit, sortModel, filterModel }), }) .then((r) => r.json()) .then(({ list, total }) => { // 第二个参数传总数,表格才知道滚动条该有多长 params.successCallback(list, total); }) .catch(() => params.failCallback()); // 失败必须回调,否则这块永远处于加载中 }, }, };cacheBlockSize决定一次请求多少行,太小会导致滚动时请求过于频繁,太大则首屏等待变长。一百到两百是个常见区间,具体看单行数据量和接口耗时。maxBlocksInCache约束内存,用户来回滚动时被回收的块要重新请求,所以不能设太小,否则会出现「滚回去又转圈」的体验问题。maxConcurrentDatasourceRequests控制并发,后端扛不住就调到 1,让请求排队。
sortModel和filterModel是透传给后端的,这意味着无限行模型下的排序筛选必须由服务端实现,客户端那套sortable: true只在当前已加载的块里生效,会导致排序结果错乱。要么关掉表头排序改用自定义按钮触发重新请求,要么在getRows里把模型完整传给后端并让后端返回正确顺序。数据变更后要刷新,用refreshInfiniteCache(),只想清空重来用purgeInfiniteCache()。
4. 编辑、校验与回写:把展示表格改成录入组件
表格一旦要改数据,问题就从「渲染」转移到「状态一致性」。ag-grid 有一套自己的编辑生命周期,值和数据的边界卡得比较严:编辑中的值是临时的,提交之后才进rowData。不理解这条分界线,就会出现「输入框里改了但保存没生效」「校验不通过却写进了数据」这类问题。
4.1 单元格编辑器的生命周期与 getValue
自定义编辑器必须实现两件事:接收初始值、交出最终值。框架适配层里这两件事分别对应props.value和暴露出去的getValue方法。React 里必须用forwardRef+useImperativeHandle把getValue挂到 ref 上,ag-grid 是通过 ref 调这个方法的,不是通过 props。
import { forwardRef, useImperativeHandle, useState } from 'react'; const NumberEditor = forwardRef((props, ref) => { // props.value 是进入编辑时的原值,只在挂载时读一次 const [text, setText] = useState(props.value ?? ''); useImperativeHandle(ref, () => ({ // 表格只认这个方法拿回写值,返回什么就存什么 getValue: () => { const n = Number(String(text).replace(/[^\d.-]/g, '')); return Number.isNaN(n) ? null : n; }, // 返回 true 表示这次编辑作废:不改数据,也不触发 cellValueChanged isCancelAfterEnd: () => String(text).trim() === '', })); return <input value={text} onChange={(e) => setText(e.target.value)} autoFocus />; });agInit(params)是纯 JS 版本的等价入口,参数里带着value、data、node、colDef、api,需要拿行上下文做联动(比如根据状态决定可选值)就从这个参数里取。isCancelBeforeStart在编辑开始前判断要不要允许进入编辑,isCancelAfterEnd在提交时判断结果是否有效,前者省一次交互,后者兜数据质量。
编辑模式有两种:默认单格编辑,点另一个格子才提交;editType: 'fullRow'是整行编辑,一行里多个格子连续改,全部提交后才触发一次rowValueChanged。整行编辑适合表单式录入,普通列表改一两个字段用单格编辑更顺。另外singleClickEdit: true让单击直接进入编辑,看着爽但误触率高,业务表格里我一般不默认开。
4.2 valueFormatter 与 valueParser 必须成对写
这是最容易被跳过的一步。valueFormatter管显示,valueParser管回写,只写前者不写后者,用户在编辑框里看到的格式化文本会原样存进数据。
{ field: 'amount', headerName: '金额', // 显示:null 显示空串,数字统一两位小数 valueFormatter: (p) => (p.value == null ? '' : Number(p.value).toFixed(2)), // 回写:剥掉用户可能输入的货币符号、千分位、空格 valueParser: (p) => { const n = Number(String(p.newValue).replace(/[^\d.-]/g, '')); return Number.isNaN(n) ? null : n; }, cellEditor: NumberEditor, }valueParser的入参是newValue(编辑后的文本)和oldValue,返回的才是最终写进数据的值。上面这段如果只保留valueFormatter,用户输入¥1,200会存成字符串,后续排序变成字典序,1200排在200前面,而且这种问题在开发环境往往看不出来——数据看着「有值」,只有排序和导出时才露馅。
同一类问题还有valueGetter和valueSetter的组合。用 getter 从多字段拼出一个值展示,就必须用 setter 定义怎么拆回去,否则这一列只能看不能改。如果这一列本来就只读,那就把editable显式关掉,别让用户点了半天发现存不进去。
4.3 事件顺序与批量提交的落点
编辑相关的事件有固定顺序,搞清楚顺序才知道该在哪一步做校验和提交。
const gridOptions = { onCellEditingStarted: (e) => { // 编辑开始,此时数据还没变 }, onCellValueChanged: (e) => { // 编辑已提交,e.data 里的值已经是新值 // e.oldValue / e.newValue 是本次变化的前后值 queueChange(e.data.id, e.data); }, onRowValueChanged: (e) => { // 只在 editType: 'fullRow' 下触发,整行提交后调用一次 }, }; // 批量提交:用一个 Map 攒住待提交的行,节流后统一发出去 const pending = new Map(); function queueChange(id, data) { pending.set(id, data); // 400ms 内的连续编辑合并成一次请求,避免每敲一个字打一次接口 clearTimeout(queueChange.timer); queueChange.timer = setTimeout(() => { const rows = [...pending.values()]; pending.clear(); saveBatch(rows); }, 400); }onCellValueChanged是最高频的事件,直接在里面发请求会打成请求风暴,尤其是数字输入框每敲一个字符提交一次。攒批加节流是标准做法。还有一个更严格的选择:readOnlyEdit: true让表格完全不碰数据,用户编辑时抛出cellEditRequest事件,由业务层决定要不要更新rowData。这条路径牺牲了一点点击反馈的即时性,但换来的是数据源单一,所有变更都走同一个入口,做撤销、脏检查、协同编辑时特别省事。数据一致性要求高的项目我倾向于直接开这个。
5. 避坑与排查:这五处翻车最集中
同一批问题会在不同项目里反复出现,把现象、原因、解法记下来,能省掉大量排查时间。
5.1 表格只显示表头,或者整块空白
现象:组件挂载了,控制台没报错,页面上只有一条表头或者干脆什么都没有。
原因:ag-grid 的根容器默认高度是 100%,父级没有显式高度时会被算成 0,行渲染出来但看不见。另一个变种是父容器用了flex但没给min-height,内容撑不开。
解决:给直接父容器一个确定高度(height: 600px或flex: 1; min-height: 400px),不要在表格自身上写height: '100%'然后指望它自己算。行数固定在几十行以内的弹窗场景,改用domLayout: 'autoHeight'让它按内容撑开更省事。上线前在不同分辨率下扫一眼,这个坑在窄屏和 iframe 里最容易复现。
5.2 数据明明改了,界面纹丝不动
现象:rowData里的对象属性已经变了,console.log打出来是新值,表格还是旧的。
原因:直接修改了数组里的对象引用,或者push之后没有换新数组。表格判断要不要重渲染,靠的是引用比较。
解决:两条路选一条。整体刷新就setRows([...rows])换个新数组引用;局部更新就配getRowId再调applyTransaction({ update: [row] })。后者性能好得多,但前提是getRowId返回的值全局唯一且稳定。别在cellRenderer里直接改params.data,那绕过了所有的更新链路,属于给自己挖坑。
5.3 排序筛选后行选中、展开状态错位
现象:点排序之后,原本选中的行跟着变了,或者展开的树节点跑到了别的行上。
原因:行标识用的是数组下标。排序改变了顺序,下标对应的行就换了。
解决:getRowId必须返回业务主键,不能用(p) => p.rowIndex。同理,React 侧如果自己渲染行相关的东西,key用主键不用 index。编辑状态下排序也会丢焦点,这是同一个原因,主键对了就都对了。
5.4 控制台提示模块未注册,某块功能用不了
现象:表格能显示,但筛选面板打不开、导出没反应,控制台有一行提示点名某个模块。
原因:按需注册时漏了模块。近几个大版本把功能拆得很细,用过企业版写法的同学尤其容易踩,因为部分能力只在企业版包里。
解决:看提示点名的是哪个模块,从ag-grid-community里补注册。开发期先用AllCommunityModule跑通,功能确认没问题再逐个替换成具体的模块名做体积优化。另外注意版本一致性,核心库和框架适配层版本不一致时会出现更难懂的报错,锁版本能省很多事。
5.5 自定义编辑器拿不到值或提交不生效
现象:自定义编辑器里输入了内容,失焦后数据没变,onCellValueChanged也不触发。
原因:getValue没有通过 ref 暴露,或者暴露了但返回undefined。React 里如果忘了useImperativeHandle的依赖数组,闭包里拿的还是初始 state。
解决:用forwardRef包一层,useImperativeHandle里返回{ getValue },依赖数组把用到的状态都列上。调试时先在getValue里打一行日志确认它被调用了,没被调用就是 ref 没挂上,被调用了但值是旧的,就是闭包问题。
6. 二次封装与主题改造:把表格收进自己的组件体系
直接在每个页面里写columnDefs和onGridReady,三五个页面之后就会失控。我一般在项目里包一层DataGrid,把列定义合并、行标识、加载态、空态、分页这几件固定动作收进去,业务侧只传columns和fetch。
import { AgGridReact } from 'ag-grid-react'; import { themeQuartz } from 'ag-grid-community'; // 新版走 Theming API,参数直接以对象形式给,不用再拼 CSS 变量 const brandTheme = themeQuartz.withParams({ accentColor: '#2f6fed', borderRadius: 6, fontFamily: 'inherit', rowHeight: 40, headerHeight: 44, }); function DataGrid({ columns, fetchRows, ...rest }) { const apiRef = useRef(null); // 列定义在外面拼一层默认值,业务方只关心自己的字段 const mergedCols = useMemo( () => columns.map((c) => ({ resizable: true, sortable: true, ...c })), [columns], ); // 把常用动作挂到 ref 上,业务侧不用碰 gridApi 的内部细节 useImperativeHandle(ref, () => ({ refresh: () => apiRef.current?.refreshInfiniteCache(), reset: () => apiRef.current?.purgeInfiniteCache(), scrollToTop: () => apiRef.current?.ensureIndexVisible(0, 'top'), })); return ( <div className="grid-shell"> <AgGridReact theme={brandTheme} columnDefs={mergedCols} rowModelType="infinite" cacheBlockSize={100} maxBlocksInCache={10} getRowId={(p) => p.data.id} datasource={{ getRows: fetchRows }} onGridReady={(e) => (apiRef.current = e.api)} {...rest} /> </div> ); }这层封装的价值不在少写几行代码,而在把「一致性」固化下来。所有表格的行模型、缓存块大小、行标识规则、主题参数都从一个地方出,后续要调性能、换配色、加埋点,改一处全站生效。refreshInfiniteCache()只刷新当前缓存块并保留滚动位置,purgeInfiniteCache()清空所有块从头加载,两个方法用错会导致用户刷新后回到顶部,体验差别很明显,我在 ref 上直接给了语义清楚的两个名字,避免业务侧记混。
主题改造还有一个方向是只用 CSS 变量覆盖,不改 Theming 配置。旧版主题类(ag-theme-quartz这一套)加--ag-*变量在需要跟随设计系统动态换肤时更灵活,可以和 CSS 变量体系直接打通;新版的withParams胜在类型提示和参数集中。两套不要混用,混用的结果是部分样式不生效而且很难定位——这个黑匣子我拆过一次,花了半天。
从那以后我每次接新项目,第一步都是先把这层壳搭好、把getRowId和主题参数定死,再往里填业务列,宁可前期多花二十分钟,也不要在十几个页面里回头改行标识。希望帮到你。
本文还有配套的精品资源,点击获取