ElTable 二次封装:我用 Vue3.3 新特性完美解决了列插槽数据无类型提示问题!!!
扯皮
关于 ElTable 的二次封装其实已经有大篇文章讲述了,但正如标题所说很少有人结合 Vue 3.3 里的泛型组件以及 defineSlots 这两个特性来谈封装,甚至只是简单介绍了一下这两个新特性,很少有将其运用到业务场景中😇
关于这里实现的思路早在暑期 Vue3.3 已经成为正式版之后我就发现了,因为当时在实习公司用 Vue2 + ElementUI 写常见的中后台业务,只能说在真实业务场景下 ElementUI 真的不太好用,尤其是它的 Table 组件一言难尽☹️☹️☹️... 直到后来关注到这两个新特性发现配合 ElementPlus 的 ElTable 真的很爽😁
包括这个实现方案我在自己的个人简历上也提到了,还受到了面试官的高度赞扬以及表示对新技术更新关注度的肯定😎,虽然哥们秋招投了几百份简历一共也就一场面试😅😅😅
当然如果你已经有二次封装 ElTable 的经验以及对 Vue3.3 的特性都有所了解但是还对本文标题感兴趣,建议直接划到最后看 解决列插槽无类型提示问题 小节,本文会有大量的前置内容进行铺垫
这次就来谈谈关于这两个特性与 ElTable 组件的结合,看看这三个有什么化学反应🤣
正文
首先正如之前提到的 ElTable 组件常规的二次封装已经有很多文章了,所以本文着重讲解标题内容而不会讲封装的功能有多全这样
天下苦 ElTable 久矣
还是简单谈谈为什么要进行二次封装吧,只说槽点最多的一点:列数据必须使用插槽的问题
写个人项目以及看一些培训班课程的时候感觉这一点也没什么毕竟字段就那么多,后来实习遇到了真实项目才发现可太难受了:
当时记得公司的项目表格里十几个字段,也就是说会有十几个 el-table-column
组件,光一个 table 组件就将近一两百行的代码,再加上没有使用 TS 做类型提示,根据业务需求随便增删改一个字段名都得费很长时间去查找😑。总的来说维护起来相当麻烦,真的十分难用...
反观像Ant Design Vue:
仅需要做简单的 columns 配置并传入数据就能够出现一个完整的表格,在 template 模板中根本不需要写过多的列数据代码
以及现在热度比较高的 Naive UI,不仅颜值高于 Element Plus(个人认为)其封装的 Table 组件也十分好用:
而且 Naive UI 如果想要实现自定义列抛弃了 template 中的插槽,只需要在 column 添加 render 配置项写 h 函数即可,或者搭配插件写 jsx,与 antd 对齐了属于是🤪
还有腾讯的 TDesign:
无一例外它们都选择了减少组件的 template 比重,通过 columns 配置项来设置列数据
当然具体如何封装肯定还是要根据实际业务来定,columns 配置的封装方式也不一定是万能的
从某种意义上来讲这些组件库的 Table 都属于 ElTable 的 Pro 版,毕竟有些 Table 直接连带了分页器以及头部的自定义配置,而这些在 Element Plus 中均需要开发者手动实现
columns 配置封装
如上面所说,现在我们也希望在 ElTable 中通过一个 columns 配置以及 data 数据就能够展示出表格,这也是很多篇文章提到的很值得封装的一个点🧐
这里只写一个最简单的实现,毕竟不是我们文章的重点,封装一个 table 组件并增加一个 columns 配置:
export interface IFsTableProps {
data: any[];
columns: IFsTableColumn[];
}
export interface IFsTableColumn {
prop: string;
label: string;
[key: string]: any;
}
组件当中拿到 columns 配置项进行 v-for 遍历出 el-table-column
,之后给它身上绑定传入的属性即可:
<template>
<div class="fs-table-container">
<el-table :data="props.data" :="$attrs">
<el-table-column v-for="item in props.columns" :key="item.prop" :="item"></el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { IFsTableProps } from "./type";
const props = defineProps<IFsTableProps>();
</script>
<style scoped>
.fs-table-container {
width: 100%;
height: 100%;
}
</style>
在父组件里引用我们封装的 table 组件,传入对应的配置:
<template>
<div class="container">
<fs-table :data="tableData" :columns="columns" style="width: 100%"></fs-table>
</div>
</template>
<script setup lang="ts">
import FsTable from "./components/FsTable/FsTable.vue";
import { IFsTableColumn } from "./components/FsTable/type";
const columns: IFsTableColumn[] = [
{
prop: "date",
label: "Date",
width: "180",
},
{
prop: "name",
label: "Name",
width: "180",
},
{
prop: "address",
label: "Address",
},
];
const tableData = [
{
date: "2016-05-03",
name: "Tom",
address: "No. 189, Grove St, Los Angeles",
},
{
date: "2016-05-02",
name: "Tom",
address: "No. 189, Grove St, Los Angeles",
},
{
date: "2016-05-04",
name: "Tom",
address: "No. 189, Grove St, Los Angeles",
},
{
date: "2016-05-01",
name: "Tom",
address: "No. 189, Grove St, Los Angeles",
},
];
</script>
<style scoped>
.container {
width: 100vw;
height: 100vh;
overflow: hidden;
}
</style>
效果就是一开始 element-plus table 文档中的第一个案例:
当然现在我们还没有实现自定义列,原 ElTable 中是通过在 el-table-column
里设置插槽实现,我们跟着照做即可,给 column 中增加一个 slotName
属性:
export interface IFsTableColumn {
prop: string;
label: string;
slotName: string; // new
[key: string]: any;
}
在封装的 table 中针对于遍历的每个 column 增加作用域插槽,其名称就是新增的 slotName 字段,而行数据我们可以获取原 el-table-column
里 default 插槽中的数据将其抛出,顺便给个默认值:
<template>
<div class="fs-table-container">
<el-table :data="props.data" :="$attrs">
<el-table-column v-for="item in props.columns" :key="item.prop" :="item">
<template #default="scope">
<slot :name="item.slotName" :row="scope.row"> {{ scope.row[item.prop] }} </slot>
</template>
</el-table-column>
</el-table>
</div>
</template>
这样父组件想要有自定义列的需求时我们只需要给 columns 增加 slotName 字段,template 中直接使用对应的插槽就行,通过组件内部封装的 row 拿到行数据:
<template>
<div class="container">
<fs-table :data="tableData" :columns="columns" style="width: 100%">
<template #name="{ row }">
<div style="color: red; font-size: 25px">{{ row.name }}</div>
</template>
</fs-table>
</div>
</template>
效果有了,其实这样就已经实现了一个最基础的 columns 配置化的 table 组件封装
但不要忘了我们现在使用的是 TS 啊,我可不想我自己封装的组件连一点类型提示都没有🤔,其实最想要的效果就是在使用自定义列拿到行数据时会有对应行数据的类型提示,但现在我们来看这里的 row 类型:
果不其然,大大的 any 🤪
还记得当时在实习项目中通过 row 拿到行数据时我自己就在想:如果在使用 .取值语法
时有整个行类型提示该有多好啊🥺
每次写到 row. 的时候后面的字段都忘了还得去跟接口仔细比对,生怕写错了😑 ,那种遇到几十个 el-table-column
时写到 row. 之后的恐慌和不知所措的感受家人们谁懂🥺🥺🥺
数据源的类型问题
实际上归根究底,还是在一开始传入 data 数据源时我们使用的是 any[]:
export interface IFsTableProps {
data: any[]; // ⭐
columns: IFsTableColumn[];
}
造成的后果就是组件当中的所有行数据统一都是 any 😶
不仅我们二次封装的是这样,来扒一下 ElTable 的源码也是一样的:
所以如果使用 ElTable 本身的自定义列拿到的行数据同样是 any,并不是我们封装的问题:
这其实并不是一个错误,因为这可是封装的组件库啊,组件本身就具备很强的通用性,不同用户传来的 data 数组行数据类型千奇百怪,我怎么可能具体到每个用户级别行数据类型,所以狠狠地 any 就完事了,大家都用 any 多好,谁也不争谁😋
但是我们如果进一步思考,有没有一种方式真能实现这样的效果呢?让用户决定数据源类型,然后我在组件封装的过程中直接使用该类型🤔
可以理解为现在我们的组件变成了一个函数,封装组件就是要实现一个函数功能,而数据源类型变成了一个函数参数让用户传入,函数拿到参数无脑使用就行
类型变成参数,这不就是泛型吗?🤔 太对了,就是泛型,但是现在我们是组件啊怎么用泛型,那就要引出 Vue3.3 的新特性了
泛型组件 和 defineSlots
泛型组件:<script setup> | Vue.js (vuejs.org)
defineSlots:<script setup> | Vue.js (vuejs.org)
官方文档里的 example 基本上已经是最大的应用场景了,我们还是先来简单谈一下这两个特性:
先来讲讲泛型组件,只需要给 script 标签上加上 generic 即可添加泛型:
它的一个很大的用途就是可以给 props 来设置泛型,以此可以推导出父组件传入 props 的数据类型:
<template>
<div>
</div>
</template>
<script setup lang="ts" generic="T">
const props = defineProps<{
data: T[];
}>();
</script>
<style scoped></style>
当然这样简单的使用好像没什么作用,你子组件拿到这个 T 泛型能干啥呢🤔?不要忘了 TS 支持 extends 对类型进行约束
比如我们上面这个例子,子组件拿到 data 数组要进行 v-for 遍历,针对于每个遍历的 item 我们肯定要给它设置一个 key 属性,按照之前的 any 那无所谓了,就完全看用户传的是否规范有设置相关的字段来设置 key
但是现在 item 类型是泛型 T,我们就可以对 T 进行类型约束,要求它必须含有一个 id 字段且为 number,这时候在使用时就会有类型提示,且父组件在使用时如果不按该约束传入也会有对应的类型错误提示:
<template>
<div>
<div v-for="item in props.data" :key="item.id"></div>
</div>
</template>
<script setup lang="ts" generic="T extends {id: number}">
const props = defineProps<{
data: T[];
}>();
</script>
<style scoped></style>
父组件传入的类型不符合规范也会有对应的提示:
这其实就是泛型组件的一个最大的利用场景,当然你可能会说我自己封装的组件针对于 props 的类型都是确定的,它肯定会有一个唯一标识的字段没必要使用泛型,而且使用泛型子组件后就无法去确定约束之外的其他字段了 🤨
你是对的,但这属于自身项目的业务组件,当我们封装成为组件库这种通用性组件时就要考虑其最大的复用性,所以泛型的特性就能够极大的发挥其作用😏
我们再来看看 defineSlots,针对于子组件的插槽在父组件使用时你会发现它默认推导出了我们在子组件中定义的插槽名以及传出的数据类型(之前的 any 案例)
但是无法对子组件的 slots 进行类型检测规范,而 defineSlots 的作用说白了就是给 slots 添加类型检测,提高对插槽的维护性
官方文档只简单说明功能和使用方法,我们来看看实际效果:
<template>
<div>
<slot />
</div>
</template>
<script setup lang="ts">
defineSlots<{
default(props: { test: string }): any;
}>();
</script>
<style scoped></style>
我们在 script 标签中使用 defineSlots 定义一个默认插槽,同时要求该默认插槽会传出 test 数据给父组件使用,但在 template 模板中我们只写了一个 slot 默认插槽标签,这时候就会有类型提示我们缺少 test 属性:
针对于具名的作用域插槽也是一样的效果:
但其实这样的效果在 react 的 tsx 中天生就实现了,无脑把插槽当作 props 一块进行类型规范就行😂😂😂,来看看 tsx 定义的组件:
interface TestProps<T> {
// 传入泛型
data: T[];
// 作用域插槽
children: (age: number) => React.ReactElement;
}
// 泛型约束
function Test<T extends { id: number }>(props: TestProps<T>) {
return (
<div>
{props.data.map((item) => (
<div key={item.id}></div>
))}
{/* 传入参数有类型限制 */}
{props.children(20)}
</div>
);
}
export default Test;
解决列插槽无类型提示问题
以上铺垫完之后才到了标题里说的内容🤣,其实单看泛型组件尽管是加了类型约束用起来还是挺鸡肋的,正如之前所说如果把它当作一个业务组件,那也无法直接去访问其他的属性:
因此泛型组件注定不能用来作为单纯的业务组件使用,它必定是用来封装一个通用性组件,而一般的通用性组件会以插槽的形式让用户自定义内容,所以就有了泛型组件与插槽的结合 😎
现在我们尝试这样做,针对于每个遍历的 div 内部在定义一个具名作用域插槽,插槽名就用约束的 name 字段,传递的数据就是行数据:
<template>
<div>
<div v-for="item in props.data" :key="item.id">
<slot :name="item.slotName" :row="item" />
</div>
</div>
</template>
<script setup lang="ts" generic="T extends {id: number;slotName:string}">
const props = defineProps<{
data: T[];
}>();
</script>
这时候你会发现 row 就有具体的类型定义了,而这里的类型针对于子组件来说就是泛型 T
针对于父组件来说就是自己传入的数据 item 类型 👇:
这时候我们再在父组件中使用行数据来通过 .取值语法
拿具体字段时才叫一个爽,因为这次拥有了行数据的全部类型提示😎:
但细心的你会发现这里的 row 是一个行数据的联合类型,因为我们是通过 v-for 遍历针对于每列都有单独的插槽直接就把 row 传出去了,我希望这里的 row 就直接是一个行数据类型而不是联合类型 🤔
最重要的一点是你如果把上面所有的操作应用到之前的 ElTable 封装上,会发现它的 row 数据还是 any 😕
因为我们毕竟是二次封装,ElTable 源码内部把数据设置成了 any,那我们通过其插槽拿到的 scope.row 也是 any:
这时候我就想到了 defineSlots,在子组件中完全可以用它来限制 row 类型,打破 ElTable 的 any 设置
但又有一个问题,defineSlots 里定义的插槽名称是固定的,而我们上面通过 v-for 遍历的插槽名根据每一列的 slotName 设置是动态的,两个写法貌似没法兼容☹️
正当我准备放弃时我想到了 Ant Design Vue 的写法:
它将所有自定义列的操作都放到了 bodyCell 插槽中,通过 column 中的 key 来区分具体要自定义哪一列
我们完全可以按照它这种方式通过 defineSlots 确定一个自定义列插槽:
defineSlots<{
bodyCell(props: { row: T; columnKey: string }): any;
}>();
我们修改之前的 column 类型定义,增加 columnKey
字段用来区分具体哪一列:
export interface IFsTableColumn {
prop: string;
label: string;
columnKey: string; // new
[key: string]: any;
}
在 template 模板中就可以使用 bodyCell 插槽了:
<template>
<div class="fs-table-container">
<el-table :data="props.data" :="$attrs">
<el-table-column v-for="item in props.columns" :key="item.prop" :="item">
<template #default="scope">
<!-- 通过 bodyCell 插槽,传出行数据 row 以及列标识 columnKey -->
<slot name="bodyCell" :row="scope.row" :columnKey="item.columnKey">{{ scope.row[item.prop] }}</slot>
</template>
</el-table-column>
</el-table>
</div>
</template>
父组件中要想实现自定义列就直接通过 bodyCell 插槽并通过 columnKey 来找到具体想要哪一列:
<template>
<div class="container">
<fs-table :data="tableData" :columns="columns" style="width: 100%">
<template #bodyCell="{ row, columnKey }">
<template v-if="columnKey === 'name'">
<div style="color: red; font-size: 25px">{{ row.name }}</div>
</template>
</template>
</fs-table>
</div>
</template>
效果和最早封装 columns 配置的 table 一样:
虽然与之前自定义列的方式不同了,但最重要的是现在父组件可以通过 row 获取整个行数据的类型提示🧐:
这时候用 .取值语法
再来看看,爽,很爽,非常爽!!!😆😆😆
再也不用担心以前访问行数据字段时在哪想半天字段名到底是啥了😭😭😭:
End
源码奉上:DrssXpro/table-demo: 配合 Vue3.3 新特性二次封装 el-table (github.com)
本文只是提供一个简单的封装思路,可以看到最后封装的 table 代码就没几行🤣
table 组件本身的功能十分复杂,要想封装功能十分完善还是去看 github 上一些大佬的封装吧,本文提供的思路并不确定封装其他功能时也会生效
转载自:https://juejin.cn/post/7331361547011145755