- 前端
- UI组件
【免费下载链接】table
🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table
本文围绕 TanStack Table v9 Octane 框架适配器的列固定(Column Pinning)能力展开,系统讲解列固定的概念模型、状态管理方式、常用 API 以及“Sticky 单表固定”和“多表拆分固定”两种实现方案,并辅以仓库源码与测试作为实现依据。读完本文,你将掌握在
@tanstack/octane-table中从零搭建可固定列的数据表格、用外部 atom 或受控 state 管理固定状态、以及通过getStartHeaderGroups/row.getStartVisibleCells等分区 API 实现复杂固定布局的完整方法。
前置阅读与运行示例
本文以 docs/framework/octane/guide/column-pinning.md 为核心指南,源码实现位于packages/table-core与packages/octane-table。想直接看可运行代码,仓库提供了三个配套示例(位于examples/octane/下):
- Column Pinning(非拆分,单表重排)
- Column Pinning Split(拆分到三张表)
- Sticky Column Pinning(CSS sticky 固定)
每个示例目录都包含完整的src/main.tsrx渲染代码、index.css样式、index.html、vite.config.ts以及基于 Playwright 的端到端测试(如 column-pinning/tests/e2e/smoke.spec.ts),可直接pnpm install && pnpm dev在本地运行验证。
概念模型:逻辑化的 start / center / end 三区域
在 TanStack Table 中,列固定并不是简单地“把某列钉在左边或右边”,而是围绕逻辑位置(logical position)建立的三区域模型:
start:起始固定区。在 LTR(从左到右)语言/布局下通常对应左侧,在 RTL(从右到左)布局下对应右侧。center:未固定的中间区,随容器滚动。end:末尾固定区。在 LTR 下通常对应右侧,在 RTL 下对应左侧。
这一语义在核心类型ColumnPinningPosition = false | 'start' | 'end'与状态ColumnPinningState { start: string[]; end: string[] }中被固化,见 columnPinningFeature.types.ts。采用逻辑位置而非物理左/右的好处是:同一套状态与 API 天然兼容 RTL 国际化布局,无需为 RTL 单独改造。
启用列固定功能
列固定是 TanStack Table v9 的**可组合功能(feature)**之一,只有在tableFeatures({ columnPinningFeature })中注册后,相关状态切片与 API 才会挂载到表实例上。注册方式见下方代码:
import { useTable, tableFeatures, columnPinningFeature, } from '@tanstack/octane-table' const features = tableFeatures({ columnPinningFeature }) const table = useTable({ features, columns, data, })补充说明:
- 功能注册是可叠加的,例如列固定常与 列排序(column ordering)、列可见性(column visibility)、列宽(column sizing)一起注册。仓库示例 column-pinning/src/main.tsrx 就同时注册了
columnVisibilityFeature、columnPinningFeature、columnOrderingFeature。 - 更贴近业务的封装是
createTableHook:它可以把一组功能 + 调试开关固化成一个useAppTablehook,示例代码即采用这种模式。 useTable的第二参数是可选 selector,用于控制组件订阅的表状态子集(默认(state) => state)。由于 v9 的状态全部原子化,selector 可以让 Octane 只订阅当前组件关心的状态,参见 useTable.tsrx。
列固定如何影响列顺序
在 Octane 表中,一共有三种功能会重排列,它们按以下固定顺序依次生效:
- 列固定(Column Pinning):如果存在固定列,列被划分成
start、center(未固定)、end三组。 - 手动列排序(Column Ordering):再应用
columnOrder状态指定的顺序。 - 分组(Grouping):如果启用了分组、存在分组状态,且
tableOptions.groupedColumnMode为'reorder' | 'remove',分组列会被移动到列流的起始位置。
因此一个关键结论是:被固定列的顺序只能通过columnPinning.start与columnPinning.end状态本身改变,columnOrder状态只会影响未固定(center)列的相对顺序。这与列排序指南 column-ordering.md 的说明相互印证。从实现看,column_pin更新状态时先把 leaf 列 id 从两个区域中移除,再按追加顺序写入目标区域(columnPinningFeature.utils.ts),这就是“固定顺序 = 点击固定操作的先后顺序”这一行为(示例 e2e 测试中多次点击固定按钮后列顺序即按点击顺序排列)的底层原因。
列固定状态管理
默认内部状态
绝大多数场景下无需自己管理columnPinning状态:只要注册了功能且不额外传状态,表格内部会自动维护固定状态。默认状态为{ start: [], end: [] },见getDefaultColumnPinningState实现(columnPinningFeature.utils.ts)。
推荐:用外部 atom 拥有状态切片
在 v9 中,官方推荐的状态所有权方式是外部 atom(external atom):把状态切片作为 atom 传给表格的atoms选项。外部 atom 允许应用内任何位置做细粒度订阅,其他代码读写固定状态时不会触发拥有该表的组件整体重渲染。示例:
import { useCreateAtom, useSelector } from '@tanstack/octane-store' import { useTable, tableFeatures, columnPinningFeature, } from '@tanstack/octane-table' import type { ColumnPinningState } from '@tanstack/octane-table' const features = tableFeatures({ columnPinningFeature }) const columnPinningAtom = useCreateAtom<ColumnPinningState>({ start: [], end: [], }) const columnPinning = useSelector(columnPinningAtom) // 需要的地方均可订阅 const table = useTable({ features, //... atoms: { columnPinning: columnPinningAtom, }, //... })需要注意:当某个切片由外部 atom 拥有时,无需再传对应的onColumnPinningChange回调,表格 API 会直接写入该 atom。v9 的原子化状态模型(table.atoms/table.store)在 table-state.md 中有更完整的对比说明。
兼容:v8 风格受控 state
state.columnPinning+onColumnPinningChange的 v8 经典模式仍然受支持,适合简单集成或从 v8 迁移的代码:
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({ start: [], end: [], }) const table = useTable({ features, //... state: { columnPinning, //... }, onColumnPinningChange: setColumnPinning, //... })受控 state 每次更新都会重渲染拥有该表的组件,粒度不如外部 atom,因此官方指南仅在简单场景推荐使用。
默认固定某些列
最常见的需求是“初始就固定某几列”,两种写法效果相同:
- 通过
initialState(推荐,简单直接):
const table = useTable({ features, //... initialState: { columnPinning: { start: ['expand-column'], end: ['actions-column'], }, //... }, //... })- 直接初始化外部 atom / 受控 state(前面两个示例中把
start/end数组填上目标列 id 即可)。
仓库示例代码里也保留了这条注释示例:// initialState: { columnPinning: { start: ['firstName'], end: [] } },表明start/end跟随布局方向(见 column-pinning/src/main.tsrx)。
常用列固定 API 详解
以下 API 均要求注册columnPinningFeature(部分样式/顺序辅助 API 来自columnSizingFeature与columnOrderingFeature,示例中一并注册)。
列(Column)级 API
| API | 作用 |
|---|---|
column.getCanPin() | 判断该列(或其任一 leaf 列)是否允许固定。列级enablePinning与表级enableColumnPinning均默认true,任一 leaf 列允许即可返回true |
column.pin(position) | 把该列的 leaf 列固定到'start'/'end';传入false取消固定回到 center |
column.getIsPinned() | 返回'start'/'end'/false;分组列在其任一 leaf 列被固定时返回对应区域 |
column.getPinnedIndex() | 该列在其固定区域内的下标;未固定列返回0 |
column.getStart(position) | 返回该列在指定区域的starts偏移(px),用于设置insetInlineStart(来自 column sizing 功能) |
column.getAfter(position) | 返回该列之后所有可见列在指定区域的宽度之和(px),用于设置insetInlineEnd(来自 column sizing 功能) |
column.getIsLastColumn(position) | 该列是否为指定固定区域内的最后一列(适合给 start 区末尾加阴影) |
column.getIsFirstColumn(position) | 该列是否为指定固定区域内的第一列(适合给 end 区开头加阴影) |
实现要点:
column.getCanPin由叶子列决定,检查leafColumn.columnDef.enablePinning ?? true与table.options.enableColumnPinning ?? true,见 columnPinningFeature.utils.ts。column.pin会把分组列的整组 leaf 列一起固定:先移除两个区域中的旧 id,再追加进目标区域(同文件 L60-L99)。column.getIsPinned在“同一列同时出现在 start 与 end”这种异常状态下优先返回'start'(测试用例对此有明确覆盖,见 columnPinningFeature.utils.test.ts)。column.getIsFirstColumn/getIsLastColumn基于table_getPinnedVisibleLeafColumns(table, position)判断首尾(columnOrderingFeature.utils.ts),因此对隐藏列也天然正确。column.getStart/getAfter读取表格预计算的getColumnOffsets映射,默认尺寸不存在时回退为0(columnSizingFeature.utils.ts)。
表(Table)级 API
// 直接更新固定状态;支持函数式 updater table.setColumnPinning({ start: ['firstName'], end: ['actions'], }) // 重置:无参恢复到 initialState.columnPinning;传 true 清空两个区域 table.resetColumnPinning() table.resetColumnPinning(true) // 是否已有固定列;可传 'start' | 'end' 只检查单侧 table.getIsSomeColumnsPinned() table.getIsSomeColumnsPinned('start') table.getIsSomeColumnsPinned('end')table.resetColumnPinning()的实现会克隆initialState.columnPinning(不存在则用默认空状态),传true则忽略 initialState 直接置空(columnPinningFeature.utils.ts)。核心测试对“重置到默认/重置到初始”两种分支均有断言(columnPinningFeature.utils.test.ts)。
区域分区(region)API
表格实例为三个区域各提供一套 header / footer / leaf 列 / flat header / leaf header 辅助方法:
table.getStartLeafColumns() table.getCenterLeafColumns() table.getEndLeafColumns() table.getStartVisibleLeafColumns() table.getCenterVisibleLeafColumns() table.getEndVisibleLeafColumns() table.getStartHeaderGroups() table.getCenterHeaderGroups() table.getEndHeaderGroups() table.getStartFooterGroups() table.getCenterFooterGroups() table.getEndFooterGroups() table.getStartFlatHeaders() table.getCenterFlatHeaders() table.getEndFlatHeaders() table.getStartLeafHeaders() table.getCenterLeafHeaders() table.getEndLeafHeaders()行(Row)级也提供对应的可见单元格分区方法:
row.getStartVisibleCells() row.getCenterVisibleCells() row.getEndVisibleCells()此外还有两个按位置参数动态取值的快捷方法('start'/'center'/'end'):
table.getPinnedLeafColumns('start') table.getPinnedLeafColumns('center') table.getPinnedLeafColumns('end') table.getPinnedVisibleLeafColumns('start') table.getPinnedVisibleLeafColumns('center') table.getPinnedVisibleLeafColumns('end')各方法的语义与实现细节(均定义于 columnPinningFeature.utils.ts):
- Leaf 列:
getStartLeafColumns严格按state.columnPinning.start中的 id 顺序解析,跳过已不存在的陈旧 id;getCenterLeafColumns则是从getAllLeafColumns()中剔除 start + end 的 id(无固定时直接返回共享的 leaf 列数组)。 - Visible Leaf 列:在 leaf 列基础上再过滤
column.getIsVisible(),隐藏的固定列会被剔除。 - Header 组:start/end 区按各自区域 id 顺序组装 leaf 列后交给同一套
buildHeaderGroups(带区域前缀生成 group id,如start_0、center_1等,测试见 columnPinningFeature.utils.test.ts);center 区则先从可见 leaf 列中剔除固定列再构建。 - Footer 组:直接复用对应 header 组并反转顺序。
- Flat / Leaf Headers:flat 包含父级与占位 header;leaf header 过滤掉含子级 header 的父级。
- Row 可见单元格:
row.getStartVisibleCells/getEndVisibleCells按区域 id 顺序查找可见单元格并打上cell.position = 'start' | 'end'标记;row.getCenterVisibleCells在无固定时直接返回共享的可见单元格数组(测试见同文件 L426-L507)。
需要指出的是,这些方法均通过callMemoOrStaticFn记忆化,依赖项包含atoms.columnPinning、columnOrder、columnVisibility、grouping与groupedColumnMode等(见 columnPinningFeature.ts),因此任何影响列顺序或可见性的状态变化都会触发这些区域结果重新计算——这也是“固定、排序、可见性、分组可自由组合”的性能基础。
方案一:非拆分单表 + 重新排序
如果只需要“列被固定后自动挪到表头/表尾”的效果,不必拆分表格:仍然用table.getHeaderGroups()渲染表头、row.getVisibleCells()渲染行,核心逻辑会把固定列重新排列到正确位置。这就是 examples/octane/column-pinning 示例的做法(其src/main.tsrx顶部注释明确写着:This example using the non-split APIs. Columns are just reordered within 1 table instead of being split into 3 different tables.)。
该示例还演示了完整的交互闭环:
- 表头每个可固定列(
header.column.getCanPin()为真)渲染三个按钮:<=(固定到 start)、X(取消固定,即column.pin(false))、=>(固定到 end),并且根据column.getIsPinned()的当前值动态隐藏不适用按钮; - 配套 Playwright 测试验证了固定 start、固定 end、取消固定、连续固定多列四种交互下的列顺序与状态(smoke.spec.ts),例如固定
Visits到 start 后 leaf 顺序变为['Visits', 'firstName', 'Last Name', 'Age', 'Status', 'Profile Progress']。
方案二:Sticky CSS 固定(同一张表内视觉钉住)
当希望固定列“钉”在视口边缘、其余列横向滚动时,采用sticky CSS方案:所有列仍渲染在同一张<table>中,但对固定列施加position: sticky与对应的insetInlineStart/insetInlineEnd偏移。核心样式逻辑在 column-pinning-sticky/src/main.tsrx 中集中为一个getCommonPinningStyles函数:
const getCommonPinningStyles = ( column: Column<typeof features, Person>, ): Style => { const isPinned = column.getIsPinned() const isLastLeftPinnedColumn = isPinned === 'start' && column.getIsLastColumn('start') const isFirstRightPinnedColumn = isPinned === 'end' && column.getIsFirstColumn('end') return { boxShadow: isLastLeftPinnedColumn ? '-4px 0 4px -4px gray inset' : isFirstRightPinnedColumn ? '4px 0 4px -4px gray inset' : undefined, insetInlineStart: isPinned === 'start' ? `${column.getStart('start')}px` : undefined, insetInlineEnd: isPinned === 'end' ? `${column.getAfter('end')}px` : undefined, opacity: isPinned ? 0.95 : 1, position: isPinned ? 'sticky' : 'relative', width: column.getSize(), zIndex: isPinned ? 1 : 0, } }要点说明:
- 偏移来源:
column.getStart('start')返回该列在 start 区内的起点偏移,column.getAfter('end')返回 end 区该列之后所有可见列的宽度之和。它们由 column sizing 功能基于列宽预计算,因此必须先启用columnSizingFeature(示例的 features 里同时注册了columnResizingFeature与columnSizingFeature),否则偏移恒为 0,sticky 不会生效。 - 边界阴影:
column.getIsLastColumn('start')判断 start 区最后一列(加左侧内阴影),column.getIsFirstColumn('end')判断 end 区第一列(加右侧内阴影),让滚动时视觉上能区分固定区与滚动区。 - 样式可复用:该函数同时应用到
<th>、<td>(示例中 footer 单元格同理),保证表头、表体、表尾的固定行为一致;传入的style对象同时包含width: column.getSize(),使 sticky 偏移与列宽严格对应。 - CSS 前提:示例 index.css 中明确要求表格使用
border-collapse: collapse; border-spacing: 0; table-layout: fixed——因为box-shadow与position: sticky在 border-collapse 的其他取值下可能失效。容器.table-container负责横向滚动(overflow-x: scroll)。
方案三:拆分为三张独立表格
另一种实现是把固定列拆分到各自独立的<table>中渲染。此时不要用getHeaderGroups/getVisibleCells,而是按区域分别取数据:
- 表头:
table.getStartHeaderGroups()、table.getCenterHeaderGroups()、table.getEndHeaderGroups() - 表体:
row.getStartVisibleCells()、row.getCenterVisibleCells()、row.getEndVisibleCells()
examples/octane/column-pinning-split 示例正是这样实现的:页面用.split-tables容器并列渲染三张<table>(start 表、center 表、end 表),每张表的表头与表体只渲染各自区域的列(main.tsrx)。该方案适合与虚拟滚动、横向滚动容器配合,但需要自行对齐三张表的行高与视觉样式。拆分时同样可以复用getCommonPinningStyles里的逻辑为各区域列加边界样式。
渲染与订阅:FlexRender 与 Subscribe
三个示例在单元格/表头渲染上都使用了table.FlexRender(Octane 版的通用渲染器,见 FlexRender.ts),例如:
<th key={header.id} colSpan={header.colSpan}> {header.isPlaceholder ? null : <table.FlexRender header={header} />} </th>header.isPlaceholder用于跳过分组父级生成的占位 header。若需要让表格状态在组件树中细粒度响应,可配合table.Subscribe或useSelector订阅table.atoms.columnPinning;useTable的 selector 参数则负责控制拥有者组件自身的重渲染范围。
常见问题与排查建议
- 注册了
columnPinningFeature但column.getCanPin()恒为 false:检查表级enableColumnPinning与列级enablePinning是否被显式设为false(两者默认均为true,测试覆盖见 columnPinningFeature.utils.test.ts)。 - 固定列的顺序与预期不符:固定列顺序只由
columnPinning.start/end数组顺序决定(追加式更新);如需调整请通过table.setColumnPinning显式重排数组,columnOrder只影响 center 区。 - sticky 方案中固定列偏移为 0 或不滚动:确认已启用
columnSizingFeature(getStart/getAfter依赖列宽偏移计算),并检查表格是否设置了border-collapse: collapse与table-layout: fixed。 - 多表拆分后表头/表体列对不齐:三张表应共享同一列宽来源(如统一
column.getSize()),并注意 start/end 区渲染的是 leaf 列顺序而非定义顺序。
小结
TanStack Table Octane 的列固定围绕逻辑化的 start/center/end 三区域展开,通过可组合的columnPinningFeature提供从状态、列 API 到区域分区 API 的完整能力。实现层面有两种主流方案:sticky 单表(配合columnSizingFeature的偏移计算与 CSS 定位,实现简单、DOM 单一)与多表拆分(利用getStartHeaderGroups/row.getStartVisibleCells等分区 API,适合复杂滚动/虚拟化场景)。结合仓库中的三个 Octane 示例与 columnPinningFeature.utils.test.ts 的核心测试,你可以在自己的 Octane 项目中快速落地可固定列的数据表格,并从容扩展到排序、可见性、分组、列宽等功能的组合使用。
- 前端
- UI组件
【免费下载链接】table
🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table
相关推荐
TanStack Table React 列固定(Column Pinning)完整实践:start/end 逻辑区域、状态管理与拆分表格实现
TanStack Table React 列固定(Column Pinning)完整实践:start/end 逻辑区域、状态管理与拆分表格实现 本篇指南基于 T
前端UI组件TanStack Alpine Table 列固定(Column Pinning)完整实战指南:状态管理、API 用法与三种实现方案
TanStack Alpine Table 列固定(Column Pinning)完整实战指南:状态管理、API 用法与三种实现方案 本指南以 TanStack
前端UI组件TanStack Alpine Table 行固定(Row Pinning)完全指南:从状态管理到模板渲染
TanStack Alpine Table 行固定(Row Pinning)完全指南:从状态管理到模板渲染 行固定(Row Pinning)允许你把选中的行固定
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考