使用table-v2
官方文档: element-plus.gitee.io/zh-CN/compo...
- 在根目录下的main.ts文件中引入element-plus的样式
js
import 'element-plus/dist/index.css'
- 在组件文件中的实现代码如下
js
<template>
<el-table-v2
:columns="columns"
:data="data"
:width="700"
:height="400"
fixed
/>
</template>
<script lang="ts" setup>
import { ElTableV2 } from 'element-plus';
const generateColumns = (length = 10, prefix = 'column-', props?: any) =>
Array.from({ length }).map((_, columnIndex) => ({
...props,
key: `${prefix}${columnIndex}`,
dataKey: `${prefix}${columnIndex}`,
title: `Column ${columnIndex}`,
width: 150,
}))
// 此方法用于构造1000条测试数据
const generateData = (
columns: ReturnType<typeof generateColumns>,
length = 200,
prefix = 'row-'
) =>
Array.from({ length }).map((_, rowIndex) => {
return columns.reduce(
(rowData, column, columnIndex) => {
rowData[column.dataKey] = `Row ${rowIndex} - Col ${columnIndex}`
return rowData
},
{
id: `${prefix}${rowIndex}`,
parentId: null,
}
)
})
const columns = generateColumns(10)
const data = generateData(columns, 1000)
</script>
- 页面最终的呈现
如上图,我们来分析下这个虚拟表格的组成
-
最外第一层是一个高350px,宽700px的相对定位容器,
will-change: transform;
通常被用作合成层提示。在给定特定的 CSS 属性标识时,Chrome 目前会执行两个操作:建立新的合成层或新的层叠上下文。 -
第二层是一个高50000px(1000 * 50px)的相对定位容器
-
第三层是展示10个高50px的列表项
总共有1000个列表项, 但每次只展示10个
每一项是绝对定位容器, 每一项都计算出top值, 第一行的top=0,第二行top=50px,第三行top=100px...
element-plus实现虚拟表格源码解析
js
const renderWindow = () => {
const Container2 = resolveDynamicComponent(props.containerElement);
const { horizontalScrollbar, verticalScrollbar } = renderScrollbars();
const Inner = renderInner();
return h("div", {
key: 0,
class: ns.e("wrapper"),
role: props.role
}, [
h(Container2, {
class: props.className,
style: unref(windowStyle),
onScroll, // scroll事件
onWheel, // wheel事件
ref: windowRef
}, !isString(Container2) ? { default: () => Inner } : Inner),
horizontalScrollbar, // 在第一层容器,overflow:hidden禁止了滚动条,element-plus自己实现的垂直滚动条
verticalScrollbar // 水平滚动条
]);
};
return renderWindow;
}
如上所示,在第一层容器上监听wheel和scroll事件,因为第一层容器将overflow设置为hidden, 应该不会触发scroll事件,只会触发wheel事件。但wheel事件可能会触发scroll事件, 在源码中scroll事件的毁掉函数中已经做了去重处理。
onwheel 是鼠标滚轮旋转,而 onscroll 处理的是对象内部内容区的滚动事件。
js
const onWheel = (e) => {
cAF(frameHandle); // 取消请求动画帧或者定时器
let x2 = e.deltaX; // 水平方向滚动量
let y = e.deltaY; // 垂直方向滚动量
// 只保留一个方向的滚动
if (Math.abs(x2) > Math.abs(y)) {
y = 0;
} else {
x2 = 0;
}
if (e.shiftKey && y !== 0) {
x2 = y;
y = 0;
}
if (hasReachedEdge(xOffset, yOffset) && hasReachedEdge(xOffset + x2, yOffset + y))
return;
// 累加多次的滚动量
xOffset += x2;
yOffset += y;
e.preventDefault(); // 阻止weel事件的默认行为
frameHandle = rAF(() => {
onWheelDelta(xOffset, yOffset);
xOffset = 0; // 重置滚动量
yOffset = 0;
});
};
return {
hasReachedEdge,
onWheel
};
};
var rAF = (fn2) => isClient ? window.requestAnimationFrame(fn2) : setTimeout(fn2, 16);
var cAF = (handle) => isClient ? window.cancelAnimationFrame(handle) : clearTimeout(handle);
window.requestAnimationFrame()
告诉浏览器------你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
使用window.requestAnimationFrame()
可以优化性能,将多次滚动合并为一次
js
const { onWheel } = useGridWheel({
atXStartEdge: computed2(() => states.value.scrollLeft <= 0),
atXEndEdge: computed2(() => states.value.scrollLeft >= estimatedTotalWidth.value - unref(parsedWidth)),
atYStartEdge: computed2(() => states.value.scrollTop <= 0),
atYEndEdge: computed2(() => states.value.scrollTop >= estimatedTotalHeight.value - unref(parsedHeight))
}, (x2, y) => {
var _a2, _b, _c, _d;
(_b = (_a2 = hScrollbar.value) == null ? void 0 : _a2.onMouseUp) == null ? void 0 : _b.call(_a2);
(_d = (_c = vScrollbar.value) == null ? void 0 : _c.onMouseUp) == null ? void 0 : _d.call(_c);
const width = unref(parsedWidth); // 第一层容器的宽度
const height = unref(parsedHeight); // 第一层容器的高度
scrollTo({
scrollLeft: Math.min(states.value.scrollLeft + x2, estimatedTotalWidth.value - width), // estimatedTotalWidth 第二层容器的宽度
scrollTop: Math.min(states.value.scrollTop + y, estimatedTotalHeight.value - height) // estimatedTotalHeight 第二层容器的宽度
});
});
js
const scrollTo = ({
scrollLeft = states.value.scrollLeft,
scrollTop = states.value.scrollTop
}) => {
scrollLeft = Math.max(scrollLeft, 0);
scrollTop = Math.max(scrollTop, 0);
const _states = unref(states);
if (scrollTop === _states.scrollTop && scrollLeft === _states.scrollLeft) {
return;
}
states.value = {
..._states,
xAxisScrollDir: getScrollDir(_states.scrollLeft, scrollLeft),
yAxisScrollDir: getScrollDir(_states.scrollTop, scrollTop),
scrollLeft,
scrollTop,
updateRequested: true
};
nextTick(() => resetIsScrolling());
onUpdated2();
emitEvents();
};
js
const onUpdated2 = () => {
const { direction: direction2 } = props;
const { scrollLeft, scrollTop, updateRequested } = unref(states);
const windowElement = unref(windowRef);
if (updateRequested && windowElement) {
if (direction2 === RTL) {
switch (getRTLOffsetType()) {
case RTL_OFFSET_NAG: {
windowElement.scrollLeft = -scrollLeft;
break;
}
case RTL_OFFSET_POS_ASC: {
windowElement.scrollLeft = scrollLeft;
break;
}
default: {
const { clientWidth, scrollWidth } = windowElement;
windowElement.scrollLeft = scrollWidth - clientWidth - scrollLeft;
break;
}
}
} else {
windowElement.scrollLeft = Math.max(0, scrollLeft);
}
windowElement.scrollTop = Math.max(0, scrollTop);
}
};
给第一层容器的scrollTop和scrollLeft重新赋值,从而实现滚动的效果
js
const rowsToRender = computed(() => {
const { totalColumn, totalRow, rowCache } = props
const { isScrolling, yAxisScrollDir, scrollTop } = unref(states)
if (totalColumn === 0 || totalRow === 0) {
return [0, 0, 0, 0]
}
// 计算开始展示行
const startIndex = getRowStartIndexForOffset(
props,
scrollTop,
unref(cache)
)
// 计算结束展示行
const stopIndex = getRowStopIndexForStartIndex(
props,
startIndex,
scrollTop,
unref(cache)
)
const cacheBackward =
!isScrolling || yAxisScrollDir === BACKWARD
? Math.max(1, rowCache)
: 1
const cacheForward =
!isScrolling || yAxisScrollDir === FORWARD ? Math.max(1, rowCache) : 1
return [
Math.max(0, startIndex - cacheBackward),
Math.max(0, Math.min(totalRow! - 1, stopIndex + cacheForward)),
startIndex,
stopIndex,
]
})
使用计算属性计算展示的起始行和结束行。
js
getRowStartIndexForOffset: ({ rowHeight, totalRow }, scrollTop) => Math.max(0, Math.min(totalRow - 1, Math.floor(scrollTop / rowHeight))),
getRowStopIndexForStartIndex: ({ rowHeight, totalRow, height }, startIndex, scrollTop) => {
const top = startIndex * rowHeight;
const numVisibleRows = Math.ceil((height + scrollTop - top) / rowHeight);
return Math.max(0, Math.min(totalRow - 1, startIndex + numVisibleRows - 1));
},
参考element-plus实现虚拟表格的原理,自己简单实现一个虚拟列表
js
<template>
<div style="position: relative;height: 300px; width: 700px; border: 1px solid #ddd; overflow: auto; will-change: transform;padding: 10px;"
@scroll="onScroll" @wheel="onWheel" ref="scrollRef" id="div1">
<div :style="'height: ' + (totalRow * rowHeight) + 'px;'" id="div2">
<div v-for="row in showData" style="display: flex; position: absolute; "
:class="row.rowIndex"
:style="'top:' + row.rowIndex * rowHeight + 'px;height:' + rowHeight + 'px;'">
<div v-for="i in 10" style="width: 150px;">{{ row['column-' + (i - 1)] }} </div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, computed, ref, unref } from 'vue';
import type { Ref } from 'vue'
const totalRow = 10000
const generateColumns = (length = 10, prefix = 'column-', props?: any) =>
Array.from({ length }).map((_, columnIndex) => ({
...props,
key: `${prefix}${columnIndex}`,
dataKey: `${prefix}${columnIndex}`,
title: `Column ${columnIndex}`,
width: 150,
}))
const generateData = (
columns: ReturnType<typeof generateColumns>,
length = 200,
prefix = 'row-'
) =>
Array.from({ length }).map((_, rowIndex) => {
return columns.reduce(
(rowData, column, columnIndex) => {
rowData[column.dataKey] = `Row ${rowIndex} - Col ${columnIndex}`
return rowData
},
{
id: `${prefix}${rowIndex}`,
rowIndex: rowIndex,
parentId: null,
}
)
})
const columns = generateColumns(10)
const data = generateData(columns, totalRow)
const showRow = reactive({
start: 0,
end: 0
})
let rowHeight = 50
// 根据scrollTop计算开始展示行,每行高50px,则开始展示行为scrollTop / 50
const showData = computed(() => {
let scrollTop = states.value.scrollTop
let num = Math.floor(scrollTop / rowHeight)
console.log("scrollTop=", scrollTop, num)
if (num > totalRow - 10) num = totalRow - 10 // 特殊处理, 触底时保底处理
showRow.start = num > 0 ? num : 0
showRow.end = showRow.start + 10
return data.slice(showRow.start, showRow.end)
})
const states = ref({
scrollTop: 0,
scrollLeft: 0,
})
//每次滚动量
let xOffset = 0
let yOffset = 0
let frameHandle : any = null
const onWheel = (e: WheelEvent) => {
e.preventDefault()// 阻止默认的weel事件执行
console.log("wheel 事件")
window.cancelAnimationFrame(frameHandle);
let x = e.deltaX; //水平方向的滚动量
let y = e.deltaY // 垂直方向的滚动量
// 只保留一个方向
if (Math.abs(x) > Math.abs(y)) {
y = 0
} else {
x = 0
}
if (y === 0 && x === 0) return // 没有滚动量
if (x === 0 && y < 0 && states.value.scrollTop <= 0) {
console.log("已经触顶了")
return //已经触顶了
}
if (x === 0 && y > 0 && states.value.scrollTop >= totalRow * rowHeight - 500 + 300) {
states.value.scrollTop = scrollRef.value.scrollTop
console.log('已经触底了')
return //已经触底了
}
if (y === 0 && x < 0 && states.value.scrollLeft <= 0 ) {
console.log("水平方向已经滚动到最左侧了")
states.value.scrollLeft = 0;
x = 0
return
}
if (y === 0 && x > 0 && states.value.scrollLeft > 150 * 10 - 700) {
console.log('水平方向已经滚动到最右侧了')
x = 0
states.value.scrollLeft = scrollRef.value.scrollLeft
return //已经触底了
}
console.log("水平滚动量", x, states.value.scrollLeft)
console.log("垂直滚动量", y)
// 累加多次的滚动量
xOffset += x
yOffset += y
// 优化性能
frameHandle = window.requestAnimationFrame(() => {
onWheelDelta(xOffset, yOffset)
xOffset = 0
yOffset = 0
})
}
const onWheelDelta = (x: any, y: any) => {
states.value.scrollLeft += x
states.value.scrollTop += y
if (states.value.scrollTop < 0) states.value.scrollTop = 0; // 这里要注意,
scrollTo()
}
const scrollRef : Ref<any> = ref({
scrollLeft: Number,
scrollTop: Number
})
const scrollTo = () => {
scrollRef.value.scrollLeft = states.value.scrollLeft
scrollRef.value.scrollTop = states.value.scrollTop
}
const onScroll = (e : any) => {
e.preventDefault() // 阻止触发默认的scroll事件
const {
scrollLeft,
scrollTop,
} = e.currentTarget;
const _states = unref(states);
if (_states.scrollTop === scrollTop && _states.scrollLeft === scrollLeft) {
return; // 滚轮事件已经触发,不再执行滚动事件
}
console.log("滚动事件")
states.value = {
...states,
scrollLeft: scrollLeft,
scrollTop: Math.max(0, scrollTop)
}
}
</script>