前言
上篇我们通过vxe-table源码解析了其虚拟表格的结构、以及相关组件的核心事件,初步了解了其实现的原理。方便我们对其实现的知识巩固,下面我们实现一个mini版的vxe-table。
一.miniVxeTable 主体内容
table组件组要分为
header,body,footer, 三个部分 我们针对body做Y轴虚拟滚动逻辑
1.组件主要结构
<template>
<!-- 为了方便理解 结构&实现命名上尽量与vxe-table同步 -->
<div class="vxe-table">
<!-- 绑定滚动事件 -->
<div class="vxe-table--main-wrapper" onScroll={handleScroll}>
<!-- 滚动条高度占位 -->
<div class="vxe-table--body-space" style={{ height: `${computeTotalHeight.value}px` }}/>
<table>
{renderHeader()}
{renderBody()}
{renderFooter()}
</table>
</div>
</div>
</template>
<script setup lang="ts">
// 为了方便理解 结构&实现命名上尽量与vxe-table同步
// body 滚动条触发数据截取
const handleScroll = () => {
// startIndex, endIndex 计算
}
// 渲染表体
const renderBody = () => {
return <tbody>
{/* 截取的data数据 遍历生成 <tr>{tds}</tr> */}
</tbody>
}
// 渲染表头
const renderHeader = () => {
// thead渲染
return <thead>
<tr class="vxe-header--row">
{/*ths*/}
</tr>
</thead>
}
// 渲染表尾
const renderFooter = () => {
// tfoot渲染
return <tfoot>
<tr class="vxe-footer--row">
{/*tds*/}
</tr>
</tfoot>
}
</script>
2.组件封装
组件props 内部数据准备
import { ref } from 'vue'
type Column = {
field: string
title: string
width?: number
// td渲染
render?: (row: Record<string, any>) => {}
// 底部td渲染
footerRender?: () => {}
}
const props = defineProps({
// 数据源
data: {
type: Array,
default: () => []
},
// 列配置
columns: {
type: Array as () => Column[],
default: () => []
},
// 虚拟滚动相关配置
virtualYConfig: {
type: Object as () => ({
// 开启虚拟滚动
enabled: boolean
// td行高
rowHeight: 40
// 缓冲数量
bufferSize: 10
}),
default: () => ({
enabled: true,
rowHeight: 40,
bufferSize: 10 // 同vxe-table的oSize
})
}
})
// 组件内部响应式数据
const reactData = ref({
// y轴虚拟滚动相关数据参数
scrollYStore: {
// 截取开始下标
startIndex: 0,
// 截取结束
endIndex: 0,
// 展示条目
visibleSize: 0,
// 滚动条scrollTop
scrollTop: 0
}
})
renderHeader
// 渲染表头
const renderHeader = () => {
// thead渲染
return <thead>
<tr class="vxe-header--row">
{
props.columns.map(column => {
return <th class="vxe-header--column" key={column.field}>
{column.title}
</th>
})
}
</tr>
</thead>
}
renderFooter
// 渲染表尾
const renderFooter = () => {
// tfoot渲染
return <tfoot>
<tr class="vxe-footer--row">
{
props.columns.map(column => {
return <td class="vxe-footer--column" key={column.field}>
{column.footerRender ? column.footerRender() : ''}
</td>
})
}
</tr>
</tfoot>
}
renderBody
// 渲染表体
const renderBody = () => {
return <tbody>
{computeVisibleData.value.map((row, index) => (
<tr key={row.id || index}>
{props.columns.map(column => (
<td key={column.field}>
{column.render ? column.render(row) : row[column.field]}
</td>
))}
</tr>
))}
</tbody>
}
滚动修改截取的数据
/ 滚动事件处理
const handleScroll = (evt: Event) => {
const target = evt.target as HTMLElement
const { scrollTop, clientHeight } = target
// 计算新的 startIndex 和 endIndex
const rowHeight = props.virtualYConfig.rowHeight
const startIndex = Math.floor(scrollTop / rowHeight)
const visibleSize = Math.ceil(clientHeight / rowHeight)
const endIndex = Math.min(startIndex + visibleSize + (props.virtualYConfig.bufferSize || 0), props.data.length)
reactData.value.scrollYStore = {
startIndex,
endIndex,
visibleSize,
scrollTop
}
}
// 计算表格总高度
const computeTotalHeight = computed(() => {
return props.data.length * props.virtualYConfig.rowHeight
})
// 计算当前应该渲染的数据
const computeVisibleData = computed(() => {
const { startIndex, endIndex } = reactData.value.scrollYStore
return props.data.slice(startIndex, endIndex)
})
通过以上逻辑处理之后 虚拟table功能已经完成
滚动条滚动的时候header头部也跟着滚动了,正常的
table都是body内容才做滚动的。那我们再整改下
滚动条滚动只允许滚动body内容整改
为了更好管理我们的 head,body,foot 我们给相关部分都包裹一层table
// 主内容区
const renderVN = () => {
return (
<div class="vxe-table">
<div class="vxe-table--main-wrapper">
{renderHeader()}
{renderBody()}
{renderFooter()}
</div>
</div>
)
}
const renderHeader = () => {
return <div class="vxe-table--header-wrapper">
<div class="vxe-table--header-inner-wrapper">
<table class="vxe-table--header">
<thead>
<tr class="vxe-header--row">
{props.columns.map(column => (
<th class="vxe-header--column" key={column.field}>
<div class="vxe-cell">{column.title}</div>
</th>
))}
</tr>
</thead>
</table>
</div>
</div>
}
const renderFooter = () => {
return <div class="vxe-table--footer-wrapper">
<div class="vxe-table--footer-inner-wrapper">
<table class="vxe-table--footer">
<tfoot>
<tr class="vxe-footer--row">
{props.columns.map(column => (
<td class="vxe-footer--column" key={column.field}>
<div class="vxe-cell">{column.footerRender ? column.footerRender() : ''}</div>
</td>
))}
</tr>
</tfoot>
</table>
</div>
</div>
}
const renderBody = () => {
return <div class="vxe-table--body-inner-wrapper" ref="refBodyScroll" onScroll={handleScroll}>
<div class="vxe-body--y-space" style={{ height: `${computeTotalHeight.value}px` }}></div>
<table class="vxe-table--body" style={computeVisibleStyle.value}>
<tbody>
{computeVisibleData.value.map((row, index) => (
<tr class="vxe-body--row" key={row.id || index}>
{props.columns.map(column => (
<td class="vxe-body--column" key={column.field}>
<div class="vxe-cell">{column.render ? column.render(row) : row[column.field]}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
}
滚动问题解决了,但由于 每个
render都包裹了一层table的缘故,head,body,foot对应的td错位了,需要给table内配置下colgroup
// renderBody renderFooter 同理
const renderHeader = () => {
// ...
<table class="vxe-table--header">
{{/*++++++ colgroup配置*/}}
<colgroup>
{props.columns.map(column => {
return <col key={column.field} style={`width: ${column.width || 100}px`}></col>
})}
</colgroup>
{/* ... */}
</table>
}
二.完整代码
1.组件样式
// ./components/vxeTable.scss
.vxe-table {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
* {
box-sizing: border-box;
}
}
.vxe-table--main-wrapper {
height: 100%;
position: relative;
min-width: 100%;
display: flex;
flex-direction: column;
}
.vxe-table--header-wrapper,
//.vxe-table--body-wrapper,
.vxe-table--footer-wrapper {
position: relative;
width: 100%;
}
/*.vxe-table--body-wrapper {
flex: 1;
}*/
.vxe-table--header,
.vxe-table--body,
.vxe-table--footer {
width: 100%;
border-collapse: collapse;
//border: 0;
//border-spacing: 0;
//border-collapse: separate;
}
.vxe-header--row,
.vxe-body--row,
.vxe-footer--row {
height: 40px;
}
.vxe-header--column,
.vxe-body--column,
.vxe-footer--column {
padding: 8px;
//border: 1px solid #e8eaec;
background-image: linear-gradient(#e8eaec, #e8eaec), linear-gradient(#e8eaec, #e8eaec);
/*linear-gradient(var(--vxe-ui-table-border-color), var(--vxe-ui-table-border-color)), linear-gradient(var(--vxe-ui-table-border-color), var(--vxe-ui-table-border-color))*/
text-align: left;
background-repeat: no-repeat;
//background-size: 1px 100%, 100% 1px;
background-size: 1px 100%, 100% 1px;
background-position: right top, right bottom;
}
.vxe-cell {
position: relative;
height: 100%;
line-height: 24px;
}
.vxe-body--y-space {
width: 0;
float: left;
}
.vxe-table--header-inner-wrapper,
.vxe-table--footer-inner-wrapper {
//overflow-y: hidden;
//overflow-x: scroll;
overflow-y: scroll;
}
.vxe-table--body-inner-wrapper {
overflow-y: scroll;
background-color: #fff;
//overflow-x: scroll;
}
.vxe-table--header-wrapper {
background-color: #f8f8f9;
}
.vxe-table--footer-wrapper {
background-color: #fff;
border-top: 1px solid #e8eaec;
margin-top: -1px;
}
2.组件代码
// ./components/VxeTable.tsx
import { defineComponent, ref, computed } from 'vue'
import './vxeTable.scss'
type Column = {
field: string
title: string
width?: number
// td渲染
render?: (row: Record<string, any>) => {}
// 底部td渲染
footerRender?: () => {}
}
export default defineComponent({
name: 'VxeTable',
props: {
// 数据源
data: {
type: Array,
default: () => []
},
// 列配置
columns: {
type: Array as () => Column[],
default: () => []
},
// 是否显示表头
showHeader: {
type: Boolean,
default: true
},
// 是否显示表尾
showFooter: {
type: Boolean,
default: false
},
// 虚拟滚动相关配置
virtualYConfig: {
type: Object as () => {
// 开启虚拟滚动
enabled: boolean
// td行高
rowHeight: 40
// 缓冲数量
bufferSize: 10
},
default: () => ({
enabled: true,
rowHeight: 40,
bufferSize: 10 // 同vxe-table的oSize
})
}
},
setup(props, { slots }) {
// 响应式数据
const reactData = ref({
scrollYStore: {
startIndex: 0,
endIndex: 1000,
visibleSize: 0,
scrollTop: 0
} /*,
scrollXStore: {
startIndex: 0,
endIndex: 0,
visibleSize: 0,
scrollLeft: 0
}*/
})
// 计算表格总高度
const computeTotalHeight = computed(() => {
return props.data.length * props.virtualYConfig.rowHeight
})
// 计算当前应该渲染的数据
const computeVisibleData = computed(() => {
if (props.virtualYConfig.enabled) {
return props.data.slice(reactData.value.scrollYStore.startIndex, reactData.value.scrollYStore.endIndex)
}
return props.data
})
// 计算可视区域的样式
const computeVisibleStyle = computed(() => {
const { startIndex } = reactData.value.scrollYStore
return {
transform: `translateY(${startIndex * props.virtualYConfig.rowHeight}px)`
}
})
// 滚动事件处理
const handleScroll = (evnt: Event) => {
const target = evnt.target as HTMLElement
const { scrollTop, scrollLeft, clientHeight } = target
// console.error(handleScroll, 'handleScroll', target.clientHeight)
if (props.virtualYConfig.enabled) {
// 计算新的 startIndex 和 endIndex
const rowHeight = props.virtualYConfig.rowHeight
const startIndex = Math.floor(scrollTop / rowHeight)
const visibleSize = Math.ceil(clientHeight / rowHeight)
const endIndex = Math.min(startIndex + visibleSize + (props.virtualYConfig.bufferSize || 0), props.data.length)
reactData.value.scrollYStore = {
startIndex,
endIndex,
visibleSize,
scrollTop
}
}
}
// 渲染表头
const renderHeader = () => {
if (!props.showHeader) return null
return (
<div class="vxe-table--header-wrapper">
<div class="vxe-table--header-inner-wrapper">
<table class="vxe-table--header">
<colgroup>
{props.columns.map(column => {
return <col key={column.field} style={`width: ${column.width || 100}px`}></col>
})}
</colgroup>
<thead>
<tr class="vxe-header--row">
{props.columns.map(column => (
<th class="vxe-header--column" key={column.field}>
<div class="vxe-cell">{column.title}</div>
</th>
))}
</tr>
</thead>
</table>
</div>
</div>
)
}
// 渲染表体
const renderBody = () => {
return (
// <div class="vxe-table--body-wrapper">
<div class="vxe-table--body-inner-wrapper" ref="refBodyScroll" onScroll={handleScroll}>
<div class="vxe-body--y-space" style={{ height: `${computeTotalHeight.value}px` }}></div>
<table class="vxe-table--body" style={computeVisibleStyle.value}>
<colgroup>
{props.columns.map(column => {
return <col key={column.field} style={`width: ${column.width || 100}px`}></col>
})}
</colgroup>
<tbody>
{computeVisibleData.value.map((row, index) => (
<tr class="vxe-body--row" key={row.id || index}>
{props.columns.map(column => (
<td class="vxe-body--column" key={column.field}>
<div class="vxe-cell">{column.render ? column.render(row) : row[column.field]}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
// </div>
)
}
// 渲染表尾
const renderFooter = () => {
if (!props.showFooter) return null
return (
<div class="vxe-table--footer-wrapper">
<div class="vxe-table--footer-inner-wrapper">
<table class="vxe-table--footer">
<colgroup>
{props.columns.map(column => {
return <col key={column.field} style={`width: ${column.width || 100}px`}></col>
})}
</colgroup>
<tfoot>
<tr class="vxe-footer--row">
{props.columns.map(column => (
<td class="vxe-footer--column" key={column.field}>
<div class="vxe-cell">{column.footerRender ? column.footerRender() : ''}</div>
</td>
))}
</tr>
</tfoot>
</table>
</div>
</div>
)
}
// 主渲染函数
const renderVN = () => {
return (
<div class="vxe-table">
<div class="vxe-table--main-wrapper">
{renderHeader()}
{renderBody()}
{renderFooter()}
</div>
</div>
)
}
return () => renderVN()
}
})
3.miniVxeTable示例
<template>
<div style="height: 400px">
<Simple_VxeTable :columns="columns" :data="tableData" show-footer />
</div>
</template>
<script setup lang="tsx">
import Simple_VxeTable from './components/VxeTable.tsx'
const tableData = Array.from({ length: 1000 }).map((_, index) => {
return {
id: index + 1,
name: `用户${index + 1}`,
age: Math.floor(Math.random() * 50) + 20,
address: `地址${index + 1}`
}
})
const columns = [
{
field: 'id',
title: 'ID',
width: 100,
fixed: 'left',
footerRender() {
return 'footer ID'
}
},
{
field: 'name',
title: '姓名',
width: 150,
footerRender() {
return 'footer 姓名'
}
},
{
field: 'age',
title: '年龄',
width: 100,
footerRender() {
return 'footer 年龄'
}
},
{
field: 'address',
title: '地址',
width: 300,
fixed: 'right',
footerRender() {
return 'footer 地址'
}
}
]
</script>