tanstack table: 一个headless table 的基本用法
一个headless table 的用法
# tanstack
初始化一个react项目,然后安装`tanstack/react-table`,为了更好的理解工作原理,选择使用typescript。
```bash
$ npm create vite # 然后选择react
$ npm i @tanstack/react-table
```
# 1 基础的用法
```tsx
import { useState,ReactNode } from "react";
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
// ts中声明table中每行数据的形状 Person在框架中对应泛型TData
type Person = {
firstName: string
lastName: string
}
// 声明列的定义即表头的元数据信息,定义中需要声明两个泛型TData和TValue,TValue对应每个cell元素中的值,此处声明为ReactNode即可,可兼容string|jsx
const columns: ColumnDef
[] = [
{id: 'firstName', accessorKey: "firstName", header: "first name" , cell: info => {info.getValue()}},
{id: 'lastName', accessorKey: "lastName", header: "last name" ,cell: info => {info.getValue()}},
]
function Table(props: {data: Person[]}) {
const [data] = useState(props.data);
// useReactTable hook必须的元素, data即TData[]是表格的数据,columns是ColumnDef[]是表格表头元数据信息,getCoreRowMode必传参数
// getCoreRowModel是一个必须的参数,并且必须传入getCoreRowModel()函数,有点多此一举是因为tanstack的模块化开发导致的这个模块是required
// 其他模块还有过滤器/排序/分页等等。。
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// table的重要属性:1 getHeaderGroups() 返回多组表头,最常见的单行表头则长度为1
// header中重要的属性header.column.columnDef就是我们传入的列的定义
// 2 getRowModel()使用定义好的模块处理后得到的行数据,如果只定义了 getCoreRowModel 那就是全量数据,如果有排序则会排序模块也生效,以此类推
// RowModel中最重要的属性就是rows,是一个Row[],每一行Row又有多个Cell,每个cell与header是同级别都是最小的单元td级别
// cell可以获取到当前对应的列columnDef信息,固定的可以通过flexRender(cell.column.columnDef.cell, cell.getContext())完成使用coldef中定义的cell渲染方式,来渲染当前cell的value
return
{
table.getHeaderGroups().map(headerGroup=>
{
headerGroup.headers.map(header =>
{flexRender(header.column.columnDef.header, header.getContext())}
| )
}
)
}
{
table.getRowModel().rows.map(row =>
{row.getAllCells().map(cell =>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
| )}
)
}
}
export default Table
```
```tsx
const defaultData: Person[] = [
{
firstName: 'tanner',
lastName: 'linsley',
age: 24,
visits: 100,
status: 'In Relationship',
progress: 50,
},
{
firstName: 'tandy',
lastName: 'miller',
age: 40,
visits: 40,
status: 'Single',
progress: 80,
},
{
firstName: 'joe',
lastName: 'dirte',
age: 45,
visits: 20,
status: 'Complicated',
progress: 10,
},
]
```

从基本的用法中,可以看出,需要准备的数据主要是表头列的元数据定义`ColumnDef[]`和数据`TData[]`,然后调用`useReactTable` hook,传入这两个参数,返回一个`table`对象,这个对象中包含了表格的各种操作方法,比如`getHeaderGroups()`、`getRowModel()`可以分别获取表头和数据来进行渲染。这里注意要使用`flexRender(cell.column.columnDef.cell, cell.getContext())`才能使用列定义中cell的渲染方式进行渲染。
这里用到了`columns`和`data`传入`useReactTable`,但是实际上我们自己封装table不用hook,也能够进行简单的封装,就是把上述代码中`headerGroup.headers`换成`columns`即可,`row.getAllCells()`换成`data`。所以tanStack的封装在基础的用法上其实看不出有什么作用,他主要还是在多种功能的封装上,例如 排序 过滤 分页等等。
# 2 基础概念与类型
一个表格的基本组成如下图,其中比较抽象的一个概念是`column`,在图上虽然是圈出了一列,但是他其实并不包含这一列的数据,它更像是一种元数据的定义,`columnDef`是column中最重要的一个属性,定义这一列如何取值,如何渲染等等,他只是定义了这一列的基本信息,但是并没有真正的包含数据,真正的数据是由`row`来包含的。下面我们简单了解下几个重要的数据类型。

