Vue3表格 Hooks 的封装与使用

前言

Vue3 中的 Hooks 是函数的一种写法,主要用于将组件中的某些独立功能或逻辑进行抽离和封装,以便于重用。这种写法借鉴了 React 的设计理念,使得在组件中,状态逻辑和副作用的处理更加统一和可复用。 Hooks 的函数名/文件名以 use 开头,形如: useXX。

在后台管理系统的开发中,表格组件是一个非常基础且重要的组件。为了提高代码复用性和可维护性,我们将表格的一些常用功能(如分页、查询、新增、修改、删除等)的逻辑抽离出来,封装成 Hooks。

演示案例使用的 UI 组件库为 Naive UI

演示地址:用户管理 - Unusual Admin

示例代码中首行地址是源码(Unusual-Admin)中的文件路径

useSelection 的封装

ts 复制代码
// src/components/basic/useBasicList/utils/useSelection.ts 

import { type DataTableRowKey } from 'naive-ui/es/data-table'
import { type Form } from './type'
import { type UnwrapRef } from 'vue'
import { cloneDeep } from 'lodash-es'

export const useSelection = <Row extends Form = Form>() => {
  const checkedRowKeys = ref<DataTableRowKey[]>([])
  const checkedRow = ref<Row[]>([])
  const changeCheckRow = (rowKeys: DataTableRowKey[], row: object[]) => {
    checkedRowKeys.value = rowKeys
    checkedRow.value = cloneDeep(row) as UnwrapRef<Row[]>
  }
  return {
    checkedRowKeys,
    changeCheckRow,
    checkedRow
  }
}

useBasicList 的封装

这里对该 Hooks 做一些说明:该 Hooks 集成了与新增/修改表单弹窗的一些联动操作,这些联动操作不是必须的,也就是你可以只使用关于表格相关的功能。

ts 复制代码
// src/components/basic/useBasicList/index.ts

import { dialog, message } from '@/utils/help'
import { type FormInst } from 'naive-ui'
import { usePagination } from './utils/index'
import { getData } from './utils/index'
import type { HookParams, Form } from './utils/type'
import { type RowData } from 'naive-ui/es/data-table/src/interface'
import { type UnwrapRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cloneDeep } from 'lodash-es'
import { useSelection } from './utils/useSelection'

