什么是超级表格?
个人认为,超级表格应该涵盖一般表格使用所涉及的相关功能,根据以往项目开发经验,总结出以下功能:
- 支持多级表头
- 支持使用h函数等方式实现自定义单元格渲染
- 支持纵向丝滑滚动
- 支持接口数据量过大情况下的虚拟列表渲染方式
超级表格相关功能实现代码解读
支持多级表头
数据结构比较复杂的时候,可使用多级表头来展现数据的层次关系。
以上多级表头element plus Table示例的代码如下:
Vue
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="date" label="Date" width="150" />
<el-table-column label="Delivery Info">
<el-table-column prop="name" label="Name" width="120" />
<el-table-column label="Address Info">
<el-table-column prop="state" label="State" width="120" />
<el-table-column prop="city" label="City" width="120" />
<el-table-column prop="address" label="Address" />
<el-table-column prop="zip" label="Zip" width="120" />
</el-table-column>
</el-table-column>
</el-table>
</template>
一行一行的写el-table-column,多少有点枯燥,可通过调整传入的tableHeader数据结构以及递归调用表头组件的方式来实现多级表头。
tableHeader数据结构:
Vue
<script setup>
const tableHeader = [
{
prop: "index",
label: "#",
align: "center",
width: "120"
},
{
prop: "date",
label: "Date",
width: "160",
align: "center",
cellRender: row => {
return h(
ElTag,
{
type: "primary",
closable: true
},
() => row.date // https://blog.csdn.net/qq_42403461/article/details/142248823
);
}
},
{
label: "Delivery Info",
children: [
{
prop: "name",
label: "Name",
width: "120",
align: "center"
},
{
label: "Address Info",
children: [
{
prop: "state",
label: "State",
width: "120"
},
{
prop: "city",
label: "City",
width: "120"
},
{
prop: "address",
label: "Address",
align: "center"
},
{
prop: "zip",
label: "Zip",
width: "120",
cellRender: row => {
return h(
ElButton,
{
type: "success",
closable: true
},
() => row.zip // https://blog.csdn.net/qq_42403461/article/details/142248823
);
}
}
]
}
]
}
];
</script>
通过children字段来存储子表头数据,如果子表头还存在子子表头,依然采用此方式来嵌套。
表头组件table-column.vue:
Vue
<template>
<el-table-column v-bind="props.columnHeader">
<template v-for="(item, index) in columnHeader.children" :key="index">
<TableColumn v-if="item.children && item.children.length" :column-header="item" />
<el-table-column v-else v-bind="item">
<template v-if="item.cellRender" #default="{ row }">
<component :is="item.cellRender(row)" />
</template>
</el-table-column>
</template>
</el-table-column>
</template>
<script setup>
const props = defineProps({
columnHeader: {
type: Object,
required: true
}
});
</script>
可以看出采用递归调用TableColumn组件的形式实现深层次的多级表头。
支持使用h函数等方式实现自定义单元格渲染
表格单元格不仅需要展示数据,还需要根据业务需求展示不同的状态。比如使用element plus的组件Tag 标签、Button 标签等结合数据进行自定义渲染。可以通过Vue的h函数实现单元格的自定义渲染。
什么是h函数?
Vue中的 h 函数是 Vue 用于创建虚拟DOM(Virtual DOM)节点的核心函数。在 Vue3.0 版本中,这个函数作为渲染函数的基础工具,用于替代模板解析的过程,允许开发者以编程方式描述组件结构和动态生成虚拟节点。
通过h函数的方式构建虚拟DOM树,Vue可以在内部高效地比较新旧虚拟DOM的不同,并最小化地更新实际DOM,从而提高页面渲染性能。
js
function h(type, props, children)
- type:必需,表示要创建的元素类型或组件类型。它可以是一个字符串(HTML标签名),一个组件选项对象、异步组件函数或者一个函数式组件。
- props:可选的对象,包含了传递给元素或组件的所有属性(attributes)和 props。例如:可以包含类名、样式、事件监听器等。
- children: 可选,代表子节点,它可以是字符串(文本内容)、数组(包含多个子节点,每个子节点可以是字符串或其他由 h 创建的虚拟节点)或者其他合法的虚拟DOM节点。
实现流程
1、在tableHeader数据结构中使用cellRender字段来接收h函数
注意:
需要给h函数的第三个参数(即内容这个参数加一个匿名函数()=>),例如 () => row.date
,从而解决Vue3组件报Non-function value encountered for default slot. Prefer function slots for better performance。
参考:
Vue3组件报Non-function value encountered for default slot. Prefer function slots for better performance
2、在wang-table.vue以及table-column.vue文件中,通过component标签来接收h函数创建的虚拟节点。component 标签是Vue框架自定义的标签,它的用途是可以通过动态绑定is 属性来动态渲染组件。
之所以使用component标签,是因为封装过程中发现直接通过
Vue
<template v-if="column.cellRender" #default="{ row }">
{{ column.cellRender(row) }}
</template>
这种方式展示的单元格为空,无法正常展示。
支持纵向丝滑滚动
wang-table.vue会监听通过props传入的autoScroll,如果autoScroll为true,则开启滚动。
Vue
//表格滚动
const tableRef = ref<HtmlElType>(null);
let timer = null; // 定时器
let time = Date.now(); // 定义 time 变量
const createScroll = () => {
if (Date.now() - time >= 50) {
time = Date.now();
// 拿到 table
const table = tableRef.value?.layout?.table?.refs; // 拿到可以滚动的元素
if (!table || !table.bodyWrapper) {
console.error("Table or bodyWrapper not found");
return;
}
const tableWrapper = table.bodyWrapper.firstElementChild.firstElementChild;
if (!tableWrapper) {
console.error("Table wrapper not found");
return;
}
tableWrapper.scrollTop = tableWrapper.scrollTop + 1;
// 判断是否滚动到底部,如果到底部了置为0(可视高度+距离顶部=整个高度)
if (tableWrapper.clientHeight + tableWrapper.scrollTop >= tableWrapper.scrollHeight) {
tableWrapper.scrollTop = 0;
}
}
timer = window.requestAnimationFrame(createScroll);
};
// 提供一个方法来停止滚动
const stopScroll = () => {
if (timer !== null) {
window.cancelAnimationFrame(timer);
timer = null;
}
};
watch(
() => props.autoScroll,
() => {
if (props.autoScroll) {
createScroll();
} else {
stopScroll();
}
},
{
immediate: true
}
);
滚动比较丝滑不卡顿的原因是:使用window.requestAnimationFrame代替setTimeout、setInterval等定时器,在浏览器下次重绘之前调用指定的回调函数更新动画,从而使滚动顺滑流畅。
支持接口数据量过大情况下的虚拟列表渲染方式
如果接口返回的数据过量大且不支持分页,可使用虚拟列表的方式进行性能优化,从而避免大数据渲染导致的页面卡顿问题。关于虚拟列表的原理以及实现过程,可参考:使用hooks实现虚拟列表并通过节流、增加缓冲区进行性能优化。
Vue
<script lang="ts" setup>
import { ref, watch } from "vue";
import useOptimizeVirtualList from "@/hooks/useOptimizeVirtualList";
interface IvirtualList {
itemHeight?: number; // 列表项的大致高度
bufferRatio?: number; // 缓冲比例
throttleTime?: number; // 节流时间
}
// 虚拟列表
// 需要使用 reactive 语法,否则无法响应式更新 使用ref定义的变量来接收actualRenderData 会导致响应式更新无效
const curRenderData = reactive({
data: null
});
watch([() => props.tableData, () => props.virtualList], () => {
if (props.virtualList) {
const { actualRenderData } = useOptimizeVirtualList({
data: ref(props.tableData), // 列表项数据
scrollContainer: ".el-table .el-scrollbar__wrap", // 滚动容器
actualHeightContainer: ".el-table .el-scrollbar__view", // 渲染实际高度的容器
translateContainer: ".el-table .el-table__body", // 需要偏移的目标元素,
itemContainer: ".el-table__row", // 列表项
itemHeight: props.virtualList?.itemHeight || 40, // 列表项的大致高度
bufferRatio: props.virtualList?.bufferRatio || 1, // 缓冲比例
throttleTime: props.virtualList?.throttleTime || 50 // 节流时间
});
curRenderData.data = actualRenderData;
}
});
监听通过props传入的virtualList,通过hooks/useOptimizeVirtualList得到需要渲染的列表数据并监听列表滚动,更新渲染的列表数据。
需要注意的是,要用reactive语法来接收需要渲染的列表数据
, 否则列表滚动时无法响应式更新数据。
使用ref定义的变量来接收actualRenderData,会导致响应式更新无效
,且只能给ref定义的变量赋常量,赋响应式数据无效。(刚开始使用ref定义的变量来接收actualRenderData,滚动表格时,数据不更新,折腾了好久才找到原因)
Vue
// 推荐
const curRenderData = reactive({
data: null
});
watch([() => props.tableData, () => props.virtualList], () => {
if (props.virtualList) {
const { actualRenderData } = useOptimizeVirtualList({
...
});
curRenderData.data = actualRenderData;
}
});
// 不推荐
const curRenderData = ref([]);
watch([() => props.tableData, () => props.virtualList], () => {
if (props.virtualList) {
const { actualRenderData } = useOptimizeVirtualList({
...
});
curRenderData.value = actualRenderData; // 只能给ref定义的变量赋常量 赋响应式数据无效
curRenderData.value = actualRenderData.value; // 这样赋值 滚动表格 curRenderData不能响应式更新表格数据 actualRenderData是响应式且能根据表格滚动更新数据 curRenderData的值为第一次计算actualRenderData对应的值 后面不会再变化
}
});
完整代码
文件目录结构
lua
|-- components
|-- wang-table
|-- table-column.vue // 多级表头组件
|-- wang-table.vue // 表格组件
|-- views
|-- wangTable
|-- baseTable.vue // 使用表格组件的文件
代码展示
Vue
// table-column.vue
<template>
<el-table-column v-bind="props.columnHeader">
<template v-for="(item, index) in columnHeader.children" :key="index">
<TableColumn v-if="item.children && item.children.length" :column-header="item" />
<el-table-column v-else v-bind="item">
<template v-if="item.cellRender" #default="{ row }">
<component :is="item.cellRender(row)" />
</template>
</el-table-column>
</template>
</el-table-column>
</template>
<script setup>
const props = defineProps({
columnHeader: {
type: Object,
required: true
}
});
</script>
// wang-table.vue
<template>
<el-table
ref="tableRef"
:class="['custome-table', { 'virtual-list-table': props.virtualList }]"
v-bind="$attrs"
:data="props.virtualList ? curRenderData.data : tableData"
:header-cell-style="headstyle"
border
@mousemove="handleMousemove"
@mouseleave="handleMouseleave"
>
<el-table-column
v-if="props.showOrderNumber"
:label="props.orderNumberLabel"
type="index"
align="center"
:width="80 * ratio"
></el-table-column>
<template v-for="column in props.tableHeader" :key="column">
<table-column v-if="column.children && column.children.length > 0" :column-header="column"></table-column>
<el-table-column v-else v-bind="column">
<template v-if="column.cellRender" #default="{ row }">
<component :is="column.cellRender(row)" />
</template>
</el-table-column>
</template>
<slot></slot>
</el-table>
<div v-if="props.page" class="common-table-page">
<el-pagination
:page-size="page.pagesize"
background
layout="prev, pager, next,sizes,total, jumper"
:total="page.total"
:current-page="currentPage"
@current-change="changePageNo"
@size-change="changePagesize"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import TableColumn from "./table-column.vue";
import useOptimizeVirtualList from "@/hooks/useOptimizeVirtualList";
type HtmlElType = HTMLElement | null;
type Mapper<T> = { [P in keyof T as string]?: string | object };
interface Ipage {
total: number;
pageNo: number;
pagesize: number;
}
interface IvirtualList {
itemHeight?: number; // 列表项的大致高度
bufferRatio?: number; // 缓冲比例
throttleTime?: number; // 节流时间
}
interface Props {
tableData: Array<any>;
tableHeader: Mapper<any>;
page?: Ipage;
showOrderNumber?: boolean; // 是否显示序号
orderNumberLabel?: string; // 序号列的列名
autoScroll?: boolean; // 是否滚动
virtualList?: IvirtualList | boolean;
}
const props = withDefaults(defineProps<Props>(), {
showOrderNumber: true,
orderNumberLabel: "序号",
autoScroll: false
});
const emit = defineEmits<{
(e: "changePageNo", pageNo: number): void;
(e: "changePagesize", pagesize: number): void;
}>();
const ratio = ref(window.innerWidth / 1920);
const currentPage = ref(1);
const changePageNo = pageNo => {
currentPage.value = pageNo;
emit("changePageNo", pageNo);
};
const changePagesize = pagesize => {
//条数变化 页码恢复为1
currentPage.value = 1;
emit("changePagesize", pagesize);
};
//表头样式
const headstyle = () => {
return {
"text-align": "center"
};
};
//表格滚动
const tableRef = ref<HtmlElType>(null);
let timer = null; // 定时器
let time = Date.now(); // 定义 time 变量
const createScroll = () => {
if (Date.now() - time >= 50) {
time = Date.now();
// 拿到 table
const table = tableRef.value?.layout?.table?.refs; // 拿到可以滚动的元素
if (!table || !table.bodyWrapper) {
console.error("Table or bodyWrapper not found");
return;
}
const tableWrapper = table.bodyWrapper.firstElementChild.firstElementChild;
if (!tableWrapper) {
console.error("Table wrapper not found");
return;
}
tableWrapper.scrollTop = tableWrapper.scrollTop + 1;
// 判断是否滚动到底部,如果到底部了置为0(可视高度+距离顶部=整个高度)
if (tableWrapper.clientHeight + tableWrapper.scrollTop >= tableWrapper.scrollHeight) {
tableWrapper.scrollTop = 0;
}
}
timer = window.requestAnimationFrame(createScroll);
};
// 提供一个方法来停止滚动
const stopScroll = () => {
if (timer !== null) {
window.cancelAnimationFrame(timer);
timer = null;
}
};
watch(
() => props.autoScroll,
() => {
if (props.autoScroll) {
createScroll();
} else {
stopScroll();
}
},
{
immediate: true
}
);
/**
* 处理鼠标移动事件的函数
*
* 本函数的目的是在鼠标移动时根据props.autoScroll的值决定是否停止自动滚动
* 这对于在自动滚动状态下进行用户交互时提高用户体验非常关键
*/
const handleMousemove = () => {
if (props.autoScroll) {
stopScroll();
}
};
/**
* 处理鼠标离开事件的函数
* 当鼠标离开时,根据props.autoScroll的值决定是否创建滚动效果
*/
const handleMouseleave = () => {
if (props.autoScroll) {
createScroll();
}
};
// 虚拟列表
// 需要使用 reactive 语法,否则无法响应式更新 使用ref定义的变量来接收actualRenderData 会导致响应式更新无效
const curRenderData = reactive({
data: null
});
watch([() => props.tableData, () => props.virtualList], () => {
if (props.virtualList) {
const { actualRenderData } = useOptimizeVirtualList({
data: ref(props.tableData), // 列表项数据
scrollContainer: ".el-table .el-scrollbar__wrap", // 滚动容器
actualHeightContainer: ".el-table .el-scrollbar__view", // 渲染实际高度的容器
translateContainer: ".el-table .el-table__body", // 需要偏移的目标元素,
itemContainer: ".el-table__row", // 列表项
itemHeight: props.virtualList?.itemHeight || 40, // 列表项的大致高度
bufferRatio: props.virtualList?.bufferRatio || 1, // 缓冲比例
throttleTime: props.virtualList?.throttleTime || 50 // 节流时间
});
curRenderData.data = actualRenderData;
}
});
</script>
<style lang="scss" scoped>
.el-table :deep(.cell) {
white-space: pre-line !important;
}
.common-table-page {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.virtual-list-table {
:deep(.el-scrollbar__wrap) {
height: 100%;
overflow: auto;
position: relative;
}
:deep(.el-table__body) {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
</style>
// baseTable.vue
<template>
<WangTable
:table-header="tableHeader"
:table-data="tableData"
:show-order-number="false"
:virtual-list="virtualList"
height="500"
/>
</template>
<script setup>
const tableHeader = [
{
prop: "index",
label: "#",
align: "center",
width: "120"
},
{
prop: "date",
label: "Date",
width: "160",
align: "center",
cellRender: row => {
return h(
ElTag,
{
type: "primary",
closable: true
},
() => row.date // https://blog.csdn.net/qq_42403461/article/details/142248823
);
}
},
{
label: "Delivery Info",
children: [
{
prop: "name",
label: "Name",
width: "120",
align: "center"
},
{
label: "Address Info",
children: [
{
prop: "state",
label: "State",
width: "120"
},
{
prop: "city",
label: "City",
width: "120"
},
{
prop: "address",
label: "Address",
align: "center"
},
{
prop: "zip",
label: "Zip",
width: "120",
cellRender: row => {
return h(
ElButton,
{
type: "success",
closable: true
},
() => row.zip // https://blog.csdn.net/qq_42403461/article/details/142248823
);
}
}
]
}
]
}
];
const tableData = ref([]);
const virtualList = ref({});
setTimeout(() => {
tableData.value = new Array(10000).fill({}).map((_, index) => ({
index: index + 1,
date: "2016-05-03",
name: "Tom",
state: "California",
city: "Los Angeles",
address: "No. 189, Grove St, Los Angeles",
zip: "CA 90036"
}));
virtualList.value = {
itemHeight: 50
};
}, 1000);
</script>
总结
主要介绍了不到300行代码实现超级表格的原理以及注意事项。当然,目前代码仅能满足大部分使用场景,对于一些复杂的使用场景未进行相关开发,后续将根据项目实战经验进行优化与补充。