## ColumnDef
有三种具体的类型: `DisplayColumnDef`基础的展示列,`AccessorColumnDef`更易用的列定义快捷访问对象,`GroupColumnDef`分组列。
`DisplayColumnDef`包括后面`AccessorColumnDef`有四个非常基础的属性如下,`id`意义不多说,`header`是表头的样子,`footer`是表尾的样子,`cell`是这一列中每一个数据单元的样子。
```jsx
const columns: ColumnDef[] = [
{
id: "id",
header: "header", // 与id二者至少指定一个
cell: "cell", // 非必须
footer: "footer", // 非必须
}
]
```
把之前的例子中的columns进入如上述改造,得到效果如下,有三行cell的原因是数据data是有三行的。

我们说def定义了渲染方式,但是上面我们只是使用了固定的字符串,那么我们如何使用变量渲染呢?其实`header/cell`除了指定为字符串类型,也可以是一个函数(footer与header完全一致不再赘述)。
```jsx
const columns: ColumnDef[] = [
{
id: "名字",
header: info => {info.column.id}
, // 这个info是HeaderContext类型
cell: info => {info.row.original.firstName}
, // 这个info是CellContext类型
}
]
```

改为函数后,渲染如上图,我们在函数中获取了一些信息,并组合为jsx元素最终渲染了,这里需要注意`header`与`cell`定义为函数类型的时候,两者的参数即上面info类型是不同的,将其打印得到如下信息:

header的参数info中有三个属性,分别是
- `column`当前列的信息,`column.id`就是当前定义的id,`column.columnDef`就是当前这个def本身,其他信息暂时不需要关注。
- `header`记录的表头的一些信息,例如这个header位于的`headerGroup`,序号下标`index`,对应的列信息`column`该引用直接指向上面的column属性。
- `table`是当前的表实例,这个后续会比较有用,这里暂时不展开,知道这个table实例就是当前整个table实例,其他地方的table实例都是同一个即可。
cell的是每一个cell触发的时候运行的函数,info中也有`column`和`table`属性与上面完全相同,此外还有以下属性:
- `getValue`暂时不管等介绍`AccessorColumnDef`的时候再介绍,这里先不管。
- `getRenderValue`暂时不管
- `row`当前这一行,`row.index`是当前这一行在data中的下标,`row.original`就是当前这一行的data,其他信息先不关注。
- `cell`当前这个格子,cell中有记录自己属于哪一行`row`和哪一列`column`,以及默认`getValue`。
这里初学会觉得很疑惑,明明在这个函数中使用的东西很少,但是框架却提供了非常复杂、且繁多的信息,这是因为`tanstack`提供了非常高的灵活度,来帮助开发者自己二次开发表格组件。
接下来看`AccessorColumnDef`他就是对上面的`DisplayColumnDef`的进一步封装,他提供了更易用的快捷访问对象,让开发者更加方便的使用数据,并且可以更加方便的进行排序和过滤,所以多数时候直接选择`AccessorColumnDef`是明智的。
与前面不同的主要就是`accessorKey`属性,这个属性是必须的,它是用来指定当前列的数据在data中的key,这个key会作为id的默认值,和cell中`getValue`的默认返回的列,如下写法,可以得到与之前类似的效果。
```jsx
const columns: ColumnDef[] = [
{
accessorKey: "firstName",
header: info => {info.column.id}
,
cell: info => {info.getValue()}
,
}
]
```
而`GroupColumnDef`则是为了实现多行表头的,他就是为了将上面的def进行聚合分组
```jsx
const columns: ColumnDef[] = [
{
id: "g1",
header: ()=>name
,
columns: [
{
accessorKey: "firstName",
header: "firstName",
cell: info => {info.getValue()}
,
},
{
accessorKey: "lastName",
header: "lastName",
cell: info => {info.getValue()}
,
},
]
}
]
```
如上,可以达到下面效果。

