Vue3 组件二次封装 Element Plus Table

公司里后台系统用的 Element UI,有百来个表格(el-table),历史遗留原因都是直接使用 el-table 的。突然有一天,产品说表格要可以自定义列,让用户控制列的显隐固定排序,最好还能持久储存。使得我不得不进行二次封装来解决,那就顺便再轻微增强一下。

Demo:element-plus-table-proxy-demo

源码:aweikalee/element-plus-table-proxy-demo

Vue 2 + Element UI 请前往 《Vue2 组件二次封装 Element UI El-Table》

主要思路

对于 el-table 的二次封装,我希望是:

  1. 不对原有的表产生影响(过度阶段 不可能一次性改完所有表)。
  2. 尽可能保留 el-table 本身的灵活性。
  3. 增强表格功能的同时,尽可能少地动原先的代码。

对于第1点,则是保留 el-table 组件,创建新组件 MyTable,所有改动在这个新组件内部完成。

对于第2点,就是 MyTable 接受的 props(attrs)slot 应与 el-table 保持一致,且应悉数传递给 el-table

于是设计的调整方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 调整前 -->
<el-table :data="data">
<el-table-column prop="name" label="名字" />
<!-- 此处省略一万个 el-table-column -->
</el-table>

<!-- 调整后 -->
<MyToolbar :table="table" />
<MyTable :data="data" :ref="table">
<el-table-column prop="name" label="名字" />
<!-- 此处省略一万个 el-table-column -->
</MyTable>

新封装的组件 MyTable 所做的事很简单,就是对 slot 重新排序、筛选、修改属性之后,生成一个新的 slot 再交给 el-table 处理。

MyTable 会给 MyToolbar 暴露列的数据与修改列数据的接口。(当然你也可以将 MyToolbar 封装 MyTable 内)

MyTable 组件的实现

基本结构

首先是 template 部分(当然你可以用 render/JSX 代替),Vue 中默认会传递所有未识别的属性给最外层的标签,所以我们只需要传一个新的 slot 就可以了。

1
2
3
<el-table>
<children />
</el-table>

children 就是我们实现的新的 slot,他是 MyTable 内部创建的子组件。他和 slots.default 一样是一个函数,里面返回了 VNode

1
2
const slotsOrigin = useSlots()
const children = () => slotsOrigin.default?.()

注:用了 setup 语法

至此,一个保留了 el-table 所有功能的二次封装,就完成了。接下来只需要再加亿点点细节完善一下。

对 VNode 分类

slot 中获取到的 VNode 除了我们要的内容外,还会有些其他东西,所以我们需要进行分类。

对于 el-table-columnVNode 的处理,将会以 prop 属性作为标识。没有 prop 属性的则不会作为自定义列做处理。

VNode 将会被分成3类:

  1. el-table-column 且有 prop 属性的
  2. el-table-column 但没有 prop 属性,但 fixed="left"
  3. 其他的 el-table-column 或不认识的 VNode

第2类,也可以并到第3类中,但我认为分成3类更符合实际需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const slotsOrigin = useSlots()

/* 对 slot 进行分类 */
const slots = computed(() => {
const main = [] // 第1类
const left = [] // 第2类
const other = [] // 第3类

slotsOrigin.default?.()?.forEach((vnode) => {
if (isElTableColumn(vnode)) {
// 是 el-table-column 组件

const { prop, fixed } = vnode.props ?? {}

// 存在 prop 属性,归第1类
if (prop !== undefined) return main.push(vnode)

// 不存在 prop 属性,但 fixed="left",归第2类
if (fixed === 'left') return left.push(vnode)
}

// 其他,归第3类
other.push(vnode)
})

return {
main,
left,
other,
}
})

/* 用于判断 vnode 是否是 el-table-column 组件 */
function isElTableColumn(vnode) {
return (vnode.type as Component)?.name === 'ElTableColumn'
}

/* 分类好的 slot 按如下顺序挂载 */
const children = () => [slots.value.left, slots.value.main, slots.value.other]

收集列数据

列数据的一手来源,就是 slots.main。因此需要从 VNode 中提取出我们需要的属性和排列顺序。

