阅读视图

发现新文章,点击刷新页面。

【配置化 CRUD 02】 useTable Hooks:列表查询、分页封装

一:前言

在业务的CRUD开发体系中 ,几乎所有业务模块都需要实现列表查询与分页功能,你是不是经常会看到类似代码:

// 1.每个页面都要单独引入接口 
import { getListApi } from '@/api/user' 

// 2. 定义分页列表参数(每个列表页都要写一遍)
const current = ref(1) 
const pageSize = ref(10) 
const total = ref(0) 
const tableData = ref([]) 
const loading = ref(false)
const searchParams = ref({})

// 3. 重复拼接请求参数(分页+搜索)
const getListData = async () => { 
  loading.value = true 
  try {
  const params = { 
      current: current.value, 
      pageSize: pageSize.value, 
      ...searchParams.value 
     } 
  const res = await getListApi(params) 
  // 重复解析响应数据(每个页面可能还要适配不同后端格式)
  tableData.value = res.data.list 
  total.value = res.data.total 
  }catch (error) { 
     console.error('列表查询失败:', error) } 
  finally { 
    loading.value = false 
  } 
}

// 4. 重复编写分页联动监听
watch([current, pageSize], async () => { await getListData() }, 
{ immediate: true })
...

类似这样的代码,每新增一个列表页就需要几乎完整复制一遍,基于此,我们针对性封装 useTable Hooks,延续配置化 CRUD体系「通用逻辑内聚、业务逻辑外溢」的设计原则,将列表查询请求、分页参数管理、加载状态控制、查询与分页联动等通用逻辑全部抽离,支持自定义请求接口、灵活配置分页参数...,可联动专栏前一期的搜索重置组件,这样在实现列表页时,无需关注底层的查询与分页逻辑,只需传入简单配置,即可快速实现列表查询与分页功能,专注于业务本身的表单配置与数据展示。

文章指路(一起食用,味道更加):

1.动态表单深度实践: juejin.cn/post/757946…

2.搜索重置组件:juejin.cn/post/760056…

二:useTable hooks实现

首先明确useTable Hooks的实现目标,所有代码实现均围绕目标展开:

1.冗余逻辑抽离:将分页参数定义、列表查询请求、分页联动、搜索联动、表格操作等通用逻辑全部内聚;

2.配置化适配:支持通过入参灵活配置请求接口、响应数据格式、分页参数、默认参数等,适配不同业务模块、不同后端接口规范;

3.联动兼容:支持与前一期封装的搜索重置组件联动,同时适配表格组件,提供实例注册、选中行获取、表格列设置等方法,实现全流程联动;

...

接下来我们开始实现:

2.1.useTable Hooks Props类型约束

interface UseTableConfig<T = any> {
    // 请求Url 或函数
    getListApi: string | Function
    // 返回数据格式配置 -- 可指定对应列表和总数的对应字段
    response?: {
        list: string
        total?: string
    }
    // 接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    initParams?: Object 
    // 搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    searchInitParams?: Object 
    // 针对table的相关配置
    tableObject?: Object
}

2.2 数据状态:统一维护表格、搜索组件、初始参数等相关状态

// 表格组件实例引用,用于调用表格内部方法
const tableRef = ref(null) 
// 接收搜索组件传递的实时参数,参与响应式更新!
const searchParamObj = ref({}) 
// 表格相关
const tableObjects = ref({
    current: 1,    // 默认当前页为1
    pageSize: 10, // 默认页大小为10
    pageSizeOptions: ['5', '10', '15', '20'], // 默认分页大小可选值
    total: 0, // 数据总数,初始为0
    tableData: [], // 表格渲染数据,初始为空数组
    // 允许外部传入覆盖默认配置
    ...tableObject, 
    loading: false, // 表格加载状态,控制加载动画
})

// 利用`computed`计算属性自动组装请求参数
const parmsObj = computed(() => {
    return {
        current: tableObjects.value.current, // 分页-当前页
        pageSize: tableObjects.value.pageSize, // 分页-页大小
        ...initParams, // 固定参数(不响应式)
        ...searchInitParams, // 搜索默认参数(不响应式)
        ...searchParamObj.value, // 搜索实时参数(响应式)
    }
})

2.3 核心方法:封装查询、表格操作与搜索联动逻辑

2.3.1 register:注册表格ref

// 注册表格ref
const register = (ref: any) => {
   tableRef.value = ref
}
// 获取表格实例
const getTable = () => {
   return tableRef.value
}

2.3.2 getListData : 获取列表数据

const getListData = async () => {
     tableObjects.value.loading = true
     console.log('getListData::列表调用参数:', parmsObj.value)
     try {
           const res = await Http({
                 method,
                 url:getListApi,
                 params:parmsObj.value
            })
           tableObjects.value.total = res[response?.total || 'total']
           tableObjects.value.tableData = res[response?.list || 'list']
           tableObjects.value.loading = false
        } catch (error) {
           message.error(error)
        }
    }

