从源码 解析vxe-table 虚拟滚动的实现二(实现miniVxeTable)

457 阅读1分钟

前言

上篇我们通过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功能已经完成 _2025-05-27 200457.gif

滚动条滚动的时候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>
}

image.png

滚动问题解决了,但由于 每个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>
}

image.png

二.完整代码

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>

4.线上访问链接

miniVxeTable