TanStack Table / Form / Virtual 完全指南
最后更新:2026-03-29 TanStack Table 是一个无头(Headless)表格库,只提供逻辑和状态管理,不提供任何 UI 样式,完全由开发者控制渲染。支持 React、Vue、Solid、Svelte。 无头的含义:Table 的核心是一个状态管理器,你传入数据和列定义,它返回处理好的行、列、单元格数据和操作方法,具体如何渲染成 HTML 完全自定义。 TanStack Form 是一个无头、类型安全的表单状态管理库,支持同步/异步验证,支持 React、Vue、Solid 等。 与 React Hook Form 对比: TanSta
最后更新:2026-03-29
官方文档:https://tanstack.com/table/latest | https://tanstack.com/form/latest | https://tanstack.com/virtual/latest
适用版本:TanStack Table/Form/Virtual v8(2026-05-07 核实)
一、TanStack Table v8
1. 基础概念
TanStack Table 是什么
TanStack Table 是一个无头(Headless)表格库,只提供逻辑和状态管理,不提供任何 UI 样式,完全由开发者控制渲染。支持 React、Vue、Solid、Svelte。
无头的含义:Table 的核心是一个状态管理器,你传入数据和列定义,它返回处理好的行、列、单元格数据和操作方法,具体如何渲染成 HTML 完全自定义。
安装
npm install @tanstack/react-table
# Vue
npm install @tanstack/vue-table
2. 快速开始
列定义(ColumnDef)
| 属性 | 类型 | 说明 |
|---|---|---|
accessorKey |
string |
从数据对象取值的字段名 |
accessorFn |
(row) => value |
自定义取值函数 |
id |
string |
列唯一 ID(accessorFn 时必填) |
header |
string | (ctx) => ReactNode |
表头内容 |
cell |
(ctx) => ReactNode |
单元格渲染 |
footer |
string | (ctx) => ReactNode |
表尾内容 |
enableSorting |
boolean |
是否允许排序 |
enableColumnFilter |
boolean |
是否允许列过滤 |
size |
number |
列宽 |
minSize / maxSize |
number |
最小/最大列宽 |
meta |
object |
自定义元数据 |
基础示例(React)
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
interface User {
id: number;
name: string;
email: string;
age: number;
}
const columnHelper = createColumnHelper<User>();
const columns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("name", {
header: "姓名",
cell: (info) => <strong>{info.getValue()}</strong>,
}),
columnHelper.accessor("email", {
header: "邮箱",
}),
columnHelper.accessor("age", {
header: "年龄",
cell: (info) => `${info.getValue()} 岁`,
}),
// 操作列(无数据字段)
columnHelper.display({
id: "actions",
header: "操作",
cell: ({ row }) => (
<button onClick={() => handleDelete(row.original.id)}>删除</button>
),
}),
];
function UserTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
3. 排序
import { getSortedRowModel, SortingState } from "@tanstack/react-table";
import { useState } from "react";
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// 表头渲染(点击切换排序)
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: header.column.getCanSort() ? "pointer" : "default" }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " ↑",
desc: " ↓",
}[header.column.getIsSorted() as string] ?? ""}
</th>
))}
</tr>
))}
4. 分页
import { getPaginationRowModel, PaginationState } from "@tanstack/react-table";
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
data,
columns,
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// 服务端分页时设置总行数
rowCount: totalCount,
manualPagination: true, // 服务端分页时开启
});
// 分页控件
<div>
<button onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()}>首页</button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>上一页</button>
<span>{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>下一页</button>
<button onClick={() => table.lastPage()} disabled={!table.getCanNextPage()}>末页</button>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>每页 {size} 条</option>
))}
</select>
</div>
5. 过滤
import {
getFilteredRowModel,
ColumnFiltersState,
} from "@tanstack/react-table";
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
state: { columnFilters, globalFilter },
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
// 全局搜索框
<input
placeholder="搜索..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
/>
// 列过滤输入框(表头下方)
{header.column.getCanFilter() && (
<input
value={(header.column.getFilterValue() as string) ?? ""}
onChange={(e) => header.column.setFilterValue(e.target.value)}
placeholder="过滤..."
/>
)}
6. 行选择
import { RowSelectionState } from "@tanstack/react-table";
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// 在列定义中添加复选框列
columnHelper.display({
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={(el) => {
if (el) el.indeterminate = table.getIsSomeRowsSelected();
}}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
}),
const table = useReactTable({
data,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
// 条件性禁用选择
// enableRowSelection: (row) => row.original.status !== "locked",
});
// 获取选中行的数据
const selectedRows = table.getSelectedRowModel().rows.map((r) => r.original);
7. 列可见性
import { VisibilityState } from "@tanstack/react-table";
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const table = useReactTable({
data,
columns,
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
});
// 列可见性控制面板
<div>
{table.getAllLeafColumns().map((column) => (
<label key={column.id}>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>
{column.id}
</label>
))}
</div>
8. 服务端模式(分页 + 排序 + 过滤)
function ServerSideTable() {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
// 同步到 TanStack Query
const { data, isFetching } = useQuery({
queryKey: ["users", { sorting, columnFilters, pagination }],
queryFn: () => fetchUsers({ sorting, columnFilters, pagination }),
});
const table = useReactTable({
data: data?.items ?? [],
columns,
rowCount: data?.total,
state: { sorting, columnFilters, pagination },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
manualFiltering: true,
manualPagination: true,
});
return (
<div>
{isFetching && <span>加载中...</span>}
{/* 渲染表格... */}
</div>
);
}
二、TanStack Form v1
1. 基础概念
TanStack Form 是什么
TanStack Form 是一个无头、类型安全的表单状态管理库,支持同步/异步验证,支持 React、Vue、Solid 等。
与 React Hook Form 对比:
| 特性 | TanStack Form | React Hook Form |
|---|---|---|
| 类型安全 | 完全类型推断 | 较好 |
| 验证适配器 | Zod / Valibot / Yup / ArkType | Zod / Yup |
| 渲染性能 | 字段级别隔离更新 | 受控表单 |
| SSR 支持 | 支持 | 支持 |
| 包体积 | 较小 | 小 |
安装
npm install @tanstack/react-form
# Vue
npm install @tanstack/vue-form
# Zod 适配器(可选)
npm install @tanstack/zod-form-adapter zod
2. 基础使用
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { zodValidator } from "@tanstack/zod-form-adapter";
const userSchema = z.object({
name: z.string().min(1, "姓名不能为空"),
email: z.string().email("邮箱格式不正确"),
age: z.number().int().min(18, "年龄必须大于等于 18"),
});
function CreateUserForm() {
const form = useForm({
defaultValues: {
name: "",
email: "",
age: 18,
},
validatorAdapter: zodValidator(),
validators: {
onChange: userSchema, // 整体 schema 验证
},
onSubmit: async ({ value }) => {
await createUser(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* 姓名字段 */}
<form.Field name="name">
{(field) => (
<div>
<label>姓名</label>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.length > 0 && (
<p style={{ color: "red" }}>{field.state.meta.errors.join(", ")}</p>
)}
</div>
)}
</form.Field>
{/* 邮箱字段 */}
<form.Field name="email">
{(field) => (
<div>
<label>邮箱</label>
<input
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((err) => (
<p key={err} style={{ color: "red" }}>{err}</p>
))}
</div>
)}
</form.Field>
<form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "提交中..." : "提交"}
</button>
)}
</form.Subscribe>
</form>
);
}
3. 字段参数说明
| 属性 | 类型 | 说明 |
|---|---|---|
field.state.value |
T |
字段当前值 |
field.state.meta.errors |
string[] |
验证错误信息 |
field.state.meta.isTouched |
boolean |
字段是否被触碰过(blur) |
field.state.meta.isDirty |
boolean |
值是否被修改过 |
field.state.meta.isValidating |
boolean |
异步验证进行中 |
field.handleChange(value) |
fn |
更新字段值 |
field.handleBlur() |
fn |
触发 blur 事件 |
4. 字段级单独验证
<form.Field
name="username"
validators={{
onChange: z.string().min(3, "用户名至少 3 个字符"),
// 异步验证(防抖 500ms)
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const exists = await checkUsernameExists(value);
if (exists) return "用户名已被使用";
return undefined;
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && <span>检查中...</span>}
{field.state.meta.errors.map((e) => <p key={e}>{e}</p>)}
</div>
)}
</form.Field>
5. 数组字段
<form.Field name="tags" mode="array">
{(field) => (
<div>
{field.state.value.map((_, i) => (
<form.Field key={i} name={`tags[${i}]`}>
{(subField) => (
<div>
<input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
<button
type="button"
onClick={() => field.removeValue(i)}
>
删除
</button>
</div>
)}
</form.Field>
))}
<button
type="button"
onClick={() => field.pushValue("")}
>
添加标签
</button>
</div>
)}
</form.Field>
6. 表单状态订阅
// 订阅指定状态,避免不必要的重渲染
<form.Subscribe selector={(state) => state.values}>
{(values) => <pre>{JSON.stringify(values, null, 2)}</pre>}
</form.Subscribe>
// 只订阅提交状态
<form.Subscribe selector={(s) => s.isSubmitting}>
{(isSubmitting) => (
<button disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "提交"}
</button>
)}
</form.Subscribe>
三、TanStack Virtual v3
1. 基础概念
TanStack Virtual 是什么
TanStack Virtual 是一个虚拟化库,用于高效渲染超长列表/表格。核心原理:只渲染可视区域内的元素,其余元素用空白占位,大幅减少 DOM 节点数量。
适用场景:
- 列表数据超过 100 条且不分页
- 下拉选项数量极多
- 与 TanStack Table 配合实现无限高性能表格
安装
npm install @tanstack/react-virtual
# Vue
npm install @tanstack/vue-virtual
2. 基础使用
虚拟列表(React)
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length, // 总条数
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // 估算每行高度(px)
overscan: 5, // 可视区域外额外渲染的行数(提升滚动流畅度)
});
return (
// 外层容器:固定高度,允许滚动
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
{/* 内层容器:总高度等于所有行高之和,用于占位 */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
);
}
3. 参数说明
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
count |
number |
必填 | 总条目数 |
getScrollElement |
() => Element | null |
必填 | 滚动容器 |
estimateSize |
(index) => number |
必填 | 估算每项高度/宽度 |
overscan |
number |
1 |
可视区外额外渲染的条目数 |
horizontal |
boolean |
false |
是否水平方向虚拟化 |
paddingStart |
number |
0 |
顶部填充 |
paddingEnd |
number |
0 |
底部填充 |
getItemKey |
(index) => string | number |
使用 index | 自定义 key |
initialOffset |
number |
0 |
初始滚动偏移 |
4. 动态高度列表
当每项高度不固定时,需要在渲染后测量实际高度:
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useCallback } from "react";
function DynamicHeightList({ items }: { items: { content: string }[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // 估算值,实际高度会在渲染后测量
measureElement: (element) => element?.getBoundingClientRect().height,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
// 通过 ref 回调让 virtualizer 测量实际高度
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
<p>{items[virtualItem.index].content}</p>
</div>
))}
</div>
</div>
);
}
5. 虚拟表格(与 TanStack Table 配合)
import { useReactTable, getCoreRowModel } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualTable({ data, columns }: { data: Row[]; columns: ColumnDef<Row>[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 10,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
// 上下空白填充
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} style={{ position: "sticky", top: 0, background: "#fff" }}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{paddingTop > 0 && <tr><td style={{ height: `${paddingTop}px` }} /></tr>}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr key={row.id} style={{ height: "40px" }}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
{paddingBottom > 0 && <tr><td style={{ height: `${paddingBottom}px` }} /></tr>}
</tbody>
</table>
</div>
);
}
6. 水平虚拟化
const virtualizer = useVirtualizer({
horizontal: true, // 水平方向
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150, // 列宽
});
7. 滚动到指定位置
const virtualizer = useVirtualizer({ ... });
// 滚动到第 100 条
virtualizer.scrollToIndex(100);
// 平滑滚动
virtualizer.scrollToIndex(100, { behavior: "smooth" });
// 滚动到指定偏移
virtualizer.scrollToOffset(500);
8. 无限滚动(与 TanStack Query 配合)
function InfiniteList() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["articles"],
queryFn: ({ pageParam }) => fetchArticles(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? allItems.length + 1 : allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
});
// 当最后一项进入视图时加载更多
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (!lastItem) return;
if (lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage, allItems.length]);
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const isLoaderRow = virtualItem.index > allItems.length - 1;
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow
? isFetchingNextPage ? "加载中..." : "没有更多数据"
: allItems[virtualItem.index].title}
</div>
);
})}
</div>
</div>
);
}
9. 最佳实践
容器高度必须固定
Virtual 的可视区域计算依赖容器有确定的高度,不能用 height: auto:
// 正确
<div style={{ height: "600px", overflow: "auto" }}>
// 正确(自适应屏幕剩余高度)
<div style={{ height: "calc(100vh - 120px)", overflow: "auto" }}>
// 错误:高度自适应,Virtual 无法工作
<div style={{ overflow: "auto" }}>
estimateSize 尽量准确
估算值越接近真实高度,首次渲染的布局越准确,避免滚动条抖动。动态高度场景下配合 measureElement 使用。
overscan 不宜过大
overscan 越大,视区外渲染的 DOM 越多,性能越差。通常 3-10 即可,滚动快的场景适当调大。
最佳实践
Table 列定义用 useMemo 包裹防止无限重渲染:columnDefs 数组每次渲染都是新引用,不 memo 会导致表格反复重建:
const columns = useMemo<ColumnDef<User>[]>(() => [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
], []);
服务端分页/排序/过滤时设置 manualPagination: true:让 TanStack Table 不在客户端处理数据,而是通过 onPaginationChange 回调把分页参数传给服务端 API,与 TanStack Query 配合使用。
Form 用 validator 实现字段级校验,避免整表提交才报错:在 fieldOptions.validators.onChange 中实时校验,用 field.state.meta.errors 展示错误,让用户即时感知输入问题。
Virtual 列表容器必须设置固定高度:useVirtualizer 需要知道滚动容器的高度,使用 CSS height: 400px 或 height: 100vh,不能用 height: auto,否则 getTotalSize() 为 0。
Virtual 动态高度用 measureElement 而非估算:estimateSize 只是初始估算,实际渲染后调用 measureElement 获取真实高度,保证滚动条比例和跳转定位准确。
常见陷阱
陷阱:Table 排序状态与服务端数据不同步
现象: 点击列头排序,客户端数据按本地数组排序,但与服务端排序逻辑不一致,导致数据错乱。
原因: 未设置 manualSorting: true,Table 默认对本地数据排序,而数据来自服务端已排序的分页结果。
解决: 设置 manualSorting: true,监听 onSortingChange,将排序参数传给 API 重新请求:
const table = useReactTable({
manualSorting: true,
onSortingChange: setSorting,
state: { sorting },
});
陷阱:Form defaultValues 异步加载后不更新
现象: 编辑表单中,defaultValues 通过 API 获取,但表单初始化时数据还未返回,之后数据到达也不更新字段。
原因: TanStack Form 初始化后 defaultValues 不再响应外部变化,需要在数据就绪后才创建表单实例。
解决: 用 TanStack Query 加载数据,isSuccess 为 true 后才渲染表单组件(或用 key={dataVersion} 强制重建)。
陷阱:Virtual 列表跳转到指定 index 时位置不准
现象: 调用 scrollToIndex(100) 后,页面滚动到大致位置,但偏差了几十像素。
原因: 动态高度列表中,index 100 之前的 item 实际高度与 estimateSize 估算不同,累计偏差造成位置误差。
解决: 滚动后用 align: 'start' 并等待 measureElement 完成,或先 scrollToIndex 两次(第二次确保已测量)。
参见
TanStack Query 完全指南
TanStack Router 完全指南
TypeScript完全指南
Vue3入门