Skip to content

Commit 41e6df0

Browse files
authored
feat: 添加virtual-scroller (#949)
* feat: 添加virtual-scroller * feat: 优化 * refactor: 优化虚拟列表性能和滚动体验 * feat: 优化 * feat: 还原
1 parent 2539b23 commit 41e6df0

15 files changed

Lines changed: 650 additions & 40 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@
1212
"[json]": {
1313
"editor.defaultFormatter": "esbenp.prettier-vscode"
1414
},
15-
// md 文件按 prettier 规范格式化
16-
"[md]": {
17-
"editor.defaultFormatter": "esbenp.prettier-vscode"
18-
},
1915
"prettier.printWidth": 180,
2016

2117
// Enable eslint for all supported languages
@@ -24,6 +20,6 @@
2420
"javascriptreact",
2521
"typescript",
2622
"typescriptreact",
27-
"vue",
23+
"vue"
2824
]
2925
}

components/scrollbar/style/index.less

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
width: 100%;
1717
height: 100%;
1818
overflow: hidden;
19+
1920
.@{scrollbar-prefix-cls}-shadow-top {
2021
.shadow(@scrollbar-shadow-size, 'top')
2122
}
@@ -32,6 +33,11 @@
3233
width: 100%;
3334
height: 100%;
3435
overflow: auto;
36+
// 启用硬件加速和平滑滚动
37+
transform: translate3d(0, 0, 0);
38+
// 包含优化,减少重排重绘
39+
contain: layout style paint;
40+
will-change: scroll-position;
3541
}
3642
&-content {
3743
box-sizing: border-box;

components/virtual-list/listItem.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* item component, we need to know their size change at any time
33
*/
44

5-
import { cloneVNode, computed, defineComponent, ref } from 'vue';
5+
import { cloneVNode, computed, defineComponent, onBeforeUnmount, ref } from 'vue';
66
import useResize from '../_util/use/useResize';
77
import { getFirstValidNode } from '../_util/vnode';
88
import getElementFromVueInstance from '../_util/getElementFromVueInstance';
@@ -14,20 +14,51 @@ export const FVirtualListItem = defineComponent({
1414
props: itemProps,
1515
setup(props, { attrs }) {
1616
const itemRef = ref();
17+
let lastReportedSize = 0;
18+
let sizeCheckTimer: number | null = null;
1719

18-
// tell parent current size identify by unqiue key
20+
// 优化的尺寸变化检测和上报
1921
const dispatchSizeChange = () => {
22+
if (!itemRef.value) {
23+
return;
24+
}
25+
2026
const shapeKey = props.horizontal ? 'offsetWidth' : 'offsetHeight';
21-
const s = itemRef.value ? itemRef.value[shapeKey] : 0;
22-
(attrs as any).onItemResized(props.uniqueKey, s);
27+
const currentSize = itemRef.value[shapeKey] || 0;
28+
29+
// 只有当尺寸显著变化时才上报,避免频繁的微小变化
30+
// 阈值可以根据实际情况调整,例如设置为2px
31+
const sizeThreshold = 2;
32+
if (Math.abs(currentSize - lastReportedSize) >= sizeThreshold) {
33+
lastReportedSize = currentSize;
34+
(attrs as any).onItemResized(props.uniqueKey, currentSize);
35+
}
2336
};
2437

38+
// 防抖的尺寸检查,并确保在组件卸载时清除定时器
39+
const debouncedSizeCheck = () => {
40+
if (sizeCheckTimer) {
41+
clearTimeout(sizeCheckTimer);
42+
}
43+
// 稍微增加延迟,减少高频触发,例如 30ms
44+
sizeCheckTimer = setTimeout(dispatchSizeChange, 16) as any;
45+
};
46+
47+
// 使用原有的useResize,但添加防抖
2548
useResize(
2649
itemRef,
27-
dispatchSizeChange,
50+
debouncedSizeCheck,
2851
computed(() => !props.observeResize),
2952
);
3053

54+
// 清理定时器
55+
onBeforeUnmount(() => {
56+
if (sizeCheckTimer) {
57+
clearTimeout(sizeCheckTimer);
58+
sizeCheckTimer = null;
59+
}
60+
});
61+
3162
return {
3263
itemRef,
3364
};

components/virtual-scroller/virtual-scroller.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export const virtualScrollerProps = {
5555
renderItemList: {
5656
type: Function as PropType<(itemVNodes: VNodeChild) => VNodeChild>,
5757
},
58+
topThreshold: {
59+
type: Number,
60+
default: 0,
61+
},
62+
bottomThreshold: {
63+
type: Number,
64+
default: 0,
65+
},
5866
} as const satisfies ComponentObjectPropsOptions;
5967

6068
export type VirtualScrollerProps = ExtractPublicPropTypes<typeof virtualScrollerProps>;
@@ -123,28 +131,36 @@ export default defineComponent({
123131
virtualRef.value?.scrollBy(offset);
124132
},
125133
getItemOffset: (index: number) => {
126-
virtualRef.value?.getItemOffset(index);
134+
return virtualRef.value?.getItemOffset(index);
127135
},
128136
getItemSize: (index: number) => {
129-
virtualRef.value?.getItemSize(index);
137+
return virtualRef.value?.getItemSize(index);
130138
},
131139
findStartIndex: () => {
132140
virtualRef.value?.findStartIndex();
133141
},
134142
findEndIndex: () => {
135143
virtualRef.value?.findEndIndex();
136144
},
145+
getOffset,
146+
getScrollSize,
147+
getClientSize,
137148
});
138149

150+
let lastScrollTop = 0;
139151
const onScroll = (e: Event) => {
140152
emit('scroll', e);
153+
const currentScrollTop = (e.target as HTMLElement).scrollTop;
154+
const isScrollUp = currentScrollTop < lastScrollTop;
155+
lastScrollTop = currentScrollTop;
156+
141157
const offset = getOffset();
142158
const scrollSize = getScrollSize();
143159
const clientSize = getClientSize();
144-
if (offset === 0) {
160+
if (isScrollUp && offset <= props.topThreshold) {
145161
emit('toTop');
146162
}
147-
if (offset + clientSize >= scrollSize) {
163+
if (!isScrollUp && offset + clientSize >= scrollSize - props.bottomThreshold) {
148164
emit('toBottom');
149165
}
150166
};

docs/.vitepress/components/virtualList/common.vue

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<template>
2-
<FVirtualScroller
2+
<FVirtualList
33
ref="virtualList"
44
class="virtual-scroll-list-vertical"
55
dataKey="id"
66
:dataSources="dataItems"
7-
:itemSize="100"
8-
:scrollbarProps="{ height: 400 }"
7+
:estimateSize="80"
8+
:height="height"
99
>
1010
<template #default="{ source, index }">
1111
<div :data-index="index" class="item-inner">
@@ -16,7 +16,7 @@
1616
<div class="desc">{{ source.desc }}</div>
1717
</div>
1818
</template>
19-
</FVirtualScroller>
19+
</FVirtualList>
2020
<FButton style="margin-top: 10px;" @click="addMessage">添加消息{{ dataItems.length }}</FButton>
2121
</template>
2222

@@ -76,8 +76,6 @@ export default {
7676
</script>
7777

7878
<style scoped>
79-
.virtual-scroll-list-vertical .item-inner {
80-
}
8179
.virtual-scroll-list-vertical .item-inner .head {
8280
font-weight: 500;
8381
}

docs/.vitepress/components/virtualList/index.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,10 @@ app.use(FVirtualList);
1212

1313
## 代码演示
1414

15-
### virtual-scroller
16-
17-
:::demo
18-
common.vue
19-
:::
20-
2115
### 不规则纵向滚动列表
2216

2317
:::demo
24-
vertical.vue
18+
common.vue
2519
:::
2620

2721
### 不规则横向滚动列表
@@ -36,6 +30,12 @@ horizontal.vue
3630
maxHeight.vue
3731
:::
3832

33+
### 无限滚动
34+
35+
:::demo
36+
infinite.vue
37+
:::
38+
3939
### 滚动操作
4040

4141
:::demo
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<template>
2+
<FVirtualList
3+
ref="virtualList"
4+
class="virtual-scroll-list-vertical"
5+
dataKey="id"
6+
:dataSources="dataItems"
7+
:estimateSize="80"
8+
:height="height"
9+
@toTop="handleToTop"
10+
@toBottom="handleToBottom"
11+
>
12+
<template #default="{ source, index }">
13+
<div :data-index="index" class="item-inner">
14+
<div class="head">
15+
<span># {{ source.index }}</span>
16+
<span>{{ source.name }}</span>
17+
</div>
18+
<div class="desc">{{ source.desc }}</div>
19+
</div>
20+
</template>
21+
</FVirtualList>
22+
</template>
23+
24+
<script>
25+
import { ref } from 'vue';
26+
27+
export default {
28+
name: 'Vertical',
29+
setup() {
30+
const virtualList = ref(null);
31+
const height = ref(400);
32+
const sentence3 = [
33+
'BFC(Block formatting context)直译为"块级格式化上下文"。它是一个独立的渲染区域,只有Block-level box参与, 它规定了内部的Block-level Box如何布局,并且与这个区域外部毫不相干。',
34+
'IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
35+
'margin 重合,margin 塌陷',
36+
'html5IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
37+
];
38+
const genUniqueId = (prefix) => {
39+
return `${prefix}$${Math.random().toString(16).substr(9)}`;
40+
};
41+
const getSentences = () => {
42+
const index = Math.floor(Math.random() * (sentence3.length - 1));
43+
return sentence3[index];
44+
};
45+
const TOTAL_COUNT = 1000;
46+
const dataItems = ref([]);
47+
48+
const createData = (length, startIndex, isAdd = true) => {
49+
const result = [];
50+
let count = length;
51+
while (count--) {
52+
const index = isAdd ? startIndex + length - count - 1 : startIndex - count - 1;
53+
result.push({
54+
index,
55+
name: `${Math.random()}`,
56+
id: genUniqueId(index),
57+
desc: getSentences(),
58+
});
59+
}
60+
return result;
61+
};
62+
63+
dataItems.value = createData(TOTAL_COUNT, 1);
64+
65+
const handleToBottom = () => {
66+
dataItems.value = [...dataItems.value, ...createData(10, dataItems.value[dataItems.value.length - 1].index + 1)];
67+
};
68+
const handleToTop = () => {
69+
dataItems.value = [...createData(10, dataItems.value[0].index, false), ...dataItems.value];
70+
setTimeout(() => {
71+
virtualList.value.scrollToIndex(10);
72+
}, 0);
73+
};
74+
return {
75+
virtualList,
76+
dataItems,
77+
height,
78+
handleToBottom,
79+
handleToTop,
80+
};
81+
},
82+
};
83+
</script>
84+
85+
<style scoped>
86+
.virtual-scroll-list-vertical .item-inner .head {
87+
font-weight: 500;
88+
}
89+
.virtual-scroll-list-vertical .item-inner .head span:first-child {
90+
margin-right: 1em;
91+
}
92+
.virtual-scroll-list-vertical .item-inner .desc {
93+
margin-top: 0.5em;
94+
margin-bottom: 1em;
95+
text-align: justify;
96+
}
97+
</style>

docs/.vitepress/components/virtualList/maxHeight.vue

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@
3232
<FVirtualList
3333
class="virtual-scroll-list-max-height"
3434
wrapClass="virtual-scroll-list-wrap"
35-
:dataKey="(data) => data"
35+
dataKey="id"
3636
:dataSources="vals"
3737
:estimateSize="80"
3838
:height="height"
3939
:maxHeight="maxHeight"
4040
>
4141
<template #default="{ source }">
4242
<div class="virtual-scroll-item">
43-
{{ source }}
43+
{{ source.desc }}
4444
</div>
4545
</template>
4646
</FVirtualList>
@@ -57,9 +57,36 @@ export default {
5757
const maxHeight = ref(200);
5858
5959
const vals = ref([]);
60-
for (let i = 0; i < 6; ++i) {
61-
vals.value.push(i);
62-
}
60+
const sentence3 = [
61+
'BFC(Block formatting context)直译为"块级格式化上下文"。它是一个独立的渲染区域,只有Block-level box参与, 它规定了内部的Block-level Box如何布局,并且与这个区域外部毫不相干。',
62+
'IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
63+
'margin 重合,margin 塌陷',
64+
'html5IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
65+
];
66+
const genUniqueId = (prefix) => {
67+
return `${prefix}$${Math.random().toString(16).substr(9)}`;
68+
};
69+
const getSentences = () => {
70+
const index = Math.floor(Math.random() * (sentence3.length - 1));
71+
return sentence3[index];
72+
};
73+
74+
const createData = (length, startIndex, isAdd = true) => {
75+
const result = [];
76+
let count = length;
77+
while (count--) {
78+
const index = isAdd ? startIndex + length - count - 1 : startIndex - count - 1;
79+
result.push({
80+
index,
81+
name: `${Math.random()}`,
82+
id: genUniqueId(index),
83+
desc: getSentences(),
84+
});
85+
}
86+
return result;
87+
};
88+
89+
vals.value = createData(1000, 1);
6390
6491
watch(
6592
heightType,
@@ -97,9 +124,7 @@ export default {
97124
width: 1000px;
98125
}
99126
.virtual-scroll-list-max-height .virtual-scroll-list-wrap .virtual-scroll-item {
100-
height: 36px;
101-
background: rgba(83, 132, 255, 0.06);
102-
border-bottom: 2px solid #fff;
127+
margin: 0.5em;
103128
}
104129
.virtual-scroll-list-max-height
105130
.virtual-scroll-list-wrap

0 commit comments

Comments
 (0)