2.3.3 setSearchParams : 搜索联动

const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            // 重置
            searchParamObj.value = {}
        }
        // 是否获取数据
        getList && getListData()
    }

2.3.4 setRowChecked : 设置列选择

const setRowChecked = (row: any) => {
     const table = getTable() as any
     table && table.toggleRowSelection(row, true)
}

...

2.4 联动监听:分页参数变化自动触发查询

// 监听当前页变化,自动刷新列表
watch(() => tableObjects.value.current, async () => {
    getListData()
}, { immediate: true, deep: true })

// 监听页大小变化,自动刷新列表
watch(() => tableObjects.value.pageSize, async () => {
    getListData()
}, { immediate: true, deep: true })

2.5 API统一暴露:对外提供简洁易用的调用接口

return {
    // 核心方法集合,供外部调用
    methods: { 
        getListData, // 列表查询
        getTable, // 获取表格实例
        setRowChecked, // 列选择
        setSearchParams, // 设置查询参数
    },
    tableRef, // 表格实例引用(可选暴露,供外部手动操作)
    register, // 表格实例注册方法(表格组件必须调用)
    tableObject: tableObjects.value, // 分页与表格核心状态(响应式,供表格组件绑定)
}

2.6 完整代码

import { ref, watch, computed } from 'vue'
interface UseTableConfig<T = any> {
    getListApi: string | Function
    // 请求方式
    method?: 'get' | 'post'
    //返回数据格式配置 -- 可指定对应列表和总数的取值
    response?: {
        list: string
        total?: string
    }
    initParams?: Object //接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    searchInitParams?: Object //搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    tableObject?: Object // 针对table的相关配置
}

export const useTable = (
    config: UseTableConfig
) => {
    const { method = 'get',getListApi, response = {}, initParams = {}, tableObject = {}, searchInitParams = {} } = config
    const tableRef = ref(null)
    // 接收搜索组件传递的实时参数,参与响应式更新!
    const searchParamObj = ref({})
    //表格配置
    const tableObjects = ref({
        current: 1,    // 当前页
        pageSize: 10, // 页限制
        pageSizeOptions: ['5', '10', '15', '20'], // 分页配置
        total: 0, // 总数
        tableData: [], // 表格数据
        ...tableObject,
        loading: false, //表格加载状态
    })
    const parmsObj = computed(() => {
        return {
            current: tableObjects.value.current,
            pageSize: tableObjects.value.pageSize,
            ...initParams, // 固定限制参数
            ...searchInitParams, // 搜索默认参数(不响应式)
            ...searchParamObj.value, // 搜索实时参数(响应式)
        }
    })
    const getListData = async () => {
        tableObjects.value.loading = true
        console.log('getListData::列表调用参数:', parmsObj.value)
        try {
            const res = await Http({
              method,
              url:getListApi,
              params:parmsObj.value
            })
            tableObjects.value.total = res[response?.total || 'total']
            tableObjects.value.tableData = res[response?.list || 'list']
            tableObjects.value.loading = false
        } catch (error) {
            message.error(error)
        }
    }
    //统一拿到table的Ref
    const getTable = () => {
        return tableRef.value
    }
    //获取表格选中的列
    const getSelections = async () => {
        const table = getTable() as any
        return table.getSelectionRows()
    }
    //从外界直接传入的表格列参数
    const setcolumns = (columns: any) => {
        const table = getTable() as any
        table && table?.setcolumns(columns)
    }
    //设置列选择
    const setRowChecked = (row: any) => {
        const table = getTable() as any
        table && table.toggleRowSelection(row, true)
    }
    //注册表格ref
    const register = (ref: any) => {
        tableRef.value = ref
    }
    // 支持从外面设置搜索参数,并查询
    const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            searchParamObj.value = {}
        }
        getList && getListData()
    }
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.current, async () => {
        getListData()
    },
        { immediate: true, deep: true })
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.pageSize, async () => {
        getListData()
    }, { immediate: true, deep: true })

    return {
        methods: {
            getListData,
            getTable,
            getSelections,
            setcolumns,
            setRowChecked,
            setSearchParams,
        },
        tableRef,
        register,
        tableObject: tableObjects.value,
    }
}

三: 实战使用(useTable结合搜索、表格组件)

页面引入Hooks使用:

const { methods, register, tableObject } = useTable({
  getListApi: 'test/list',
  initParams: {
    pageType:"testPage"
  },
  searchInitParams:{
    age:10,
    name:"初始化名字"
  }
})

const { getListData, setSearchParams } = methods