到此我们了解了`ColumnDef`的基本概念,同时对`Table` `HeaderGroup` `Column` `Row` `Cell` `Header`这些类型和概念,也有了初步的了解,接下来我们看看`useReackTable`这个hook的返回值也就是`Table`类型。
## Table
`Table`是`useReactTable`返回的对象,也是上面`ColumnDef`的cell和header属性中函数的入参中的一员,这个对象可以说是包含了整个table的所有信息,包括前面图中看到的`HeaderGroup`、`Columns` `Rows` `Cell`等等。
在示例代码中我们可以看到使用了`table.getHeaderGroups()`获取当前所有的表头分组,进而遍历每一个`header`进行渲染,后续渲染数据的时候,又通过`table.getRowModel().rows`获取到了所有的`row`,然后遍历每一个`row`,每个`row`又通过`getAllCells()`获取到了其包含的`cell`。
`table`中的api非常多,我们需要结合场景来看。
## flexRender
这个函数是辅助渲染的,以cell中为例`flexRender(cell.column.columnDef.cell, cell.getContext())`这个函数其实就是帮我们做了类似这样的事情:
```jsx
typeof(cell.column.columnDef.cell) === 'string' ?
cell.column.columnDef.cell :
cell.column.columnDef.cell!(cell.getContext())
```
也就是他会判断我们在columnDef中定义的cell的渲染方式,如果直接定义的字符串那就直接返回该字符串,如果是个函数那就把当前cell上下文传入运行函数返回jsx。
# 3 高阶用法
开始高阶用法之前,还是需要将上面基础用法的代码准备好,我们在此基础上进行改动。
## 3.1 排序
排序只需要通过在hook中传入`getSortedRowModel: getSortedRowModel()`,然后使用`header.column.getToggleSortingHandler()`即可切换当前这一列的排序状态了,这个函数每次触发会依次将当前列的排序方式从 `false->asc->desc`切换,这个状态可以从`table.getState().sorting`中查看全局的排序状态,也可以通过`header.column.getIsSorted()`查看当前列的排序状态。
如下我们只需要改动几行代码,就可以实现一个对列可以排序的按钮。
```tsx
import { ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
....
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
...
{flexRender(header.column.columnDef.header,header.getContext())}
{header.column.getIsSorted() && (header.column.getIsSorted() === 'asc' ? '🔼' :'🔽')}
|
```