export const useBasicList = <List extends Form = Form, QueryParams extends Form = Form>({
  name, // 名称
  url, // 查询url
  key, // rowKey
  isPagination = true, // 是否需要分页
  isInitQuery = true, // 是否初始化查询
  initForm = {} as List, // 表单初始化数据
  initQuery = {} as QueryParams, // 查询初始化数据
  doCreate, // 新建
  doDelete, // 删除
  doUpdate, // 编辑
  beforeRefresh, // 查询之前
  afterRefresh, // 查询之后
  beforeSave, // 新增/编辑保存之前
  afterSave // 新增/编辑保存之后
}: HookParams<List, QueryParams>) => {

  // 国际化
  const { t } = useI18n()

  // 操作类型
  const ACTIONS = computed(() => {
    return {
      view: t('view'),
      edit: t('edit'),
      add: t('add')
    }
  })

  const modalVisible = ref(false)
  const modalAction = ref('')
  const modalLoading = ref(false)
  const modalFormRef = ref<FormInst | null>(null)
  const modalForm = ref<List>({ ...initForm })

  const defualtQuery = ref<QueryParams>({ ...initQuery })

  const modalTitle = computed(() => ACTIONS.value[modalAction.value as keyof typeof ACTIONS.value] + ' ' + (name || ''))
  const modalShowFooter = computed(() => modalAction.value !== 'view')

  /** 表格需要勾选的话需要设置rowkey */
  const rowKey = (row: RowData) => row[key]

  /** 重置搜索 */
  const handlereset = () => {
    defualtQuery.value = {...initQuery} as UnwrapRef<QueryParams>
  }

  /** 选择行变化 */
  const { changeCheckRow, checkedRowKeys, checkedRow } = useSelection<List>()

  /** 新增 */
  const handleAdd = () => {
    modalAction.value = 'add'
    modalVisible.value = true
    modalForm.value = { ...initForm } as UnwrapRef<List>
  }

  /** 修改 */
  const handleEdit = (row?: List) => {
    let rowData = cloneDeep(row)
    if (!row && checkedRow.value) rowData = checkedRow.value[0] as List
    modalAction.value = 'edit'
    modalVisible.value = true
    modalForm.value = rowData as UnwrapRef<List>
  }

  /** 查看 */
  const handleView = (row: List) => {
    modalAction.value = 'view'
    modalVisible.value = true
    modalForm.value = cloneDeep(row) as UnwrapRef<List>
  }

  /** 保存 */
  const handleSave = () => {
    if (!['edit', 'add'].includes(modalAction.value)) {
      modalVisible.value = false
      return
    }
    modalFormRef.value?.validate(async (err: any) => {
      if (err) return
      const action = modalAction.value === 'add' ? doCreate : doUpdate
      const prompt = modalAction.value === 'add' ? t('add') : t('edit')
      try {
        modalLoading.value = true
        // 保存之前,如果返回处理后的数据则替换
        const formData = beforeSave && beforeSave(modalForm.value as List)
        const params = formData || modalForm.value as List
        action && await action(params)
        // 保存之后
        afterSave && afterSave()
        action && message.success(prompt + ' ' + t('sucess'))
        modalLoading.value = modalVisible.value = false
        listQuery()
      } catch (error) {
        modalLoading.value = false
      }
    })
  }

  /** 删除 */
  const checkIds = computed(() => {
    return checkedRow.value?.map(item => {
      return item[key]
    })
  })
  const handleDelete = (ids?: number[]) => {
    if (ids && ids.length === 0 && checkIds.value && checkIds.value.length === 0) return
    let rowKeys = ids
    if (!ids) rowKeys = checkIds.value
    const dia = dialog.warning({
      title: t('warn'),
      content: t('dureDelete'),
      positiveText: t('determine'),
      negativeText: t('cancellation'),
      onPositiveClick: async () => {
        dia.loading = true
        try {
          doDelete && await doDelete(rowKeys as number[])
          dia.loading = false
          doDelete && message.success(t('delete') + ' ' + t('sucess'))
          listQuery()
        } catch (error) {
          dia.loading = false
        }
      },
      onNegativeClick: () => {
        console.log('取消')
      }
    })
  }

  // 查询
  const loading = ref(false)
  const listData = ref<List[]>()
  const listQuery = async () => {
    loading.value = true
    try {
      let params = {
        ...defualtQuery.value
      }
      if (isPagination) {
        params = {
          page: pagination?.page || 0,
          pageSize: pagination?.pageSize || 10,
          ...defualtQuery.value
        }
      }
      // 查询前,如果返回false则不继续查询
      const queryParams = beforeRefresh && beforeRefresh(params as QueryParams)
      if (typeof queryParams === 'boolean' && !queryParams) return
      if (queryParams && typeof queryParams !== 'boolean') params = queryParams as typeof params

      const { data, total } = await getData<List[]>(url, params)

      // 查询后,如果返回处理后的数据则替换列表数据,没有则使用接口返回的数据
      const newData = afterRefresh && afterRefresh([...data])
      if (newData) {
        listData.value = newData || []
      } else {
        listData.value = data || []
      }
      if (isPagination) pagination.itemCount = total as number || 0
      loading.value = false
    } catch(e) {
      loading.value = false
    }
  }

  // 分页
  const { pagination } = usePagination(listQuery)

  // 初始化查询
  isInitQuery && listQuery()

  /** 导出 */
  const handleDownload = () => {
    console.log('handleDownload')
  }

  // 操作按钮禁用
  const btnDisabled = computed(() => {
    return {
      edit: !(checkedRowKeys.value.length === 1),
      del: !(checkedRowKeys.value.length > 0),
      download: isPagination && pagination.itemCount <= 0
    }
  })

  return {
    modalVisible,
    modalAction,
    modalTitle,
    modalLoading,
    modalShowFooter,
    handlereset,
    handleAdd,
    handleDelete,
    handleEdit,
    handleView,
    handleDownload,
    handleSave,
    modalForm,
    modalFormRef,
    defualtQuery,
    changeCheckRow,
    loading,
    listData,
    pagination,
    listQuery,
    rowKey,
    btnDisabled
  }
}

文档

options参数

参数 类型 默认值 说明
name string undefined 列表名称
key string 'id' 表格数据rowKey
url string undefined 查询数据的url
isPagination boolean true 是否分页
isInitQuery boolean true 是否初始化查询
initForm object undefined 表单初始化数据
initQuery object undefined 查询初始化数据
doCreate (form: List) => Promise<ResultData<List\[\]>> undefined 新建
doDelete (id: number\[\]) => Promise undefined 删除
doUpdate (form: List) => Promise undefined 编辑

生命周期

提供了四个生命周期,分别是 beforeRefresh(查询之前), afterRefresh(查询之后), beforeSave(新增/编辑保存之前), afterSave(新增/编辑保存之后)。

生命周期的类型如下