getListData()

搜索组件联动:search和reset方法改造

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                  prop: 'age',
                  path: 'componentProps.placeholder',
                  value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
    initValue: 10,
    formItemProps: {
      rules:[
      {
        required: true,
        message: '请输入年龄',
        trigger: 'blur'
      }
    ]
    }
  },
  {
    label: '上学阶段',
    prop: 'jieduan',
    component: 'Select',
    componentProps: {
        options: [
          {
            label: '幼儿园',
            value: 1
          },
          {
            label: '其他阶段',
            value: 2
          }
        ]
      }
  },
]

// 改动前:
<Search 
   ref="searchRef" 
   :schema="searchColumn" 
   @search="(params) => console.log('点击查询:',{params})" 
   :showReset="false" 
   :isVaildSearch="true"
   > 
</Search>

// 改动后:
<Search
   ref="searchRef"
   :schema="searchColumn"
   @search="setSearchParams"
   :showReset="false" 
   :isVaildSearch="true"
>
</Search>

表格组件支持:提供register方法、及相关props接收

const tablecolumns = [
  {
    label: '姓名',
    prop: 'name',
    minWidth: '140px',
  },
  {
    label: '年龄',
    prop: 'age',
      minWidth: '140px',
  },
  {
     label: '上学阶段',
     prop: 'jieduan',
    minWidth: '140px',
  }
]

 <Comtable
     v-model:current="tableObject.current"
     v-model:pageSize="tableObject.pageSize"
     :rowKey="'id'"
     :columns="tablecolumns"
     :dataList="tableObject.tableData"
     :pagination="tableObject"
     :loading="tableObject.loading"
     @register="register"
   >
      <template #operation="{ row }">
         <el-button
            type="primary"
            size="small"
            link
            @click="editClick(row)"
            >{{$t('Edit')}}</el-button
         >
      </template>
</Comtable>

运行截图:

初始状态:

image.png

输入搜索:

image.png

分页变化:

image.png

以上就是useTable Hooks 的封装过程以及实战案例~

【配置化 CRUD 01】搜索重置组件:封装与复用

一:前言

在后台管理系统的配置化 CRUD 开发中,搜索+重置是高频组合场景,几乎所有列表页都需要通过搜索筛选数据、通过重置恢复初始查询状态等等...。基于此,本文将详细讲解「搜索重置组件」的封装思路及使用方法,该组件基于Vue3 + Element Plus 开发,支持配置化扩展、响应式联动,可直接集成到配置化 CRUD 体系中,提升开发效率与代码一致性。

二:解决问题

封装搜索重置组件主要为了解决以下几个问题:

1.代码冗余 : 每个列表页都要重复编写表单结构、搜索按钮、重置按钮,以及对应的点击事件、数据校验逻辑;

2.风格不统一:不同开发人员编写的搜索表单,在布局、按钮尺寸、标签宽度、间距等细节上可能存在差异;

3.维护成本高:当需要修改搜索表单的布局、按钮样式,需要逐个页面排查修改;

...

三:具体实现

在开始之前,请先阅读一下本专栏的第一篇文章,动态表单的实现是搜索重置组件的基础:

juejin.cn/post/757946…

image.png

接下来我们可以思考一下一个通用的搜索重置组件具备的基本功能

1.搜索项的展示

2.搜索项默认展示搜索初始值

3.搜素与重置按钮功能

...

扩展功能:

1.搜索表单项的联动

2.搜索表单项的校验

...

接下来我们一步一步来实现:

3.1 基础功能实现

先完成最简单的部分 : 展示搜索项和搜索重置按钮、以及基本样式统一处理

组件基础实现:


type SearchPrams = {
  schema?: FormOptions[] // 配置表
  search?: () => void  // 搜索回调
  reset?: () => void   // 重置回调
  labelWidth?: string,
  flex?: number
}
const props = withDefaults(defineProps<SearchPrams>(), {
  schema: {},
  labelWidth:'140px',
  flex:5
})

const codeFormRef = ref(null)

//搜索
const search = async () => {
  const data = await codeFormRef?.value?.getData()
  emits('search', data)
}

//重置
const reset = () => {
  codeFormRef?.value?.resetFields('')
  emits('reset', {})
}

 <div class="sea-box">
    <CodeForm
      class="form-box"
      :style="{ flex: props?.flex || 5 }"
      layoutType="cell"
      ref="codeFormRef"
      :schema="schema"
      :labelWidth="props.labelWidth"
    >
    </CodeForm>
    <div class="sea-btn-box">
      <div>
        <ElButton
          type="primary"
          :style="{ width: '80px' }"
          @click="search"
          >{{ $t('Search') }}</ElButton
        >
        <ElButton
          :style="{ width: '80px', marginLeft: '15px' }"
          @click="reset"
          >{{ $t('Reset') }}</ElButton
        >
      </div>
    </div>
  </div>
  
 <style scoped>
    .sea-btn-box {
      flex: 1;
      display: flex;
      justify-content: flex-end;
    }
    .form-box {
      flex: 5;
    }
    .sea-box {
      display: flex;
      padding: 20px;
      padding-bottom: 0;
      padding-top: 0;
    }
