2024-07-21 17:49:03

在中后台开发里,表格查询基本是最高频的场景之一。列表、分页、筛选、刷新,这些逻辑几乎每个页面都会出现,所以大多数项目最后都会走到“封装表格”这一步。

但问题也很明显:如果只提供一个统一的 Table 组件,前期会很省事,后面随着业务变复杂,总会遇到一些 UI 定制场景,通过一层组件不能完全解决。

但数据层的逻辑其实相对稳定。我觉得更合适的做法,就是把这两部分拆开处理。

提供两层能力:

  • 底层提供 useTable,只处理数据请求、分页和状态管理
  • 上层提供 useTableComponent,用于标准化场景下的快速开发

如果页面没有太多特殊交互,直接用 useTableComponent 就够了;如果表格样式或者结构需要深度定制,就只使用 useTable,自己控制渲染。

useTable

既然 useTable 只负责数据层,那就先把它的输入和输出定义清楚。

入参设计:

interface DataFields {
  responseFields?: {
    list?: string
    total?: string
  }

  paginationFields?: {
    page?: string
    pageSize?: string
  }
}

export interface UseTableOptions<T = any> {
  /** 数据获取函数 */
  request: (params: FetchParams, signal?: AbortSignal) => Promise<Record<string, any>>
  /** 除分页之外的业务参数 */
  queryParams?: MaybeRefOrGetter<Record<string, any>>
  /** 是否立即执行 */
  immediate?: boolean
  /** 是否监听查询参数变化自动重新查询 */
  watchQueryParams?: boolean

  /** 默认分页配置 */
  defaultPagination?: DefaultPagination

  /** 发送请求前对参数进行处理 */
  paramsHandler?: (params: FetchParams) => FetchParams

  /** 数据转换函数 */
  transform?: (data: T[]) => T[]

  /** 请求成功回调 */
  onSuccess?: (data: T[], total: number) => void
  /** 请求失败回调 */
  onError?: (error: unknown) => void

  /** 请求和响应中的分页/列表字段映射 */
  dataFields?: DataFields
}

返回值为: 返回数据、加载状态、错误信息、分页状态,以及几个常用操作方法:

export interface UseTableResult<T = any> {
  data: Ref<T[]>
  loading: Ref<boolean>
  error: Ref<unknown>
  pagination: PaginationState
  refresh: () => Promise<T[] | undefined>
  search: () => void
  handlePageChange: (page: number) => void
  handleSizeChange: (pageSize: number) => void
}

export function useTable<T = any>(options: UseTableOptions<T>): UseTableResult<T> {

}

1. 处理分页和请求参数

表格请求一定绕不开分页,所以先把分页状态和字段映射处理掉。

export function useTable<T = any>(options: UseTableOptions<T>): UseTableResult<T> {
  const { defaultPagination = {}, paramsHandler, dataFields } = options
  const { page: defaultCurrentPage = 1, pageSize: defaultPageSize = 10 } = defaultPagination

  const listField = dataFields?.responseFields?.list ?? 'list'
  const totalField = dataFields?.responseFields?.total ?? 'total'
  const pageField = dataFields?.paginationFields?.page ?? 'page'
  const pageSizeField = dataFields?.paginationFields?.pageSize ?? 'pageSize'

  const pagination = reactive({
    page: defaultCurrentPage,
    pageSize: defaultPageSize,
    total: 0
  })

  // queryParams 可能是 ref、computed 或普通对象
  const getQueryParams = (): Record<string, any> | undefined => toValue(queryParams)

  const buildRequestParams = (): FetchParams => {
    const totalParams: FetchParams = {
      ...getQueryParams(),
      [pageField]: pagination.page,
      [pageSizeField]: pagination.pageSize
    }

    return paramsHandler ? paramsHandler(totalParams) : totalParams
  }
}
  1. dataFields 兼容不同接口的字段命名。
  2. paramsHandler 自定义参数的处理

2. 补齐请求逻辑

参数准备好之后,就可以开始处理真正的请求流程了。

我额外做了一层 AbortController 控制,避免连续触发查询时旧请求覆盖新请求。

const { request, onSuccess, onError, transform } = options

function getValueByPath<T = unknown>(source: Record<string, any>, path: string): T {
  return path.split('.').reduce<any>((value, key) => value?.[key], source)
}

async function fetchData(): Promise<T[]> {
  abortController?.abort()

  const controller = new AbortController()
  abortController = controller

  try {
    const response = await request(buildRequestParams(), controller.signal)

    if (controller.signal.aborted) {
      return latestData
    }

    const list = getValueByPath<T[]>(response, listField)
    const total = getValueByPath<number>(response, totalField)

    if (!Array.isArray(list)) {
      throw new TypeError(`Expected response.${listField} to be an array`)
    }

    if (typeof total !== 'number') {
      throw new TypeError(`Expected response.${totalField} to be a number`)
    }

    pagination.total = total
    onSuccess?.(list, total)

    const finalData = transform ? transform(list) : list
    latestData = finalData
    return finalData
  }
  catch (err) {
    if (controller.signal.aborted) {
      return latestData
    }

    onError?.(err)
    throw err
  }
  finally {
    if (abortController === controller) {
      abortController = undefined
    }
  }
}

getValueByPath 的作用比较直接:有些接口返回的数据结构不一定是平铺的,比如 data.listdata.total,这时候通过路径读取会更灵活。

3. 通过 useAsyncState 托管异步状态

请求函数准备好之后,状态管理就交给 useAsyncState 了。