ts 复制代码
beforeRefresh?: (form: QueryParams) => QueryParams | boolean
afterRefresh?: (listData: List[]) => List[] | undefined
beforeSave?: (listData: List) => List | undefined
afterSave?: () => void

使用示例

ts 复制代码
// src/views/system/user.vue 

<template>
  <div>
    <BasicLayout
      v-model:columns="columns"
      :btnDisabled="btnDisabled"
      @search="listQuery"
      @reset="handlereset"
      @add="handleAdd"
      @delete="handleDelete"
      @edit="handleEdit"
      @download="handleDownload"
    >
      <template #queryBar>
        <query-item label="用户名称">
          <n-input v-model:value="defualtQuery.userName" size="small" clearable placeholder="输入用户名称,模糊搜索" />
        </query-item>
        <query-item label="手机号">
          <n-input v-model:value="defualtQuery.phone" size="small" clearable placeholder="输入手机号,模糊搜索" />
        </query-item>
        <query-item label="用户状态">
          <n-select v-model:value="defualtQuery.status" placeholder="选择用户状态" :options="dict?.status" clearable />
        </query-item>
      </template>
      <n-data-table
        :columns="columns"
        :data="listData"
        :loading="loading"
        :row-key="rowKey"
        striped
        :remote="true"
        @update:checked-row-keys="changeCheckRow"
      />
    </BasicLayout>
    <BasicModel
      v-model:visible="modalVisible"
      :title="modalTitle"
      :loading="modalLoading"
      :show-footer="modalShowFooter"
      width="600px"
      @save="handleSave"
    >
      <n-form
        ref="modalFormRef"
        label-placement="left"
        label-align="right"
        :label-width="80"
        :model="modalForm"
        :rules="formRules"
        :disabled="modalAction === 'view'"
      >
        <n-grid x-gap="12" :cols="2">
          <n-gi>
            <n-form-item label="登录账号" path="userName">
              <n-input v-model:value="modalForm.userName" clearable />
            </n-form-item>
          </n-gi>
          <n-gi>
            <n-form-item label="电话" path="phone">
              <n-input v-model:value="modalForm.phone" clearable />
            </n-form-item>
          </n-gi>
          <n-gi>
            <n-form-item label="用户姓名" path="name">
              <n-input v-model:value="modalForm.name" clearable />
            </n-form-item>
          </n-gi>
          <n-gi>
            <n-form-item label="邮箱" path="email">
              <n-input v-model:value="modalForm.email" clearable />
            </n-form-item>
          </n-gi>
          <n-gi>
            <n-form-item label="性别" path="sex">
              <n-radio-group v-model:value="modalForm.sex" name="sex">
                <n-radio v-for="item in dict?.sex" :key="item.id" :value="Number(item.value)" :label="item.label"></n-radio>
              </n-radio-group>
            </n-form-item>
          </n-gi>
          <n-gi>
            <n-form-item label="状态" path="status">
              <n-radio-group v-model:value="modalForm.status" name="status">
                <n-radio v-for="item in dict?.status" :key="item.id" :value="Number(item.value)" :label="item.label"></n-radio>
              </n-radio-group>
            </n-form-item>
          </n-gi>
          <n-gi span="2">
            <n-form-item label="用户角色" path="roles">
              <n-select v-model:value="modalForm.roles" multiple label-field="roleName" value-field="id"  filterable clearable :options="roles" />
            </n-form-item>
          </n-gi>
        </n-grid>
      </n-form>
    </BasicModel>
  </div>
</template>

<script setup lang="ts" name="User">
import { type DataTableColumn } from 'naive-ui/es/data-table'
import TableAction from '@/components/basic/tableAction.vue'
import { useBasicList } from '@/components/basic/useBasicList/index'
import { type Query, type UserList, addUser, delUser, editUser } from '@/api/user/user'
import { getUserRole } from '@/api/user/userRole'
import { type RoleList } from '@/api/user/userRole'
import { type FormRules, NSwitch } from 'naive-ui/es/components'
import { useDict } from '@/hooks/useDict'
import { checkPassword, checkEmail, checkPhone } from '@/utils/calibrationRules';

// 获取角色
const roles = ref<RoleList[]>([])
getUserRole().then(res => {
  roles.value = res.data
})

// 获取dict
const { dict, getDictLabel } =  useDict(['status', 'sex'])

