源码
useTable
import { reactive, watch, toValue, type MaybeRefOrGetter } from 'vue'
import { useAsyncState } from '@vueuse/core'
export interface UseTableOptions<T = any> {
/** 数据获取函数 */
request: (params: FetchParams) => Promise<FetchResult<T>>
/** 初始查询参数(除分页外的业务参数) */
queryParams?: MaybeRefOrGetter<Record<string, any>>
/** 是否立即执行 */
immediate?: boolean
/** 是否监听查询参数变化自动重新查询 */
watchQueryParams?: boolean
/** 默认分页配置 */
defaultPagination?: {
currentPage?: number
pageSize?: number
}
/** 参数处理函数(在发送请求前对参数进行处理) */
paramsHandler?: (params: FetchParams) => FetchParams
/** 数据转换函数 */
transform?: (data: T[]) => T[]
/** 请求成功回调 */
onSuccess?: (data: T[], total: number) => void
/** 请求失败回调 */
onError?: (error: unknown) => void
/** 数据字段 */
listField?: string
totalField?: string
}
export interface FetchParams extends Record<string, any> {
/** 当前页码 */
page: number
/** 每页数量 */
pageSize: number
}
export interface FetchResult<T = any> {
/** 数据列表 */
list: T[]
/** 总数 */
total: number
/** 其他字段 */
[key: string]: any
}
export function useTable<T = any>(options: UseTableOptions<T>) {
const {
request,
queryParams,
immediate = true,
watchQueryParams = true,
defaultPagination = {},
paramsHandler,
transform,
onSuccess,
onError,
listField = 'list',
totalField = 'total'
} = options
const {
currentPage: defaultCurrentPage = 1,
pageSize: defaultPageSize = 10
} = defaultPagination
// 分页状态
const pagination = reactive({
currentPage: defaultCurrentPage,
pageSize: defaultPageSize,
total: 0
})
// 获取查询参数
const getQueryParams = () => toValue(queryParams) ?? {}
/** 构建请求参数 */
const buildRequestParams = () => {
const totalParams = {
page: pagination.currentPage,
pageSize: pagination.pageSize,
...getQueryParams()
}
// 如果更严谨一些,可以进一步判断是否是函数
const finalParams = paramsHandler
? paramsHandler(totalParams)
: totalParams
return finalParams
}
// 实际的数据获取函数
const doFetch = async () => {
try {
// 如果提供了参数处理函数,则先处理参数
const result = await request(buildRequestParams())
pagination.total = result[totalField]
const finalData = transform
? transform(result[listField])
: result[listField]
onSuccess?.(finalData, result[totalField])
return finalData
} catch (err) {
onError?.(err)
throw err
}
}
// 使用 useAsyncState 管理异步状态
const {
state: data,
isLoading: loading,
error,
execute
} = useAsyncState<T[]>(doFetch, [], {
immediate,
resetOnExecute: false
})
// 刷新数据(保持当前分页)
const refresh = () => execute()
// 重新查询(重置到第一页)
const search = () => {
pagination.currentPage = 1
return execute()
}
// 分页变化处理
const handlePageChange = (page: number) => {
pagination.currentPage = page
execute()
}
// 每页数量变化处理
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.currentPage = 1
execute()
}
// 重置分页到初始状态
const resetPagination = () => {
pagination.currentPage = defaultCurrentPage
pagination.pageSize = defaultPageSize
}
// 监听查询参数变化,自动重新查询(可选)
if (queryParams && watchQueryParams) {
watch(
() => toValue(queryParams),
() => {
search()
},
{ deep: true }
)
}
return {
/** 表格数据 */
data,
/** 加载状态 */
loading,
/** 错误信息 */
error,
/** 分页信息 */
pagination,
/** 刷新数据(保持当前分页) */
refresh,
/** 重新查询(重置到第一页) */
search,
/** 分页变化处理 */
handlePageChange,
/** 每页数量变化处理 */
handleSizeChange,
/** 重置分页 */
resetPagination
}
}
export type UseTableReturn<T = any> = ReturnType<typeof useTable<T>>
useTableComponent
import { defineComponent, h } from 'vue'
import { ElTable, ElTableColumn, ElPagination } from 'element-plus'
import { useTable, type UseTableOptions } from '../use-table'
export interface ColumnConfig {
/** 列字段名 */
prop: string
/** 列标题 */
label: string
/** 列宽度 */
width?: number | string
/** 最小宽度 */
minWidth?: number | string
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
/** 是否固定列 */
fixed?: boolean | 'left' | 'right'
/** 其他 el-table-column 支持的属性 */
[key: string]: any
}
export interface UseTableComponentOptions<T = any> extends UseTableOptions<T> {
/** 列配置 */
columns: ColumnConfig[]
/** 表格属性 */
tableProps?: Record<string, any>
/** 分页属性 */
paginationProps?: Record<string, any>
}
export function useTableComponent<T = any>(
options: UseTableComponentOptions<T>
) {
const {
columns,
tableProps = {},
paginationProps = {},
...tableOptions
} = options
// 内部调用 useTable 获取所有逻辑
const tableState = useTable<T>(tableOptions)
// 返回一个 Vue 组件
const TableComponent = defineComponent({
name: 'DynamicTableComponent',
setup() {
return () =>
h('div', { class: 'table-component-wrapper' }, [
// 表格
h(
ElTable,
{
data: tableState.data.value,
vLoading: tableState.loading.value,
...tableProps
},
() =>
columns.map(col =>
h(ElTableColumn, {
key: col.prop,
...col
})
)
),
// 分页
h(ElPagination, {
'class': 'table-pagination',
'currentPage': tableState.pagination.currentPage,
'pageSize': tableState.pagination.pageSize,
'total': tableState.pagination.total,
'layout': 'total, sizes, prev, pager, next, jumper',
'pageSizes': [10, 20, 50, 100],
'onUpdate:currentPage': tableState.handlePageChange,
'onUpdate:pageSize': tableState.handleSizeChange,
...paginationProps
})
])
}
})
return TableComponent
}
设计思路
在日常中后台系统开发中,table 的查询占据了大量的篇幅,因此或多或少都会对 table 组件进行封装,那么应该如何进行封装呢?理想的状态当然是所有的情况都可以通过这个封装的 table 组件来完成。但是现实是随着业务的迭代,表格 ui 层的表现总会遇到目前封装的 table 无法实现的情况,但是表格的数据的方法可以说是有限的。所以在封装 table 的时候,我们应该把 ui 和 数据 进行分开。
那么如何分开呢?本文的方案是提供 两层 API,满足不同的场景需求。分层结构如图:
当业务的表格是标准化的、无特殊定制的情况时,那么通过更上层的 useTableComponent 就可以完成快速开发。而业务的表格 ui 需要高度自定义、需求复杂时,我们也可以使用 useTable 来简化数据的处理,专注于 ui 的开发。
useTable
函数签名
既然是处理数据,那么我们就应该想清楚,这个函数要接收什么,以及返回什么。
想要搞清楚接收什么参数,就要回顾开发中,我们拿到数据都做了什么,如图:
根据这些逻辑,我们可以写出 useTable 需要的参数类型,如下:
interface UseTableOptions {
/** 请求函数 */
request: (params:any) => Promise<any>
/** 初始查询参数(不包含分页的业务参数)*/
queryParams: any
/** 是否监听查询参数变化自动重新查询 */
watchQueryParams?: boolean
/** 是否立即执行 */
immediate?: boolean
/** 默认分页参数(这个分页参数在我的思考中,仅用于初始化)*/
defaultPagination?: {
currentPage?: number
pageSize?: number
},
/** 参数处理函数(在发送请求前对参数进行处理) */
paramsHandler?: (params: FetchParams) => FetchParams
/** 数据转换函数 */
transform?: (data: T[]) => T[]
/** 请求成功回调 */
onSuccess?: (data: T[], total: number) => void
/** 请求失败回调 */
onError?: (error: unknown) => void
/** 数据字段 */
listField?: string
totalField?: string
}
至于返回的参数,这个应该非常好确定。表格数据、数据总数、加载状态、分页信息、刷新数据的方法、重新查询(比如条件变更的时候,页码需要重置到当前页面)、分页变化处理、页容量变化处理。
那么,通过这个,我们就可以很好的写出这个 useTable 的函数签名,如下:
function useTable(options: UseTableOptions){
return {
data,
total,
loading,
pagination,
refresh,
search,
handlePageChange,
handleSizeChange
}
}
step1: 处理参数
根据上面的步骤,第一步就是获得最终的处理参数。因此我们需要有一个函数来构建完成的请求参数。我们接收的分页值是一个默认值,所以我们在 useTable 内部,需要单独创建一个分页参数对象,如下:
function useTable(options: UseTableOptions){
// 参数解构
const { defaultPagination, paramsHandler } = options
const {
currentPage: defaultCurrentPage = 1,
pageSize: defaultPageSize = 10
} = defaultPagination
// 分页状态
const pagination = reactive({
currentPage: currentPage,
pageSize: pageSize,
total: 0
})
// 因为 queryParams 可能是一个响应式对象,为了取值方便,使用 toValue 来进行提取
const getQueryParams = () => toValue(queryParams) ?? {}
/** 构建请求参数 */
const buildRequestParams = ()=>{
const totalParams = {
page: pagination.currentPage,
pageSize: pagination.pageSize,
...getQueryParams()
}
// 如果更严谨一些,可以进一步判断是否是函数
const finalParams = paramsHandler ? paramsHandler(totalParams) : totalParams
return finalParams
}
return {/**...*/}
}
step2: 完善请求函数
当具备参数之后,就可以来利用请求发送请求了,如下:
function useTable(options: UseTableOptions){
const {
request,
onSuccess,
onError,
transform,
listField = 'list',
totalField = 'total'
} = options
/** 省略构建参数的代码 */
/** 获取数据的请求 */
const fetchData = async () => {
try{
// 调用请求函数
const response = await request(buildRequestParams())
// 保存数据
pagination.total = response[totalField]
// 只保存总数是因为,后续具体的数据将使用 useAsyncState 管理
// 如果需要转换数据,就调用
const finalData = transform
? transform(result[listField])
: result[listField]
onSuccess?.(finalData, result[totalField])
return finalData
} catch (err){
onError?.(err)
throw err
}
}
return {/**...*/}
}
step3: useAsyncState
使用 useAsyncState 来管理的要做的事情也很简单,这个方法来自 vueuse。这个方法具体有什么作用,我就不在这里过多介绍了,如下:
function useTable(options: UseTableOptions){
const { immediate = true } = options
/** 省略构建参数的代码 */
/** 省略请求函数的代码 */
const { state: data, isLoading: loading, error, execute } = useAsyncState(doFetch, [], {
immediate,
resetOnExecute:false
})
}
step4: 完善剩余方法
现在只剩下一些辅助方法了,如下:
function useTable(options: UseTableOptions){
const { immediate = true } = options
/** 省略构建参数的代码 */
/** 省略请求函数的代码 */
/** 省略useAsyncState的代码 */
// 刷新数据-即保持当前查询条件不变,执行一次查询
const refresh = () => execute()
// 重新查询(重置到第一页)
const search = () => {
pagination.currentPage = 1
return execute()
}
// 分页变化处理
const handlePageChange = (page: number) => {
pagination.currentPage = page
execute()
}
// 每页数量变化处理
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.currentPage = 1
execute()
}
// 监听查询参数变化,自动重新查询(可选)
if (queryParams && watchQueryParams) {
watch(
() => toValue(queryParams),
() => {
search()
},
{ deep: true }
)
}
return {
/** 表格数据 */
data,
/** 加载状态 */
loading,
/** 错误信息 */
error,
/** 分页信息 */
pagination,
/** 刷新数据(保持当前分页) */
refresh,
/** 重新查询(重置到第一页) */
search,
/** 分页变化处理 */
handlePageChange,
/** 每页数量变化处理 */
handleSizeChange
}
}
useTableComponent
这个方法做的事情很简单:
- 调用 useTable 得到操作一个 table 需要的数据和方法
- 将这些数据和方法,绑定到一个 table 组件上,这个组件一般来说,选择更上层封装的更好,但是我这个例子中就简单的直接使用 el-table
- 返回一个组件。
代码如下:
interface ColumnConfig {
/** 列字段名 */
prop: string
/** 列标题 */
label: string
/** 列宽度 */
width?: number | string
/** 最小宽度 */
minWidth?: number | string
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
/** 是否固定列 */
fixed?: boolean | 'left' | 'right'
/** 其他 el-table-column 支持的属性 */
[key: string]: any
}
interface UseTableComponentOptions{
/** 列配置 */
columns: ColumnConfig[]
/** 表格属性 */
tableProps?: Record<string, any>
/** 分页属性 */
paginationProps?: Record<string, any>
}
function useTableComponent(options: UseTableComponentOptions){
const {
columns,
tableProps = {},
paginationProps = {},
...tableOptions // 剩余的属性参数
} = options
// 内部调用 useTable 获取所有逻辑
const tableState = useTable<T>(tableOptions)
// 返回一个 Vue 组件
const TableComponent = defineComponent({
name: 'DynamicTableComponent',
setup(){
return ()=>{
return h('div', { class: 'table-warpper' }, [
// 搜索表单,这里进行了省略
// 表格
h(
ElTable,
{
data: tableState.data.value,
vLoading: tableState.loading.value,
...tableProps
},
() =>
// 列组件
columns.map(col =>
h(ElTableColumn, {
key: col.prop,
...col
})
)
),
// 分页
h(ElPagination, {
'class': 'table-pagination',
'currentPage': tableState.pagination.currentPage,
'pageSize': tableState.pagination.pageSize,
'total': tableState.pagination.total,
'layout': 'total, sizes, prev, pager, next, jumper',
'pageSizes': [10, 20, 50, 100],
'onUpdate:currentPage': tableState.handlePageChange,
'onUpdate:pageSize': tableState.handleSizeChange,
...paginationProps
})
])
}
}
})
return TableComponent
}