const { immediate } = options

const { state: data, isLoading: loading, error, execute } = useAsyncState(fetchData, [], {
  immediate,
  resetOnExecute: false
})

这样 dataloadingerror 这些状态就不需要自己再手动维护一遍。

4. 补充表格常用操作

最后再把刷新、查询、翻页这些高频操作补齐,useTable 基本就成型了。

const refresh = (): Promise<T[] | undefined> => execute()

function search(): void {
  pagination.page = 1
  execute()
}

function handlePageChange(page: number): void {
  pagination.page = page
  execute()
}

function handleSizeChange(pageSize: number): void {
  pagination.pageSize = pageSize
  pagination.page = 1
  execute()
}

// 监听查询参数变化,自动重新查询
if (queryParams && watchQueryParams) {
  watch(
    () => toValue(queryParams),
    () => {
      search()
    },
    { deep: true }
  )
}

完整代码

import type { FetchParams, UseTableOptions, UseTableResult } from './types'
import { useAsyncState } from '@vueuse/core'
import { reactive, toValue, watch } from 'vue'

function getValueByPath<T = unknown>(source: Record<string, any>, path: string): T {
  return path.split('.').reduce<any>((value, key) => value?.[key], source)
}

export function useTable<T = any>(options: UseTableOptions<T>): UseTableResult<T> {
  const {
    request,
    queryParams,
    immediate = true,
    watchQueryParams,
    defaultPagination = {},
    paramsHandler,
    transform,
    onError,
    onSuccess,
    dataFields
  } = options

  const listField = dataFields?.responseFields?.list ?? 'list'
  const totalField = dataFields?.responseFields?.total ?? 'total'
  const pageField = dataFields?.paginationFields?.page ?? 'page'
  const pageSizeField = dataFields?.paginationFields?.pageSize ?? 'pageSize'
  const { page: defaultCurrentPage = 1, pageSize: defaultPageSize = 10 } = defaultPagination

  const pagination = reactive({
    page: defaultCurrentPage,
    pageSize: defaultPageSize,
    total: 0
  })

  const getQueryParams = (): Record<string, any> | undefined => toValue(queryParams)
  let abortController: AbortController | undefined
  let latestData: T[] = []

  const buildRequestParams = (): FetchParams => {
    const totalParams: FetchParams = {
      ...getQueryParams(),
      [pageField]: pagination.page,
      [pageSizeField]: pagination.pageSize
    }
    return paramsHandler ? paramsHandler(totalParams) : totalParams
  }

  const fetchData = async (): Promise<T[]> => {
    abortController?.abort()

    const controller = new AbortController()
    abortController = controller

    try {
      const response = await request(buildRequestParams(), controller.signal)

      if (controller.signal.aborted) {
        return latestData
      }

      const list = getValueByPath<T[]>(response, listField)
      const total = getValueByPath<number>(response, totalField)

      if (!Array.isArray(list)) {
        throw new TypeError(`Expected response.${listField} to be an array`)
      }

      if (typeof total !== 'number') {
        throw new TypeError(`Expected response.${totalField} to be a number`)
      }

      // 只保存 total,是因为 data / loading / error 交给 useAsyncState 管理
      pagination.total = total
      onSuccess?.(list, total)

      const finalData = transform ? transform(list) : list
      latestData = finalData
      return finalData
    }
    catch (err) {
      if (controller.signal.aborted) {
        return latestData
      }

      onError?.(err)
      throw err
    }
    finally {
      if (abortController === controller) {
        abortController = undefined
      }
    }
  }

  const { state: data, isLoading: loading, error, execute } = useAsyncState(fetchData, [], {
    immediate,
    resetOnExecute: false
  })

  const refresh = (): Promise<T[] | undefined> => execute()

  const search = (): void => {
    pagination.page = 1
    execute()
  }

  const handlePageChange = (page: number): void => {
    pagination.page = page
    execute()
  }

  const handleSizeChange = (pageSize: number): void => {
    pagination.pageSize = pageSize
    pagination.page = 1
    execute()
  }

  // 监听查询参数变化,自动重新查询
  if (queryParams && watchQueryParams) {
    watch(
      () => toValue(queryParams),
      () => {
        search()
      },
      { deep: true }
    )
  }

  return {
    /** 表格数据 */
    data,
    /** 加载状态 */
    loading,
    /** 错误信息 */
    error,
    /** 分页信息 */
    pagination,
    /** 刷新数据(保持当前分页) */
    refresh,
    /** 重新查询(重置到第一页) */
    search,
    /** 分页变化处理 */
    handlePageChange,
    /** 每页数量变化处理 */
    handleSizeChange
  }
}

useTableComponent

如果业务场景比较标准,其实没必要每次都自己写一遍 el-tableel-pagination。这时候就可以在 useTable 之上,再包一层返回组件的能力。

也就是 useTableComponent

interface ColumnConfig {
  prop: string
  label: string
  width?: number | string
  minWidth?: number | string
  align?: 'left' | 'center' | 'right'
  fixed?: boolean | 'left' | 'right'

  [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

  const tableState = useTable<T>(tableOptions)
}

这里的思路很简单:useTableComponent 不重新实现查询逻辑,只是消费 useTable 返回的数据和方法,然后拼出一个标准表格组件。

  // 返回一个 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
}

这样一来,整个表格封装就形成了两层:

  • useTable 负责稳定的数据逻辑
  • useTableComponent 负责标准化场景下的快速渲染