背景介绍
日常开发工作中,尤其是中后台系统
,表格功能有增、删、改、查
是很正常的,查
和删
今天我们暂且不说,增
和改
可能在大部分场景下需要复用同一个弹窗 (Modal)
,所以我想和大家分享一下我在这边做的最佳实践,先看效果图
案例
目前市面上封装的基于 json
的或者是基于 template
的,都需要将行信息 (row)
通过函数进行传递,主要为了区分是新增、编辑或者是其他状态,比如以下代码(截图自 vben-admin)
解释一下:
- 上图代码的
BasicTable
组件是基于AntDesignVue Table
二次封装的表格组件 handleEdit
是编辑按钮的回调函数,显示在表格的最后一列
,这里将record
进行了传递handleCreate
是新增按钮的回调函数,显示在表格的工具栏 (右上方)
,没有传递record
我们去看一下 handleEdit
和 handleCreate
做了什么
解释一下:
handleEdit
函数调用了openModal
方法打开弹窗,并且将行信息 (record)
进行了传递,为了等下可以在弹窗中 (Modal) 回显
, 这里我们不研究内部是怎么传递的handleCreate
函数也调用了openModal
方法打开弹窗,区别是没有传递行信息 (record)
,因为没有
我们在去看 AccountModal 组件
中针对这些信息做的事情
解释一下: 额外定义了 2个变量
,isUpdate
和 rowId
,本质是为了区分 新增、编辑
痛点
一句话概括:为了记录是新增
还是编辑
额外定义了一些变量,需要手动维护这些变量
解决思路
既然我们知道了痛点是手动维护这些变量,那么我们可以实现一个自定义hook
来解决手动维护的问题,这个 hook
可以自动跟踪当前表格的行信息,返回一个响应式的数据交给外层,伪代码如下
ts
<script setup lang="ts">
const proTableInst = ref<ProTableInst>()
const { row } = useTrackTableRow(proTableInst)
</script>
<template>
当前表格行信息:{{ row }}
<ProTable ref="proTableInst" />
</template>
可以看到,我们的这个 hook
接收一个 表格实例
作为参数,返回的是 当前行信息
,先看下最终效果
看完了效果,我们一起来实现一下这个 hook
吧
实现前的思考
我们要想一下如何去实现这个功能,有以下几个问题
- 如何拿到
行信息
- 如何在用户
点击按钮触发点击回调前
执行获取行信息
逻辑 - 在一些场景中,如何扩展获取到的
行信息
,比如复制
和编辑
都在同一行中,但是复制本质上是新增,不需要携带 id 去请求,但是又需要回填
,所以要扩展行信息
如何拿到行信息
一般表格组件都有行的唯一值,拿 AntDesignVue
举例,有一个 row-key
参数,代表表格每一行的唯一值,我们可以在封装 Table
时将这个 row-key
映射到 dom 属性上
,如图
我们的 行信息 (row)
是存储在表格的数据源中的,拿 AntDesignVue
举例,就是 dataSource
属性,它是一个数组,里面就是我们所有的表格信息,我们可以通过一个计算属性来拿到 rowKey
对应 行信息的 Map
, 如图
解释一下
traverse
函数用来遍历的,它可以进行递归,因为有可能是树形表格
getRowKey
函数是用来根据用户传递的row-key
属性来获取唯一值的
,表格组件是必须要传递这个属性的
最后我们将这个 rowKeyToRowMap
作为表格的实例方法,可以通过表格实例拿到,因为我们的 useTrackTableRow
接收表格实例作为参数,这样就可以根据 row-key
拿到行信息了
,如图
如何在用户点击按钮触发点击回调前
执行获取行信息
逻辑
我们可以给封装的 Table
组件整体包裹一个 div
,这样我们就可以通过 proTableInst.value.$el
拿到当前表格实例的 根节点元素
,我们可以对这个 根节点元素
实现监听,比如监听 click
事件,这样用户不管是点击 新增
,还是 编辑
都可以触发相应的事件回调,如图
以上代码还有一个问题,就是我们的 trackRowInfo
函数是在用户点击 新增
、编辑
按钮之后通过事件冒泡执行的,我们需要用户在点击新增
、编辑
按钮之前执行,也就是说在事件的捕获阶段触发
,我们可以添加一个配置搞定,如图
这样我们就可以完成用户点击按钮触发点击回调前
执行获取行信息
逻辑
如何扩展行信息
我们在一些场景中,可能需要扩展一些额外的字段来满足我们的业务场景,因为现在获取行信息的逻辑是 hook
自动完成的,我们需要暴露给用户如何扩展行信息的方法,我们假设这个方法名叫 extend
,假设我们现在有 新增
、 复制 (copy)
和 编辑 (edit)
的业务场景,用户使用的伪代码如下
ts
<script setup lang="ts">
const open = ref(false)
const proTableInst = ref<ProTableInst>()
const { row,extend } = useTrackTableRow(proTableInst)
// 表单验证成功后触发的回调事件
function onFinish(values){
const rawRow = toValue(row)
if (rawRow && rawRow.type === 'edit') {
// 编辑请求
return reloadUpdateUser({ ...values, id: rawRow.id })
}
// 新增 & 复制 请求
return reloadAddUser(values)
}
function openModal(type: 'copy' | 'edit') {
// 我们在这里将行信息进行扩展,增加一个 `type` 类型用来标识 `复制` 或者 `编辑`
extend({ type })
open.value = true
}
</script>
<template>
<ProTable>
<template #extra>
<KProButton type="primary" @click="open = true">新增</KProButton>
</template>
<template #bodyCell="{ column }">
<template v-if="column.key === 'action'">
<ASpace>
<KProButton compact @click="openModal('copy')">复制</KProButton>
<KProButton compact color="warn" @click="openModal('edit')">编辑</KProButton>
</ASpace>
</template>
</template>
</ProTable>
<KProModalForm
v-model:open="open"
:title="`${row ? '编辑' : '新增'}用户`"
:initial-values="row"
@finish="onFinish"
>
<KProInput label="用户名" name="name" required />
<KProPassword label="密码" name="password" required />
</KProModalForm>
</template>
解释一下:
KProModalForm
可以简单看成Modal
+Form
的结合KProInput
可以简单看成FormItem
+Input
的结合KProPassword
可以简单看成FormItem
+Password
的结合- 在点击
复制
按钮时在行信息
中扩展了一个type: 'copy'
- 在点击
编辑
按钮时在行信息
中扩展了一个type: 'edit'
- 这样我们在触发
onFinish
时就可以拿到row.type
字段,然后根据字段区分是复制
还是编辑
,从而走不同的接口请求
好了,看完了以上的伪代码,我们大概也知道 extend
方法的内部实现了,其实就是做了一个合并,我们的问题都已经描述清楚了,接下来我们正式的去完成 useTrackTableRow
这个 hook
吧
实现 useTrackTableRow
参数设计
ts
import type { MaybeRef } from 'vue'
import type { KProTableInst } from '../inst'
const rowAttributeKey = 'custom-row-key'
interface UseTrackTableRowOptions {
/**
* @description 跟踪行信息的触发时机
* @default 'click'
*/
trigger?: 'click' | 'hover'
/**
* @description 跟踪行信息的拦截器,可以返回 false 阻止跟踪,在遇到一些特殊的场景时可能会有用
*/
guard?: (ev: MouseEvent) => boolean | void
}
const triggerToEventNameMap: Record<string, string> = {
click: 'click',
hover: 'mousemove',
}
export function useTrackTableRow<Row extends Record<string, any>>(
tableInstRef: MaybeRef<KProTableInst | undefined>,
options: UseTrackTableRowOptions = {},
) {}
解释一下:
- 设计了
tableInstRef
参数, 为了用户方便,可以直接传递一个ref
类型 - 设计了
trigger
配置,用来覆盖更多的场景,可以是点击,或者是鼠标移入 - 设计了
guard
配置,主要针对一些特殊化场景,比如弹窗不是挂载到body
,而是挂载到表格
内部,这个时候我们点击弹窗的蒙版,可能也会触发到获取行信息的函数
,从而导致行信息被清空的情况
hook 实现
上面已经介绍了每一步的实现思路及伪代码,所以我们这里将这些思路具象化,完整代码如下
ts
import type { ComponentPublicInstance, ComputedRef, MaybeRef } from 'vue'
import type { KProTableInst } from '../inst'
const rowAttributeKey = 'custom-row-key'
export const proTableCls = 'k-pro-table'
/**
* @description 跟踪表格行信息
*/
interface UseTrackTableRowOptions {
/**
* @description 跟踪行信息的触发时机
* @default 'click'
*/
trigger?: 'click' | 'hover'
/**
* @description 跟踪行信息的拦截器,可以返回 false 阻止跟踪,在遇到一些特殊的场景时可能会有用
*/
guard?: (ev: MouseEvent) => boolean | void
}
export function useTrackTableRow<Row extends Record<string, any>>(
tableInstRef: MaybeRef<KProTableInst | undefined>,
options: UseTrackTableRowOptions = {},
) {
const {
guard,
trigger = 'click',
} = options
const row = ref<Row | null>(null)
const extendInfo = ref<Partial<Row>>({})
function trackRowInfo(ev: MouseEvent) {
const tableInst = toValue(tableInstRef)
const shouldTrack = guard?.(ev) ?? true
if (!shouldTrack || !tableInst)
return
const rowKey = findRowKeyByElement(ev.target)
const rowInfo = tableInst.getRowKeyToRowMap().get(rowKey as string) ?? null
;(row as any).value = rowInfo
}
function getRowKey(target: Element) {
return target.getAttribute(rowAttributeKey)
}
function hasRowKey(target: Element) {
return !!getRowKey(target)
}
function isRootElement(target: Element) {
return target.classList.contains(proTableCls)
}
function findRowKeyByElement(target: EventTarget | null) {
const rowKey = null
let current = target as Element
while (current) {
if (isRootElement(current))
return null
if (hasRowKey(current))
return getRowKey(current)
current = current.parentElement as Element
}
return rowKey
}
useEventListener(
computed(() => {
return (toValue(tableInstRef) as any as ComponentPublicInstance)?.$el
}),
triggerToEventNameMap[trigger],
trackRowInfo,
{ capture: true },
)
function extend(data: Partial<Row>) {
extendInfo.value = data as any
}
return {
row: computed(() => {
const rawRow = toRaw(toValue(row))
if (!rawRow)
return null
const rawExtendInfo = toRaw(toValue(extendInfo)) as Partial<Row>
return { ...rawRow, ...rawExtendInfo }
}) as ComputedRef<Row | null>,
extend,
}
}