1
2
3
4
5
6
7
8
9
10
11
12
const columns = reactive({
slot: computed(() =>
slots.value.main.map(({ props }) => ({
prop: props.prop, // 标识
label: props.label, // 列名称
fixed: props.fixed, // 固定位置
visiable: props.visiable ?? true // 是否可见
})),

storage: [],
),
})

除了 visiable 外都是 el-table-column 原有的属性。
columns.slot 只保存最原始的列数据,我们对于列的修改,需要保存在另外的地方,后续还要做持久化储存,所以就存在了 columns.storage 中。

对外提供一个修改 columns.storage 的方法。

1
2
3
function updateColumns(value) {
columns.storage = value
}

合并列数据

现在我们有两个数据 columns.slotcolumns.storage,考虑到持久化储存,储存的列的信息可能不准确(如后期新增/删除了列),取长补短,获得一个渲染时用的完整的列数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const columns = reactive({
// 其他同上 略

render: computed(() => {
const slot = [...columns.slot]
const storage = [...columns.storage]

const res = []
storage.forEach((props) => {
const index = slot.findIndex(({ prop }) => prop === props.prop)
if (~index) {
const propsFromSlot = slot[index]
res.push({
...propsFromSlot, // 可能新增属性 所以用 slot 的数据打个底
...props,
})
slot.splice(index, 1) // storage 里不存在的列
}
// slot 中没有找到的 则会被过滤掉
})
res.push(...slot)

return res
})
})

生成新的 VNode

前期准备都做好了,现在需要创建传给 el-tableslot 了。

我们需要以 columns.render 的数据创建 refactorSlot 代替 slots.main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const refactorSlot = computed(() => {
const { main } = slots.value

const refactorySlot = []

columns.render.forEach(({ prop, visiable, fixed }) => {
// 设置为不可见的 则跳过(即不渲染)
if (!visiable) return

// 从 slots.main 中寻找对应 prop 的 VNode
const vnode = main.find((vnode) => prop === vnode.props?.prop)
if (!vnode) return

// 克隆 VNode 并修改部分属性
const cloned = cloneVNode(vnode, {
fixed,
// 这里可以根据需求 修改属性,非常灵活
})

refactorySlot.push(cloned)
})

return refactorySlot
})

最后合并所有 slot ,就完成了 children 的创建

1
const children = () => [slots.value.left, refactorSlot.value, slots.value.other]

更新列数据

el-table-column 是通过 onMountedonUnmounted 两个生命周期将列数据同步给 el-table 的。但 Vue 会尽可能利用旧的实例,只会更新实例上的数据,而不是销毁重新创建。这就导致 onMountedonUmmounted 无法运行,从而会产生 el-table 中的列数据与 el-table-column 不一致。

故此处通过更新 key 来强制重新创建 el-table-column

1
2
3
<el-table>
<children :key="key" />
</el-table>
1
2
const key = ref(0)
watch(refactorSlot, () => (key.value += 1))

暴露接口

1
2
3
<el-table ref="table">
<children :key="key" />
</el-table>
1
2
3
4
5
6
7
8
9
10
11
12
13
const table = ref()
defineExpose({
// 提供访问 el-table 途径
table,

// 列的数据
columns: computed(() => readonly(columns.render)),

// 修改列的数据(要求全覆盖)
updateColumns(value) {
columns.storage = value
}
})

至此,我们主体结构就搭完了,完整代码可以到 aweikalee/element-plus-table-proxy-demo 查看。

追加功能

接下来就是追加各种功能。

MyToolbar 组件的实现

MyTable 对外提供了 columnsupdateColumns,通过它们我们可以根据需求实现一个自定义列的显示、固定和排序。由于这边怎么实现都行,就不细说了。aweikalee/element-plus-table-proxy-demo 中有简单的实现可以参考。

列数据持久化储存