</style>

外部定义配置表:

  const searchColumn = [
      {
        label: '姓名',
        prop: 'name',
        component: 'Input',
      },
      {
        label: '年龄',
        prop: 'age',
        component: 'Input',
      },
      {
        label: '上学阶段',
        prop: 'jieduan',
        component: 'Select',
        componentProps: {
            options: [
              {
                label: '幼儿园',
                value: 1
              },
              {
                label: '其他阶段',
                value: 2
              }
            ]
          }
      },
]

引入组件使用:

 <Search
    :schema="allshema.searchcolumns"
    @search="(params) => console.log('点击查询:',{params})"
    @reset="() => setSearchParams({}, true, true)"
   >
</Search>

运行截图:

image.png

到这一步我们就已经实现了基本功能:展示表单、统一风格、查询重置

当然我们可能会想要某些表单项具有初始值,或者不展示重置按钮,只要组件内部稍加改造一下就行:


type SearchPrams = {
  showSearch?: boolean // 展示搜索按钮
  showReset?: boolean // 展示重置按钮
  schema?: any // 配置表
  search?: () => any
  reset?: () => any
  labelWidth?: string,
  flex?: number
}

 <div class="sea-btn-box">
      <div>
        <ElButton
          v-if="showSearch"
          type="primary"
          :style="{ width: '80px' }"
          @click="search"
          >{{ $t('Search') }}</ElButton
        >
        <ElButton
          v-if="showReset"
          :style="{ width: '80px', marginLeft: '15px' }"
          @click="reset"
          >{{ $t('Reset') }}</ElButton
        >
      </div>
 </div>

外部引入:

const searchColumn = [
      {
        label: '姓名',
        prop: 'name',
        initValue: '初始化名字',
        component: 'Input',
      },
      ...
  ]
  <Search
     :schema="searchColumn"
     @search="(params) => console.log('点击查询:',{params})"
     :showReset="false"
    >
 </Search>
 

运行截图:

image.png

这样就实现了按钮的展示与隐藏以及初始化默认值。

3.2 扩展功能实现

接下来我们继续实现一下扩展功能:

1.表单项的联动

利用动态表单组件内置的 setValues、setSchemas方法,

组件内部增加方法定义及暴露:


const setValues = (data: any) => {
  codeFormRef?.value?.setValues(data)
}

const setSchemas = (data: any) => {
  codeFormRef?.value?.setSchemas(data)
}

defineExpose({
  getData,
  setValues,
  setSchemas
})

外部增加搜索组件的ref引用:

const searchRef: any = ref(null)

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                prop: 'age',
                path: 'componentProps.placeholder',
                value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
  },
  ...
]

<Search
   ref="searchRef"
   :schema="allshema.searchcolumns"
   @search="setSearchParams"
   @reset="() => setSearchParams({}, true, true)"
  >
</Search>

运行截图:

image.png

这样就实现了搜索表单项之间的联动。

2.表单项的校验

组件内部改动:

type SearchPrams = {
  showSearch?: boolean // 展示搜索
  showReset?: boolean // 展示重置按钮
  isVaildSearch?: boolean // 是否校验搜索
  schema?: any // 配置表
  search?: () => any
  reset?: () => any
  labelWidth?: string,
  flex?: number
}

const props = withDefaults(defineProps<SearchPrams>(), {
  showSearch: true,
  showReset: true,
  isVaildSearch: false,
  schema: {}, // 表单配置
  labelWidth:'140px',
  flex:5
})

const search = async () => {
  if(props.isVaildSearch) {
    const valid = await codeFormRef?.value?.validate();
    if(!valid) return;
  }
  const data = await codeFormRef?.value?.getData()
  emits('search', data)
}

外部引入使用:

const searchColumn = [
    ...,
    {
       label: '年龄',
       prop: 'age',
       component: 'Input',
       formItemProps: {
         rules:[
             {
               required: true,
               message: '请输入年龄',
               trigger: 'blur'
             }
          ]
       }
    },
    ...
]

<Search
    ref="searchRef"
    :schema="searchColumn"
    @search="(params) => console.log('点击查询:',{params})"
    :showReset="false"
    :isVaildSearch="true"
   >
</Search>

运行截图:

image.png

这样就实现了搜索表单项的表单校验。

以上就是搜索重置组件的核心实现步骤~

❌