表格状态与虚拟化
本教程共 38 篇 · 第 31 篇 · 更新于 2026-07-27 · 约 10 分钟阅读
31. 表格状态与虚拟化
本节目标:理解 TanStack Table 的状态管理体系(受控 vs 非受控、初始状态、状态持久化),学会用 TanStack Virtual 给表格做虚拟化渲染处理万行数据,顺带了解自定义特性扩展。学完你能把表格状态同步到 URL,也能渲染上万行数据不卡。
31.1 表格状态全景
前面几章你已经接触了不少状态:sorting、columnFilters、globalFilter、pagination、grouping、expanded、columnPinning、rowSelection、columnSizing…
这些状态有两种管理模式:受控(Controlled) 和 非受控(Uncontrolled)。
打个比方:受控就像你亲自开车,方向盘在你手里(状态在组件的 useState 里);非受控就像自动驾驶,车子自己管(状态在表格实例内部)。
31.1.1 非受控模式
不给表格传 state 和 onXxxChange,表格自己管状态:
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
// 没传 state.sorting 和 onSortingChange
// 表格内部自己管理 sorting 状态
})
非受控模式简单省事,但你在外面读不到当前状态。适合不需要和外部交互的简单场景。
31.1.2 受控模式
把状态提到组件里管,传给表格:
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: { sorting }, // 传当前状态
onSortingChange: setSorting, // 状态变化时回调
})
受控模式你完全掌控状态,能做这些事:
- 同步到 URL(刷新页面不丢排序状态)
- 传给其他组件(比如把当前过滤条件显示在面包屑上)
- 持久化到 localStorage
- 跨组件共享状态
Tip实际项目里推荐用受控模式。初始开发时用非受控省事,但后面要加状态持久化、URL 同步时还得改成受控。不如一开始就受控。
31.1.3 混合模式
不是所有状态都要受控。你可以只受控需要外部交互的状态,其余的非受控:
// 只受控 sorting(要同步到 URL),其余的非受控
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
state: { sorting }, // 只传 sorting
onSortingChange: setSorting,
// 没传 columnFilters、pagination 等的状态
// 这些状态表格自己管
})
31.2 初始状态(InitialState)
不想受控但又想设默认值?用 initialState:
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
sorting: [{ id: 'name', desc: false }], // 默认按 name 升序
pagination: {
pageIndex: 0,
pageSize: 20, // 默认每页 20 条
},
columnVisibility: { age: false }, // age 列默认隐藏
},
})
initialState 只在表格初始化时生效一次,之后状态由表格自己管(非受控)或由你管(受控)。
Warning
initialState和state不能同时设同一个状态。比如设了state: { sorting }就不能再设initialState: { sorting: ... },会冲突。受控状态用useState的初始值设默认值,非受控状态用initialState。
31.3 状态同步到 URL
常见需求:排序、过滤、分页状态同步到 URL,刷新页面或分享链接时能恢复。
31.3.1 读取 URL 初始化状态
import { useSearchParams } from 'react-router-dom'
function UrlStateTable() {
const [searchParams, setSearchParams] = useSearchParams()
// 从 URL 读排序状态
const sortParam = searchParams.get('sort') // "name-desc" 或 null
const initialSorting: SortingState = sortParam
? [{ id: sortParam.split('-')[0], desc: sortParam.split('-')[1] === 'desc' }]
: []
const [sorting, setSorting] = useState<SortingState>(initialSorting)
// 排序变化时更新 URL
const handleSortingChange = (updater) => {
const newSorting = typeof updater === 'function' ? updater(sorting) : updater
setSorting(newSorting)
if (newSorting.length > 0) {
const { id, desc } = newSorting[0]
setSearchParams({ sort: `${id}-${desc ? 'desc' : 'asc'}` })
} else {
// 清除排序参数
searchParams.delete('sort')
setSearchParams(searchParams)
}
}
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: { sorting },
onSortingChange: handleSortingChange,
})
// ... 渲染
}
Note
onSortingChange的参数可能是新值也可能是更新函数。处理时要判断typeof updater === 'function',是函数就调用它拿到新值。这是 TanStack Table 状态回调的统一模式。
31.4 状态重置
表格实例提供了 resetXxx 方法,把某个状态重置到初始值:
// 重置排序
table.resetSorting()
// 重置分页
table.resetPagination()
// 重置行选择
table.resetRowSelection()
// 重置列固定
table.resetColumnPinning()
// 重置所有状态
table.reset()
resetXxx 默认重置到 initialState 里的值,也可以传 false 参数重置到默认值而非初始值:table.resetSorting(false)。
31.5 自定义特性(Custom Features)
TanStack Table v8 支持通过 createTable 扩展自定义特性。这是高级用法,了解即可。
import { createTable, getCoreRowModel } from '@tanstack/table-core'
// 定义自定义特性
function myCustomFeature<TData extends RowData>() {
return (instance: Table<TData>): Table<TData> => {
return {
...instance,
myCustomMethod: () => {
console.log('自定义方法', instance.getRowModel().rows.length)
},
}
}
}
// 使用自定义特性
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
_features: [myCustomFeature],
})
table.myCustomMethod?.()
Tip自定义特性主要用于二次封装表格库的场景。普通使用不需要,了解有这么个能力就行。
31.6 与 Virtual 集成:大数据表格
前面几章的表格,不管多少行数据全部渲染 DOM。100 行没问题,10000 行就会卡—浏览器渲染上万个 DOM 节点很吃力。
解决方案是虚拟化(Virtualization):只渲染可视区域内的行,滚动时动态替换。TanStack 生态有自己的虚拟化库 @tanstack/react-virtual,和 Table 天然集成。
31.6.1 安装
npm install @tanstack/react-virtual
31.6.2 基本虚拟化表格
核心思路:用 useVirtualizer 算出哪些行在可视区域内,只渲染这些行。
import { useVirtualizer } from '@tanstack/react-virtual'
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'
import { useRef } from 'react'
function VirtualizedTable({ data, columns }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
// 大数据场景通常不用前端分页,直接虚拟化
})
// 滚动容器
const parentRef = useRef<HTMLDivElement>(null)
// 行虚拟化器
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // 每行预估高度
overscan: 10, // 上下额外渲染的行数
})
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
{/* 撑开滚动高度的容器 */}
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{/* 只渲染可视区域的行 */}
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index]
return (
<div
key={row.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* 渲染一行 */}
<div style={{ display: 'flex' }}>
{row.getVisibleCells().map((cell) => (
<div
key={cell.id}
style={{ width: cell.column.getSize(), padding: '8px' }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}
关键步骤:
- 创建滚动容器(
parentRef),设固定高度和overflow: auto。 - 用
useVirtualizer创建行虚拟化器,传入总行数、滚动元素、预估行高。 - 内层容器高度设为
getTotalSize()(全部行的总高度),撑开滚动条。 - 只遍历
getVirtualItems()(可视区域的行),用position: absolute+translateY定位。
Note虚拟化表格通常用
<div>替代<table>,因为原生<table>的布局规则和绝对定位冲突。用 flex 布局模拟表格结构更灵活。
31.6.3 虚拟化 + 排序过滤
虚拟化不影响排序和过滤,它们在行模型层面工作。虚拟化只管「渲染哪些行」,排序过滤管「行的顺序和数量」:
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
})
// 虚拟化器用的是 table.getRowModel().rows.length
// 排序过滤后行数变了,虚拟化器自动适应
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length, // 过滤后的行数
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
})
31.6.4 动态行高
行高不固定时(内容多少不一),用 measureElement 动态测量:
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
measureElement: (element) => element.getBoundingClientRect().height,
})
// 渲染时把 measureElement 绑到 ref 上
<div
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}
>
{/* 行内容 */}
</div>
虚拟化器会自动测量每个渲染过的行的实际高度,下次渲染时用实际值替代预估值。
Warning动态行高有性能开销。每行首次渲染时都会触发一次
measureElement,可能导致滚动时短暂闪烁。行高差异不大时建议用固定预估高度,关闭动态测量。
31.7 虚拟化表格的完整结构
一个功能较全的虚拟化表格通常长这样:
function BigDataTable({ data, columns }) {
const parentRef = useRef<HTMLDivElement>(null)
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
})
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 10,
})
return (
<div>
{/* 搜索框 */}
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="搜索..."
/>
{/* 表头(固定不滚动) */}
<div style={{ display: 'flex' }}>
{table.getHeaderGroups()[0]?.headers.map((header) => (
<div
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{ width: header.column.getSize(), padding: '8px', fontWeight: 'bold' }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</div>
))}
</div>
{/* 可滚动的表体(虚拟化) */}
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<div
key={row.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
}}
>
{row.getVisibleCells().map((cell) => (
<div
key={cell.id}
style={{ width: cell.column.getSize(), padding: '8px' }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
)
})}
</div>
</div>
<div>共 {rows.length} 行</div>
</div>
)
}
31.8 常见坑
坑一:受控和非受控混用同一个状态。 同时传了 state: { sorting } 和 initialState: { sorting: [...] },后者不生效。受控状态用 useState 初始值设默认。
坑二:状态回调没处理 updater 函数。 onSortingChange 的参数可能是值也可能是函数。直接 setSorting(updater) 对函数类型的 updater 也能工作,因为 React 的 setState 本身支持函数更新器。但如果你在回调里做额外操作(如更新 URL),就得先判断类型。
坑三:虚拟化表格用 <table> 渲染。 原生表格的布局规则和绝对定位不兼容。虚拟化表格用 <div> + flex 布局,不要用 <table>/<tr>/<td>。
坑四:虚拟化后行高不准导致跳动。 预估高度和实际差太多,滚动时行会跳动。尽量让 estimateSize 接近实际值,或用 measureElement 动态测量。
坑五:排序/过滤后虚拟化没更新。 虚拟化器的 count 用了旧的 rows.length。确保 count: table.getRowModel().rows.length 每次都能拿到最新的行数,虚拟化器会自动重新计算。
31.9 小结
这一章是 Table 部分的收尾:
- 状态管理:受控(传
state+onXxxChange)和非受控(表格自己管),推荐受控。initialState设非受控状态的默认值。 - 状态持久化:受控状态下,把状态同步到 URL 或 localStorage,刷新不丢状态。
- 状态重置:
resetXxx()方法重置到初始值。 - 自定义特性:通过
_features扩展表格实例方法,高级用法。 - 虚拟化集成:用
useVirtualizer只渲染可视行,配合position: absolute+translateY定位。万行数据也能流畅滚动。 - 虚拟化注意事项:用
<div>不用<table>,count用最新行数,行高用estimateSize或measureElement。
Table 六章到此结束。下一章开始讲 TanStack Form,从表单状态和 Field API 入手。