只要让 columns.storage 初始化时从 localStorage 中获取,修改时写入 localStorage 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 实现一个简易版本,意思一下。
const storage = {
set(key, value) {
localStorage.setItem(key, JSON.stringify(value))
},

get(key) {
try {
return JSON.parse(localStorage.getItem(key))
} catch (error) {
return
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const columnsFormStorage = ref(
storage.get('columns') ?? []
)

const columns = reactive({
// 其他不变 略

storage: computed({
get() {
return columnsFormStorage.value
},
set(value) {
columnsFormStorage.value = value
storage.set('columns', value)
}
})
})

这边 stroage.get('columns') 并没有对表格进行区分储存。可以为 MyTable 增加一个属性 name,储存与读取时以 name 做为标识以区分。

当然列的设置是可以存服务器,意味着储存都是异步的,读取时请求返回之前,会进行一次渲染,请求返回后会再次渲染,这是需要特别注意的。我选择了请求完成前不渲染 children,而是使用加载的状态代替。上传则采用了防抖的方式减少与服务器交互。

KeepAlive 保留滚动条位置

尽管 KeepAlive 会缓存 DOM,但 DOM 会从文档上移除。而离开文档的 DOM 是没有 offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollWidth, scrollHeight, clientWidth, clientHeight 的,此时访问到的也都是 0

KeepAlive 中最受影响的就是 scrollTopscrollLeft,即使重新添加到文档中也无法恢复。所以我们需要在离开文档前保存它们,重新添加到文档后将保存的值再赋值到 DOM 上。

下面介绍两种方法。

方法一

监听 DOM 的 scroll 事件,scroll 事件中记录当前的滚动位置。然后在 onActivated 时重新给 DOM 赋值。

1
<el-table ref="table"></el-table>
1
2
3
4
5
6
const table = ref()
const scrollRef = computed(() => {
// el-table 中滚动的容器
return table.value?.$refs.bodyWrapper
})
useKeepScroll(scrollRef)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function useKeepScroll(el) { // 这是一个 ref 对象
let scrollTop = 0
let scrollLeft = 0

/* 保存滚动条位置 */
function save() {
if (!el.value) return

scrollTop = el.value.scrollTop
scrollLeft = el.value.scrollLeft
}

/* 恢复滚动条位置 */
function restore() {
if (!el.value) return

el.value.scrollTop = scrollTop
el.value.scrollLeft = scrollLeft
}

/* 在组件恢复时 恢复滚动条位置 */
onActivated(restore)

/* 添加、移除 scroll 的监听 */
let listenedEl = null
function removeEventListener() {
listenedEl?.removeEventListener('scroll', save)
listenedEl = null
}
function addEventListener() {
if (!el.value) return
if (listenedEl === el.value) return
removeEventListener()

listenedEl = el.value
listenedEl?.addEventListener('scroll', save)
}

watch(el, addEventListener)
onActivated(addEventListener)
onDeactivated(removeEventListener)
}

方法二

KeepAlive 为我们提供了 onDeactivated ,但它定义就是 DOM 停用后的生命周期,所以 onDeactivated 运行的时候 DOM 已经从文档中移除了。

我们可能更需要 onBeforeDeactivate,但是很可惜,该 RFC 还没有实装。

当前的代替方案,有那么点取巧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useKeepScroll(el) {
let scrollTop = 0
let scrollLeft = 0

function save() {
if (!el.value) return

scrollTop = el.value.scrollTop
scrollLeft = el.value.scrollLeft
}
function restore() {
if (!el.value) return

el.value.scrollTop = scrollTop
el.value.scrollLeft = scrollLeft
}

onActivated(restore) // 恢复
onDeactivated(save) // 保存
}

接下来是关键了!

1
2
3
4
5
<Transition>
<KeepAlive>
<!-- 内容 略 -->
</KeepAlive>
</Transition>

找到使用 KeepAlive 的地方,在外面套一层 Transition 组件,此时 onDeactivated 就等同于 onBeforeDeactivate 了。

若你的项目只存在一个 KeepAlive,就非常适合用这种解决方法。

简单解释一下原理:

KeepAlive 组件的 deactivate 方法中,会先将 DOM 从文档中移除,再创建微任务调用组件的 onDeactivated。若 VNode 上存在 transition,移除将会是变为宏任务,那么就会变成先执行微任务中的 onDeactivated 再从文档中移除了。