自定义排序方式可以在`ColumnDef`中指定`sortingFn`,如下,使用长度进行排序。
```jsx
{
accessorKey: "lastName",
header: "lastName",
sortingFn: (rowA, rowB) => rowA.original.lastName.length - rowB.original.lastName.length,
cell: info => {info.getValue()}
,
}
```
在自己的业务中捕捉修改排序状态,在hook中指定`onSortingChange`即可,函数的入参就是`table.getState().sorting`,可以获取到change后的排序状态,如果在自己的业务中需要修改排序状态,可以直接修改这个状态
```tsx
const [mysort, setMysort] = useState([])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: sorting => setMysort(sorting),
state: {sorting: mysort}
});
// 此时setMysort可以通过回调,让其他组件触发排序状态改变
```
默认是单列排序,如果需要多列排序,可以指定`enableMultiSort`为true,此时如果多列同时生效,需要按住`shift`去触发才可以。
```tsx
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableMultiSort: true,
// maxMultiSortColCount: 3, 默认多列可以是所有列,这里可指定最多3列
// isMultiSortEvent: function(e) { return e.shiftKey; }, 默认是shift按下,可以改成别的比如control或者不限制直接返回true
});
```
## 3.2 过滤
单列过滤与排序类似,我们在hook中引入`getFilteredRowModel: getFilteredRowModel(),`即可使用`header.column.getCanFilter()`和`header.column.setFilterValue()`了
```tsx
import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
....
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
...
{flexRender(header.column.columnDef.header,header.getContext())}
{header.column.getCanFilter() &&
{header.column.setFilterValue(()=>e.target.value)}}/>
}
|
```
默认的过滤函数就是`includes`,如果需要自定义过滤函数,在`ColumnDef`可以指定自定义的过滤函数,`filterFn`属性,返回布尔值,如下,是把filterValue转成数字,然后过滤lastName长度小于这个值的。
```tsx
{
accessorKey: "lastName",
header: "lastName",
cell: info => {info.getValue()}
,
filterFn: (row, colId, filterValue) => {
return row.original.lastName.length < parseInt(filterValue)
}
}
```
过滤状态的state是`columnFilters`与`sorting`格式类似都是个`{id: string, value: string}`的数组,id都是列的id。
同样如果想要自己管理这个`fitler`状态,可以指定`onColumnFiltersChange`钩子函数,与sorting的类似,这里不展开了。
## 3.3 全局过滤
全局过滤或者叫多列过滤,同样需要`getFilteredRowModel: getFilteredRowModel(),`,并且需要指定`state`,该值与自己的搜索框的value进行关联,代码如下
```tsx
....
const [filterTxt, setFilterTxt] = useState('')
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state:{
globalFilter: filterTxt,
},
// 全局过滤的函数,不指定就是任意一个cell包含filterValue就显示
globalFilterFn: (row, _, filterValue) => {
return Object.values(row.original).filter(v=>v.toString().includes(filterValue)).length > 0
},
});
...
{setFilterTxt(e.target.value)}}/>
....
```

修改`globalFilterFn`可以灵活的控制过滤的方式,比如排除某些列(也可以在def中enableGlobalFilter配置为false)。
## 3.4 调整列宽
hook中指定`columnResizeMode: 'onChange',`,然后再th中添加了一个span样式定义为一条竖线,这里主要是指定`onMouseDown={header.getResizeHandler()}`这样鼠标按下后的拖动过程都交给`header`来处理,框架就帮我们实现了拖动实时改变`table.totalSize`和`header.size`的过程了,我们只需要把这两个值设置到对应的dom样式中即可:
```tsx
....
const table = useReactTable({
data,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
}
...
....
{flexRender(header.column.columnDef.header,header.getContext())}
|
```

## 3.4 分页
上面场景都针对小数据量,当数据较多的时候就不得不分页,并且引入了另一个复杂的话题,也就是服务端交互的问题,例如对于上面的`sort`排序,如果table没有获取到全量的数据,而是分页的数据,`sort`要做的是把`sortby=xx&pageSize=xx&pageNum=xx`这样的请求传给服务端,让服务端负责返回当前页的数据集,一共有多少页等信息。
开启分页直接在hook中引入` getPaginationRowModel: getPaginationRowModel(),`开启,此时可以查看`table.getState().pagination`是记录的状态默认应该是`{pageIndex: 0, pageSize: 10}`,而`table.getPageCount()`则是记录了当前数据一共分了多少页,我们将数据data长度扩展为12,如下引入分页后,数据就会变成只展示前十个,这是因为分页默认页size10
```tsx
const table = useReactTable({
data,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
....
//tanstack中pageIndex也就是页号是从0开始,这个比较重要,所以展示数据的时候需要+1比较readable一点。
Page{table.getState().pagination.pageIndex+1} of {table.getPageCount()}
```

`server-side`以分页为例,服务端模式都会比客户端更麻烦一点,例如服务端分页的话,你需要去掉`getPaginationRowModel`,并且在hook中指定`pageCount`总页数的属性
```js
// server-side就需要自己维系data和pageIndex信息
const [data, setData] = React.useState(() => [...defaultData])
const [pageIndex, setPageIndex] = React.useState(0)
const table = useReactTable({
data,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
pageCount: 10,
manualPagination: true,
state:{
pagination:{
pageIndex,
pageSize: 10
}
},
autoResetPageIndex: false,
})
...
Page{table.getState().pagination.pageIndex+1} of {table.getPageCount()}
```