// 表格
const columns = ref<Array<DataTableColumn<UserList>>>([
  {
    type: 'selection',
    disabled: (row) => {
      return row.id === 1
    }
  },
  {
    title: 'ID',
    key: 'id'
  },
  {
    title: '登录账号',
    key: 'userName'
  },
  {
    title: '用户姓名',
    key: 'name'
  },
  {
    title: '性别',
    key: 'sex',
    render(row) {
      return h('span', getDictLabel('sex', String(row.sex)))
    }
  },
  {
    title: '电话',
    key: 'phone'
  },
  {
    title: '状态',
    key: 'status',
    render(row) {
      return h(
        NSwitch,
        {
          rubberBand: false,
          value: Number(row['status']),
          loading: !!row.loading,
          checkedValue: 1,
          uncheckedValue: 0,
          disabled: row.id === 1,
          onUpdateValue: () => handleChangeStatus(row)
        }
      )
    }
  },
  {
    title: '创建日期',
    key: 'createTime'
  },
  {
    title: '操作',
    key: 'actions',
    // width: 280,
    align: 'center',
    fixed: 'right',
    render(row) {
      return [
        h(
          TableAction,
          {
            disabled: row.id === 1,
            onHandleDelete: () => handleDelete([row.id as number]),
            onHandleEdit: () => handleEdit(row),
            onHandleView: () => handleView(row)
          },
        )
      ]
    }
  }
])

// 更改用户状态
const handleChangeStatus = async (row: UserList) => {
  row.loading = true
  const params: UserList = { ...row, status: row.status === 0 ? 1 : 0 }
  await editUser(params)
  await listQuery()
  row.loading = false
}

// 表单规则
const formRules: FormRules = {
  userName: [{required: true, message: '请输入用户名', trigger: 'blur'}],
  pwd: [
    {required: true, message: '请输入密码', trigger: 'blur'},
    {validator: checkPassword, message: '密码格式不正确', trigger: 'input' }
  ],
  email: [
    {required: true, message: '请输入邮箱', trigger: 'blur'},
    {validator: checkEmail, message: '请输入正确的邮箱', trigger: 'input' }
  ],
  phone: [
    {required: true, message: '请输入手机号', trigger: 'blur'},
    {validator: checkPhone, message: '请输入正确的手机号', trigger: 'input' }
  ],
}
// 表格hooks
const {
  modalVisible,
  modalAction,
  modalShowFooter,
  modalTitle,
  modalLoading,
  handleAdd,
  handleDelete,
  handleEdit,
  handleDownload,
  handleView,
  handleSave,
  handlereset,
  defualtQuery,
  modalForm,
  modalFormRef,
  changeCheckRow,
  listQuery,
  listData,
  loading,
  rowKey,
  btnDisabled
} = useBasicList<UserList, Query>({
  name: '用户',
  url: '/user',
  key: 'id',
  isPagination: false,
  initForm: { userName: '', name: '', phone: '', email: '', sex: 0, status: 1, roles: [] },
  initQuery: { userName: undefined, phone: undefined, status: undefined },
  // 搜索前
  beforeRefresh: (query) => {
    if (query && query.title) {
      query.pid = undefined
    }
    return query
  },
  doDelete: delUser,
  doCreate: addUser,
  doUpdate: editUser
})
</script>

<style scoped>
:deep(.selected-row > .n-data-table-td) {
  background-color: #e8f4ff !important;
}
</style>

源码地址和演示地址

演示地址 :Unusual Admin

源码 :Github 或者 Gitee

相关推荐
拖孩26 分钟前
AI 帮我写的小程序「厨菜记」上线满月了,挣了两百块!!!
前端·后端·微信小程序
用户3600555790026 分钟前
Kestrel_VLLM - 13 · 为什么排除 MoE FFN Tiling 与 Tensor Core
前端
流烟默30 分钟前
SSE 实战:在 SpringBoot + Vue 项目中零依赖实现轻量级实时消息推送
vue.js·spring boot·后端·sse
Hilaku35 分钟前
AI 流式输出都用 SSE,什么时候必须换 WebSocket?
前端·javascript·程序员
奇牙coding44 分钟前
Codex CLI 升级后多文件编辑静默失效:config.yaml 枚举变更排查与修复指南
java·前端·javascript·ai
小羊没烦恼!1 小时前
Ajax与JSON的一些总结
android·java·前端·数据库·mysql·算法·okhttp
程序员老赵1 小时前
Docker 部署 Filestash:轻松搭建浏览器文件管理平台
前端·docker·开源
夏天要喝冰可乐1 小时前
给 23 万字修仙小说造个阅读站:浏览器内打包 EPUB 分页阅读 + 三主题实战
前端·产品·全栈
茶底世界之下1 小时前
林间步道要不要加暗角?用 Awaking 把强度收在20%
前端·ios·图片资源
执子念的飞鱼1 小时前
纯前端预览 275 种文件格式是怎么做到的
前端·vue.js·react.js