惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
D
Docker
S
SegmentFault 最新的问题
博客园 - 聂微东
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
GbyAI
GbyAI
L
LangChain Blog
Vercel News
Vercel News
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
T
Threatpost
Scott Helme
Scott Helme
T
Tailwind CSS Blog
Latest news
Latest news
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
The Register - Security
The Register - Security
罗磊的独立博客
P
Proofpoint News Feed
腾讯CDC
S
Schneier on Security
雷峰网
雷峰网
A
About on SuperTechFans
T
Tenable Blog
F
Full Disclosure
Cyberwarzone
Cyberwarzone
博客园_首页
有赞技术团队
有赞技术团队
K
Kaspersky official blog

文章列表

NestJS实践杂记 React-记-3 React-记-2 React-记-1 闭包与内存泄漏 Vue3.5 更新了啥 Git lint 相关 集成 lint 代码规范工具 Monorepo 单仓库多应用 开发了一个 Canvas 2D 渲染引擎 Canvas 2D 事件 Canvas 2D 贝塞尔曲线 Canvas 2D 进阶 Canvas 2D 基础 Git 规范与实践 Git 熟知熟用 解决System占CPU过高 浏览器HTTP缓存 浏览器渲染流程
Vue+TS 实现虚拟列表
『轻笑Chuckle』 · 2024-07-04 · via

前言

将大量DOM元素直接渲染到页面性能是很差的,存在的问题:

  1. 大量DOM元素重绘,CPU开销大,滚动卡顿。
  2. GPU渲染能力不够,跳屏。
  3. 页面等待、布局时间长,白屏问题。
  4. 大量DOM元素内存占用大。

传统的做法是随着滚动增量渲染,堆积的DOM元素也会越来越多,会出现同样的性能问题。

虚拟列表的核心思想是动态计算按需渲染,是一种根据滚动容器元素的可视区域来渲染长列表数据中部分数据的技术。
在线感受虚拟列表的魅力:virtual-list-demo

虚拟列表可以简单分为以下几类:

  1. 定高:每个DOM元素高度确定
  2. 不定高:每个DOM元素高度不确定
  3. 瀑布流:例如小红书首页,是普通瀑布流的优化,也属于不定高类型。

原生JS定高

定高的原理比较简单,也是其它虚拟列表的基础,这里使用原生JS实现。

这是预期的DOM结构:

1
2
3
4
5
6
7
8

<div class="virtual-list-container">

<div class="virtual-list">

<div class="virtual-list-item">1</div>
</div>
</div>

virtual-list-container 外层的滚动容器元素,由它确定可视区域
virtual-list 实际列表容器,撑起滚动高度。
virtual-list-item 动态渲染的虚拟列表项。

撑起滚动高度

由于是动态渲染,滚动高度不能再由列表项元素撑起,为了维持正常的滚动条,需要如下技巧。

在滚动过程中,对 virtual-list 设置 transform: translateY() 撑起卷去高度(滚动的偏移量),模拟滚动效果,再设置 height 为初始列表高度减去滚动的偏移量。

基本数据结构

基本的数据结构:封装 virtualList 类方便调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class virtualList {
constructor(el, itemHeight) {
this.state = {
data: [],
itemHeight: itemHeight || 100,
viewHeight: 0,
renderCount: 0,
};
this.startIndex = 0;
this.endIndex = 0;
this.renderList = [];
this.scrollStyle = {
height: "0px",
transform: "translateY(0px)",
};
this.el = document.querySelector(el);
this.init();
}
}

state 是一些基本数据,包括列表数据、每一项高度、可视区域高度、渲染项数。
根据滚动状态计算 startIndexendIndex,由这两者确定 renderList 实际渲染的列表数据,以及 scrollStyle 虚拟滚动样式。

挂载

mount() 创建虚拟列表预期的DOM结构,并挂载到指定元素上。

1
2
3
4
5
6
7
8
9
10
11
12
13
mount() {

this.oContainer = document.createElement("div");
this.oContainer.className = "virtual-list-container";

this.oList = document.createElement("div");
this.oList.className = "virtual-list";

this.oContainer.appendChild(this.oList);

this.el.innerHTML = "";
this.el.appendChild(this.oContainer);
}

初始化

init() 进行必要的初始化,进行挂载、计算基本数据、绑定事件(主要是滚动事件),当然还需要进行一次初始渲染。

1
2
3
4
5
6
7
8
9
10
init() {
this.mount();

this.state.viewHeight = this.oContainer.offsetHeight;

this.state.renderCount =
Math.ceil(this.state.viewHeight / this.state.itemHeight) + 1;
this.render();
this.bindEvent();
}

offsetHeight 只读属性,它返回该元素的像素高度,高度包含该元素的垂直内边距、边框和滚动条,且是一个整数。使用它作为可视区域的高度正合适。

计算 renderCount 时需要至少多渲染一项,避免滚动时出现空白。

渲染数据

render() 进行一些必要的计算后,渲染出列表子项,并设置虚拟滚动样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
render() {

this.computeEndIndex();
this.computeRenderList();
this.computeScrollStyle();

const items = this.renderList.map((item) => {
return `<div class="virtual-list-item" style="height: ${this.state.itemHeight}px;">${item}</div>`;
});
const template = items.join("");
this.oList.innerHTML = template;

this.oList.style.height = this.scrollStyle.height;
this.oList.style.transform = this.scrollStyle.transform;
}

一些计算

每次渲染前需要计算必要的数据,包括末索引、渲染列表、滚动样式。

计算结束渲染的索引
1
2
3
4
5
6
7
computeEndIndex() {
this.endIndex = this.startIndex + this.state.renderCount - 1;

if (this.endIndex > this.state.data.length - 1) {
this.endIndex = this.state.data.length - 1;
}
}
计算渲染的列表
1
2
3
4
computeRenderList() {

this.renderList = this.state.data.slice(this.startIndex, this.endIndex + 1);
}
计算虚拟滚动样式
1
2
3
4
5
6
7
8
9
10
11
computeScrollStyle() {

const scrollTop = this.startIndex * this.state.itemHeight;

this.scrollStyle = {

height: `${this.state.data.length * this.state.itemHeight - scrollTop}px`,

transform: `translateY(${scrollTop}px)`,
};
}

绑定事件

绑定滚动事件,注意要将滚动回调的this绑定到当前类实例。

1
2
3
4
5
bindEvent() {

const handle = this.rafThrottle(this.handleScroll.bind(this));
this.oContainer.addEventListener("scroll", handle);
}

滚动回调:
在滚动过程中计算起始索引,即将 scrollTop (卷去高度)除以每项高度,并向下取整。还需要调用渲染函数,不断渲染最新DOM元素。

1
2
3
4
5
6
7
8
handleScroll() {

this.startIndex = Math.floor(
this.oContainer.scrollTop / this.state.itemHeight
);

this.render();
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>原生JS固高虚拟列表</title>
<style>
.container {
width: 600px;
height: 500px;
border: 1px solid #333;
margin: 150px auto;
}

.virtual-list-container {
width: 100%;
height: 100%;
overflow: auto;
}

.virtual-list {
width: 100%;
height: 100%;
}

.virtual-list-item {
width: 100%;

display: flex;
justify-content: center;
align-items: center;
border: 1px solid #333;
box-sizing: border-box;
text-align: center;
font-size: 20px;
font-weight: bold;
}
</style>
</head>

<body>
<div class="container"></div>
<script src="index.js"></script>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140








class virtualList {
constructor(el, itemHeight) {
this.state = {
data: [],
itemHeight: itemHeight || 100,
viewHeight: 0,
renderCount: 0,
};
this.startIndex = 0;
this.endIndex = 0;
this.renderList = [];
this.scrollStyle = {
height: "0px",
transform: "translateY(0px)",
};
this.el = document.querySelector(el);
this.init();
}

init() {
this.mount();

this.state.viewHeight = this.oContainer.offsetHeight;

this.state.renderCount =
Math.ceil(this.state.viewHeight / this.state.itemHeight) + 1;
this.render();
this.bindEvent();
}

mount() {

this.oContainer = document.createElement("div");
this.oContainer.className = "virtual-list-container";

this.oList = document.createElement("div");
this.oList.className = "virtual-list";

this.oContainer.appendChild(this.oList);

this.el.innerHTML = "";
this.el.appendChild(this.oContainer);
}

computeEndIndex() {
this.endIndex = this.startIndex + this.state.renderCount - 1;

if (this.endIndex > this.state.data.length - 1) {
this.endIndex = this.state.data.length - 1;
}
}

computeRenderList() {

this.renderList = this.state.data.slice(this.startIndex, this.endIndex + 1);
}

computeScrollStyle() {

const scrollTop = this.startIndex * this.state.itemHeight;

this.scrollStyle = {

height: `${this.state.data.length * this.state.itemHeight - scrollTop}px`,

transform: `translateY(${scrollTop}px)`,
};
}

render() {

this.computeEndIndex();
this.computeRenderList();
this.computeScrollStyle();

const items = this.renderList.map((item) => {
return `<div class="virtual-list-item" style="height: ${this.state.itemHeight}px;">${item}</div>`;
});
const template = items.join("");
this.oList.innerHTML = template;

this.oList.style.height = this.scrollStyle.height;
this.oList.style.transform = this.scrollStyle.transform;
}

throttle(fn, delay = 50) {
let lastTime = 0;
return function () {
const now = Date.now();
if (now - lastTime > delay) {
fn.apply(this, arguments);
lastTime = now;
}
};
}


rafThrottle(fn) {
let ticking = false;
return function () {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(() => {
fn.apply(this, arguments);
ticking = false;
});
};
}

handleScroll() {

this.startIndex = Math.floor(
this.oContainer.scrollTop / this.state.itemHeight
);

this.render();
}

bindEvent() {


const handle = this.rafThrottle(this.handleScroll.bind(this));
this.oContainer.addEventListener("scroll", handle);
}

setData(data) {
this.state.data = data;
this.render();
}
}
const list = new virtualList(".container", 50);
list.setData(new Array(1000).fill(0).map((item, index) => index + 1));

Vue3定高

原理相同,不需要自己操作DOM更加方便。增加了触底增量等功能。在线效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
<template>
<div class="virtual-list-panel" v-loading="props.loading">

<div class="virtual-list-container" ref="container">

<div class="virtual-list" :style="listStyle" ref="list">

<div
class="virtual-list-item"
:style="{
height: props.itemHeight + 'px',
}"
v-for="(i, index) in renderList"
:key="startIndex + index"
>
<slot name="item" :item="i" :index="startIndex + index"></slot>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts" generic="T">
import { CSSProperties } from "vue";

const props = defineProps<{
loading: boolean;
itemHeight: number;
dataSource: T[];
}>();
const emit = defineEmits<{
addData: [];
}>();

defineSlots<{

item(props: { item: T; index: number }): any;
}>();


const container = ref<HTMLDivElement | null>(null);
const list = ref<HTMLDivElement | null>(null);


const state = reactive({
viewHeight: 0,
renderCount: 0,
});

const startIndex = ref(0);

const endIndex = computed(() => {

const end = startIndex.value + state.renderCount;

if (end > props.dataSource.length) {
return props.dataSource.length;
}
return end;
});

const renderList = computed(() => {

return props.dataSource.slice(startIndex.value, endIndex.value);
});

const listStyle = computed(() => {

const scrollTop = startIndex.value * props.itemHeight;

const listHeight = props.dataSource.length * props.itemHeight;

return {

height: `${listHeight - scrollTop}px`,

transform: `translate3d(0, ${scrollTop}px, 0)`,
} as CSSProperties;
});


const createHandleScroll = () => {
let lastScrollTop = 0;
return () => {
if (!container.value) return;

startIndex.value = Math.floor(container.value.scrollTop / props.itemHeight);
const { scrollTop, clientHeight, scrollHeight } = container.value;

const bottom = scrollHeight - clientHeight - scrollTop;

const isScrollingDown = scrollTop > lastScrollTop;

lastScrollTop = scrollTop;
if (bottom < 20 && isScrollingDown) {
!props.loading && emit("addData");
}
};
};
const handleScroll = rafThrottle(createHandleScroll());

const handleResize = rafThrottle(() => {
if (!container.value) return;

state.viewHeight = container.value.offsetHeight ?? 0;

state.renderCount = Math.ceil(state.viewHeight / props.itemHeight) + 1;

startIndex.value = Math.floor(container.value.scrollTop / props.itemHeight);
});


const init = () => {

state.viewHeight = container.value?.offsetHeight ?? 0;

state.renderCount = Math.ceil(state.viewHeight / props.itemHeight) + 1;

container.value?.addEventListener("scroll", handleScroll);

window.addEventListener("resize", handleResize);
};


const destroy = () => {
container.value?.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};

onMounted(() => {
init();
});

onUnmounted(() => {
destroy();
});
</script>

<style lang="scss">
.virtual-list-panel {
width: 100%;
height: 100%;
.virtual-list-container {
overflow: auto;
width: 100%;
height: 100%;
.virtual-list {
width: 100%;
height: 100%;
.virtual-list-item {
width: 100%;

height: 50px;
border: 1px solid #333;
box-sizing: border-box;
}
}
}
}
</style>

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<template>
<div class="list-container">
<VirtualList
:loading="loading"
:data-source="data"
:item-height="60"
@add-data="addData"
>
<template #item="{ item, index }">
<div>{{ index + 1 }} - {{ item.content }}</div>
</template>
</VirtualList>
</div>
</template>

<script setup lang="ts">
import Mock from "mockjs";
const data = ref<
{
content: string;
}[]
>([]);
const loading = ref(false);
const addData = () => {
loading.value = true;
setTimeout(() => {
data.value = data.value.concat(
new Array(5000).fill(0).map((_, index) => ({
content: Mock.mock("@csentence(100)"),
}))
);
loading.value = false;
}, 1000);
};
onMounted(() => {
addData();
});
</script>

<style scoped lang="scss">
.list-container {
max-width: 600px;
width: 100%;
height: calc(100vh - 100px);
border: 1px solid #333;
}
</style>

Vue3不定高

不定高即每个列表项高度不确定,核心原理和定高一样,找到 startIndexendIndex 确定实际渲染列表、虚拟滚动样式,再由 transform 模拟滚动。在线效果

但不定高,确定 startIndex 以及计算位置信息就需要额外设计。

数据结构

通常做法是由外部传入一个适中的平均高度,作为每项的初始高度,并确定一个固定的渲染数量。

组件 props:

1
2
3
4
5
6
interface EstimatedListProps<T> {
loading: boolean;
estimatedHeight: number;
dataSource: T[];
}
const props = defineProps<EstimatedListProps<T>>();

为了方便计算和使用位置信息,使用一个数组,对应记录 dataSource 中每一项的顶部位置、底部位置、高度、高度差。

1
2
3
4
5
6
7
interface PosInfo {
top: number;
bottom: number;
height: number;
dHeight: number;
}
const positions = ref<PosInfo[]>([]);

列表的状态:

1
2
3
4
5
6
7
const state = reactive({
viewHeight: 0,
listHeight: 0,
startIndex: 0,
renderCount: 0,
preLen: 0,
});

结束索引 endIndex 是一个计算属性:

1
2
3
const endIndex = computed(() =>
Math.min(props.dataSource.length, state.startIndex + state.renderCount)
);

渲染列表同样由 startIndex 和 endIndex 确定。

1
2
3
const renderList = computed(() =>
props.dataSource.slice(state.startIndex, endIndex.value)
);

计算动态样式,使用 transform 模拟滚动,使用 translate3d 可以调用 GPU 辅助计算,性能更好。

1
2
3
4
5
6
7
8
const listStyle = computed(() => {

const preHeight = positions.value[state.startIndex]?.top;
return {
height: `${state.listHeight - preHeight}px`,
transform: `translate3d(0, ${preHeight}px, 0)`,
} as CSSProperties;
});

挂载初始化

在组件挂载后调用 init()

1
2
3
onMounted(() => {
init();
});

初始化获取可视区域高度、计算渲染数量、绑定事件。

1
2
3
4
5
6
7
const init = () => {
state.viewHeight = contentRef.value?.offsetHeight ?? 0;

state.renderCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1;
contentRef.value?.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleResize);
};

滚动事件

滚动事件的核心是调用 findStartingIndex() 找到起始索引,在后续根据起始索引计算位置信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

const createHandleScroll = () => {
let lastScrollTop = 0;
return () => {
if (!contentRef.value) return;
const { scrollTop, clientHeight, scrollHeight } = contentRef.value;

state.startIndex = findStartingIndex(scrollTop);

const bottom = scrollHeight - clientHeight - scrollTop;

const isScrollingDown = scrollTop > lastScrollTop;

lastScrollTop = scrollTop;
if (bottom < 20 && isScrollingDown) {

!props.loading && emit("addData");

}
};
};
const handleScroll = rafThrottle(createHandleScroll());

查找起始索引

使用二分查找,找到第一个 bottom 大于或等于 scrollTop 的 item。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const findStartingIndex = (scrollTop: number) => {

let left = 0;
let right = positions.value.length - 1;
let mid = -1;
while (left < right) {
const midIndx = Math.floor((left + right) / 2);
const midValue = positions.value[midIndx].bottom;
if (midValue === scrollTop) {
return midIndx;
} else if (midValue < scrollTop) {
left = midIndx + 1;
} else {
right = midIndx;



if (mid === -1 || mid > midIndx) {
mid = midIndx;
}
}
}
return mid;
};

计算位置信息

不定高虚拟列表的核心就是计算每一项的位置信息,再根据这些信息去渲染。

使用 watch 监听数据源的变化、Dom变化,计算位置信息。先初始化位置信息,再在下一次渲染时更新实际位置信息。

1
2
3
4
5
6
7

watch([() => listRef.value, () => props.dataSource], () => {
props.dataSource.length && initPositions();
nextTick(() => {
updatePositions();
});
});

当 startIndex 变化时,也需要更新位置信息。

1
2
3
4
5
6
7
8
9

watch(
() => state.startIndex,
() => {
nextTick(() => {
updatePositions();
});
}
);

初始化位置信息

位置信息需要与数据源一一对应,初始的高度就是预设高度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const initPositions = () => {
const pos: PosInfo[] = [];
const disLen = props.dataSource.length - state.preLen;

const preTop = positions.value[state.preLen - 1]?.bottom ?? 0;
const preBottom = positions.value[state.preLen - 1]?.bottom ?? 0;
for (let i = 0; i < disLen; i++) {
pos.push({
height: props.estimatedHeight,
top: preTop + i * props.estimatedHeight,
bottom: preBottom + (i + 1) * props.estimatedHeight,
dHeight: 0,
});
}

positions.value = [...positions.value, ...pos];
state.preLen = props.dataSource.length;
};

更新位置信息

在实际DOM渲染完成后,获取实际位置信息,并更新 positions。

这里是不定高虚拟列表计算量最大的地方:

  1. 获取DOM上已渲染的item,累加一个高度差偏移量,根据实际DOM更新对应的位置信息。
  2. 更新后续所有未渲染的item的位置信息、以及列表总高度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const updatePositions = () => {

const itemNodes = listRef.value?.children;
if (!itemNodes || !itemNodes.length) return;

let dHeightAccount = 0;

for (let i = 0; i < itemNodes.length; i++) {
const node = itemNodes[i];

const rect = node.getBoundingClientRect();
const id = state.startIndex + i;

const itemPos = positions.value[id];

const dHeight = rect.height - itemPos.height;

dHeightAccount += dHeight;
if (dHeight) {

itemPos.height = rect.height;
itemPos.dHeight = dHeight;
itemPos.bottom = itemPos.bottom + dHeightAccount;
}

if (i !== 0) {

itemPos.top = positions.value[id - 1].bottom;
}
}

const endID = endIndex.value;
for (let i = endID; i < positions.value.length; i++) {
const itemPos = positions.value[i];

itemPos.top = positions.value[i - 1].bottom;

itemPos.bottom = itemPos.bottom + dHeightAccount;
if (itemPos.dHeight) {

dHeightAccount += itemPos.dHeight;
itemPos.dHeight = 0;
}
}


state.listHeight = positions.value[positions.value.length - 1].bottom;
};

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
<template>

<div class="virtual-list-container" v-loading="props.loading">

<div class="virtual-list-content" ref="contentRef">

<div class="virtual-list" ref="listRef" :style="listStyle">
<div
class="virtual-list-item"
v-for="(i, index) in renderList"
:id="String(state.startIndex + index)"
:key="state.startIndex + index"
>
<slot name="item" :item="i" :index="state.startIndex + index"></slot>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts" generic="T">
import { CSSProperties } from "vue";

interface EstimatedListProps<T> {
loading: boolean;
estimatedHeight: number;
dataSource: T[];
}

interface PosInfo {
top: number;
bottom: number;
height: number;
dHeight: number;
}

const props = defineProps<EstimatedListProps<T>>();
const emit = defineEmits<{
addData: [];
}>();

defineSlots<{

item(props: { item: T; index: number }): any;
}>();


const state = reactive({
viewHeight: 0,
listHeight: 0,
startIndex: 0,
renderCount: 0,
preLen: 0,
});

const endIndex = computed(() =>
Math.min(props.dataSource.length, state.startIndex + state.renderCount)
);

const renderList = computed(() =>
props.dataSource.slice(state.startIndex, endIndex.value)
);

const positions = ref<PosInfo[]>([]);

const listStyle = computed(() => {

const preHeight = positions.value[state.startIndex]?.top;
return {
height: `${state.listHeight - preHeight}px`,
transform: `translate3d(0, ${preHeight}px, 0)`,
} as CSSProperties;
});

const contentRef = ref<HTMLDivElement>();
const listRef = ref<HTMLDivElement>();


const initPositions = () => {
const pos: PosInfo[] = [];
const disLen = props.dataSource.length - state.preLen;

const preTop = positions.value[state.preLen - 1]?.bottom ?? 0;
const preBottom = positions.value[state.preLen - 1]?.bottom ?? 0;
for (let i = 0; i < disLen; i++) {
pos.push({
height: props.estimatedHeight,
top: preTop + i * props.estimatedHeight,
bottom: preBottom + (i + 1) * props.estimatedHeight,
dHeight: 0,
});
}

positions.value = [...positions.value, ...pos];
state.preLen = props.dataSource.length;
};


const updatePositions = () => {

const itemNodes = listRef.value?.children;
if (!itemNodes || !itemNodes.length) return;

let dHeightAccount = 0;

for (let i = 0; i < itemNodes.length; i++) {
const node = itemNodes[i];

const rect = node.getBoundingClientRect();
const id = state.startIndex + i;

const itemPos = positions.value[id];

const dHeight = rect.height - itemPos.height;

dHeightAccount += dHeight;
if (dHeight) {

itemPos.height = rect.height;
itemPos.dHeight = dHeight;
itemPos.bottom = itemPos.bottom + dHeightAccount;
}

if (i !== 0) {

itemPos.top = positions.value[id - 1].bottom;
}
}

const endID = endIndex.value;
for (let i = endID; i < positions.value.length; i++) {
const itemPos = positions.value[i];

itemPos.top = positions.value[i - 1].bottom;

itemPos.bottom = itemPos.bottom + dHeightAccount;
if (itemPos.dHeight) {

dHeightAccount += itemPos.dHeight;
itemPos.dHeight = 0;
}
}


state.listHeight = positions.value[positions.value.length - 1].bottom;
};


const createHandleScroll = () => {
let lastScrollTop = 0;
return () => {
if (!contentRef.value) return;
const { scrollTop, clientHeight, scrollHeight } = contentRef.value;

state.startIndex = findStartingIndex(scrollTop);

const bottom = scrollHeight - clientHeight - scrollTop;

const isScrollingDown = scrollTop > lastScrollTop;

lastScrollTop = scrollTop;
if (bottom < 20 && isScrollingDown) {

!props.loading && emit("addData");

}
};
};
const handleScroll = rafThrottle(createHandleScroll());


const findStartingIndex = (scrollTop: number) => {

let left = 0;
let right = positions.value.length - 1;
let mid = -1;
while (left < right) {
const midIndx = Math.floor((left + right) / 2);
const midValue = positions.value[midIndx].bottom;
if (midValue === scrollTop) {
return midIndx;
} else if (midValue < scrollTop) {
left = midIndx + 1;
} else {
right = midIndx;



if (mid === -1 || mid > midIndx) {
mid = midIndx;
}
}
}
return mid;
};

const handleResize = rafThrottle(() => {
if (!contentRef.value) return;
state.viewHeight = contentRef.value.offsetHeight ?? 0;
state.renderCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1;
state.startIndex = findStartingIndex(contentRef.value.scrollTop);
});


const init = () => {
state.viewHeight = contentRef.value?.offsetHeight ?? 0;

state.renderCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1;
contentRef.value?.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleResize);
};


const destroy = () => {
contentRef.value?.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};


watch([() => listRef.value, () => props.dataSource], () => {
props.dataSource.length && initPositions();
nextTick(() => {
updatePositions();
});
});


watch(
() => state.startIndex,
() => {
nextTick(() => {
updatePositions();
});
}
);

onMounted(() => {
init();
});

onUnmounted(() => {
destroy();
});
</script>

<style lang="scss">
div.virtual-list-container {
width: 100%;
height: 100%;
div.virtual-list-content {
width: 100%;
height: 100%;
overflow: auto;
div.virtual-list {
div.virtual-list-item {
width: 100%;
box-sizing: border-box;
border: 1px solid #333;
}
}
}
}
</style>

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<template>
<div class="list-container">
<EstimatedVirtualList
:data-source="data"
:loading="loading"
:estimated-height="40"
@addData="addData"
:height="500"
:width="600"
>
<template #item="{ item, index }">
<div>{{ index + 1 }} - {{ item.content }}</div>
</template>
</EstimatedVirtualList>
</div>
</template>

<script setup lang="ts">
import Mock from "mockjs";
const data = ref<
{
content: string;
}[]
>([]);
const loading = ref(false);
const addData = () => {
loading.value = true;
setTimeout(() => {
data.value = data.value.concat(
new Array(2000).fill(0).map((_, index) => ({
content: Mock.mock("@csentence(40, 100)"),
}))
);
loading.value = false;
}, 1000);
};
onMounted(() => {
addData();
});
</script>

<style scoped lang="scss">
.list-container {
max-width: 600px;
width: 100%;
height: calc(100vh - 100px);
border: 1px solid #333;
}
</style>

瀑布流

在实现虚拟瀑布流之前,需要先学习下普通的瀑布流。在线效果

通常通过绝对定位实现瀑布流,动态计算布局,且元素通常带有图片。

对于图片的处理,常见的优化是由后端预先传图片的宽高,这样能减少计算布局的次数。
不过在普通瀑布流这,我还是采用了前端计算,即在图片 load 完后再次计算布局,实际上性能还可以。在之后的虚拟瀑布流实现,就允许传入宽高信息,减少计算量。

布局计算:每次找到最小高度列,添加元素。

DOM结构

使用插槽,允许自定义每项的DOM结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<div class="water-fall-panel" v-loading="props.loading">
<div class="water-fall-container" ref="containerRef" @scroll="handleScroll">
<div
class="water-fall-content"
ref="contentRef"
:style="{
height: state.maxHeight + 'px',
}"
>
<div
class="water-fall-item"
v-for="(i, index) in props.data"
:style="{
width: state.columnWidth + 'px',
}"
:key="index"
>
<slot name="item" :item="i" :index="index" :load="imgLoadHandle">
<img :src="i.src" @load="imgLoadHandle" />
</slot>
</div>
</div>
</div>
</div>
</template>

数据结构

每项数据定义:需要一个图片地址,当然也可以加入其它东西,毕竟使用了插槽,DOM结构是允许自定义的。

1
2
3
4
interface imgData {
src: string;
[key: string]: any;
}

组件 props:
传入列数、每项之间的间距、以及数据源。

1
2
3
4
5
6
const props = defineProps<{
loading: boolean;
column: number;
space: number;
data: imgData[];
}>();

基本状态:
主要是列宽和最高列高,三种数据长度只是辅助计算需要。

1
2
3
4
5
6
7
8
9
10
11
12
13
const state = reactive<{
columnWidth: number;
maxHeight: number;
firstLength: number;
lastLength: number;
loadedLength: number;
}>({
columnWidth: 0,
maxHeight: 0,
firstLength: 0,
lastLength: 0,
loadedLength: 0,
});

挂载初始化

计算一次布局,绑定事件,滚动事件已经通过模板语法 @scroll 绑定。

1
2
3
4
5
6
7
8
const init = () => {
computedLayout();
window.addEventListener("resize", resizeHandler);
};

onMounted(() => {
init();
});

计算布局

计算布局分为两部:先计算列宽,再计算每项位置信息。

1
2
3
4
const computedLayout = rafThrottle(() => {
computedColumWidth();
setPositions();
});

列宽通过容器宽度除以列数即可,当然还要考虑间距。

1
2
3
4
5
6
7
8

const computedColumWidth = () => {

const containerWidth = contentRef.value?.clientWidth || 0;

state.columnWidth =
(containerWidth - (props.column - 1) * props.space) / props.column;
};

计算位置信息

初始化每列高度为0,遍历所有图片元素,每次找到最小高度列添加元素。

代码中有一大段是为了实现动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const setPositions = () => {

const columnHeight = new Array(props.column).fill(0);

const imgItems = contentRef.value?.children;
if (!imgItems || imgItems.length === 0) return;
if (state.firstLength === 0) {
state.firstLength = imgItems.length;
}

for (let i = 0; i < imgItems.length; i++) {
const img = imgItems[i] as HTMLDivElement;

const minHeight = Math.min.apply(null, columnHeight);

const minHeightIndex = columnHeight.indexOf(minHeight);



img.style.setProperty(
"--img-tr-x",
`${minHeightIndex * (state.columnWidth + props.space)}px`
);
img.style.transform = `translate3d(var(--img-tr-x), var(--img-tr-y), 0)`;
if (!img.classList.contains("animation-over")) {
img.classList.add("animation-over");
img.style.transition = "none";
if (i >= state.firstLength) {
img.style.setProperty("--img-tr-y", `${minHeight + 60}px`);
} else {
img.style.setProperty("--img-tr-y", `${minHeight}px`);
}
img.offsetHeight;
img.style.transition = "all 0.3s";
img.style.setProperty("--img-tr-y", `${minHeight}px`);
} else {
img.style.setProperty("--img-tr-y", `${minHeight}px`);
}

columnHeight[minHeightIndex] += img.offsetHeight + props.space;
}

state.maxHeight = Math.max.apply(null, columnHeight);
};

每当有图片加载完,也要重新计算布局。

1
2
3
4
const imgLoadHandle = () => {
state.loadedLength++;
computedLayout();
};

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
<template>
<div class="water-fall-panel" v-loading="props.loading">
<div class="water-fall-container" ref="containerRef" @scroll="handleScroll">
<div
class="water-fall-content"
ref="contentRef"
:style="{
height: state.maxHeight + 'px',
}"
>
<div
class="water-fall-item"
v-for="(i, index) in props.data"
:style="{
width: state.columnWidth + 'px',
}"
:key="index"
>
<slot name="item" :item="i" :index="index" :load="imgLoadHandle">
<img :src="i.src" @load="imgLoadHandle" />
</slot>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
interface imgData {
src: string; // 图片地址
[key: string]: any;
}
const props = defineProps<{
loading: boolean; // 加载状态
column: number; // 列数
space: number; // 间距
data: imgData[]; // 数据
}>(); // 定义props
const emit = defineEmits<{
addData: [];
}>(); // 定义emit
// 定义插槽
defineSlots<{
// 插槽本质就是个函数,接收一个参数props,props是一个对象,包含了插槽的所有属性
item(props: {
item: imgData;
index: number;
load: typeof computedLayout;
}): any;
}>();

// 状态
const state = reactive<{
columnWidth: number; // 列宽
maxHeight: number; // 最高列高
firstLength: number; // 第一次加载的数据长度
lastLength: number; // 最后一次加载的数据长度
loadedLength: number; // 已加载的数据长度
}>({
columnWidth: 0,
maxHeight: 0,
firstLength: 0,
lastLength: 0,
loadedLength: 0,
});

// 获取dom元素
const contentRef = ref<HTMLDivElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);

// 计算列宽
const computedColumWidth = () => {
// 获取容器宽度
const containerWidth = contentRef.value?.clientWidth || 0;
// 计算列宽
state.columnWidth =
(containerWidth - (props.column - 1) * props.space) / props.column;
};

// 设置每个图片的位置
const setPositions = () => {
// 每列的高度初始化为0
const columnHeight = new Array(props.column).fill(0);
// 获取所有图片元素
const imgItems = contentRef.value?.children;
if (!imgItems || imgItems.length === 0) return;
if (state.firstLength === 0) {
state.firstLength = imgItems.length;
}
// 遍历图片元素
for (let i = 0; i < imgItems.length; i++) {
const img = imgItems[i] as HTMLDivElement;
// 获取最小高度的列
const minHeight = Math.min.apply(null, columnHeight);
// 获取最小高度的列索引
const minHeightIndex = columnHeight.indexOf(minHeight);
// 设置图片位置
// img.style.top = minHeight + "px";
// img.style.left = minHeightIndex * (state.columnWidth + props.space) + "px";
img.style.setProperty(
"--img-tr-x",
`${minHeightIndex * (state.columnWidth + props.space)}px`
);
img.style.transform = `translate3d(var(--img-tr-x), var(--img-tr-y), 0)`;
if (!img.classList.contains("animation-over")) {
img.classList.add("animation-over");
img.style.transition = "none";
if (i >= state.firstLength) {
img.style.setProperty("--img-tr-y", `${minHeight + 60}px`);
} else {
img.style.setProperty("--img-tr-y", `${minHeight}px`);
}
img.offsetHeight; // 强制渲染
img.style.transition = "all 0.3s";
img.style.setProperty("--img-tr-y", `${minHeight}px`);
} else {
img.style.setProperty("--img-tr-y", `${minHeight}px`);
}
// 更新列高
columnHeight[minHeightIndex] += img.offsetHeight + props.space;
}
// 更新最高列高
state.maxHeight = Math.max.apply(null, columnHeight);
};

const imgLoadHandle = () => {
state.loadedLength++;
computedLayout();
};

// 计算布局
const computedLayout = rafThrottle(() => {
computedColumWidth();
setPositions();
});

// 尺寸变化后计算布局
const createResizeComputedLayout = () => {
let timer: number;
return () => {
computedColumWidth();
window.requestAnimationFrame(() => {
timer = setTimeout(() => {
setPositions();
}, 300);
});
};
};

const resizeComputedLayout = createResizeComputedLayout();

// 监听列数和间距变化,重新计算布局
watch(
() => [props.column, props.space],
() => {
// console.log("change column or space");
resizeComputedLayout();
}
);

const resizeHandler = debounce(() => {
resizeComputedLayout();
}, 300);

const init = () => {
computedLayout();
window.addEventListener("resize", resizeHandler);
};

onMounted(() => {
init();
});

onUnmounted(() => {
window.removeEventListener("resize", resizeHandler);
});

// 滚动回调
const createHandleScroll = () => {
let lastScrollTop = 0;
return () => {
if (!containerRef.value) return;
const { scrollTop, clientHeight, scrollHeight } = containerRef.value;
const bottom = scrollHeight - clientHeight - scrollTop;
// 判断是否向下滚动
const isScrollingDown = scrollTop > lastScrollTop;
// 记录上次滚动的距离
lastScrollTop = scrollTop;
if (bottom < 20 && isScrollingDown) {
// 只有本次加载的数据加载完毕后才能继续加载
if (state.loadedLength >= props.data.length - state.lastLength) {
// 记录上次加载的数据长度
state.lastLength = props.data.length;
state.loadedLength = 0;
// 加载新数据
!props.loading && emit("addData");
}
containerRef.value.offsetHeight;
}
};
};
const handleScroll = rafThrottle(createHandleScroll());
</script>

<style lang="scss">
.water-fall-panel {
height: 100%;
width: 100%;
.water-fall-container {
height: 100%;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
.water-fall-content {
height: 100%;
width: 100%;
position: relative;
.water-fall-item {
position: absolute;
transition: all 0.3s;
overflow: hidden;
img {
width: 100%;
object-fit: cover;
overflow: hidden;
display: block;
}
}
}
}
}
</style>

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
<template>
<div class="list-container">
<WaterFallList
:data="data"
:loading="loading"
:column="column"
:space="space"
@add-data="addData"
>
<template #item="{ item, index, load }">
<div
:style="{
display: 'flex',
flexDirection: 'column',
}"
>
<img :src="item.src" @load="load" />
<span>{{ item.title }}</span>
</div>
</template>
</WaterFallList>
</div>
</template>

<script setup lang="ts">
import Mock from "mockjs";
const data = ref<
{
src: string;
title: string;
}[]
>([]);
const loading = ref(false);
const column = ref(4);
const space = ref(10);

let size = 40;
let page = 1;
const addData = () => {

simulatedData();
};
const simulatedData = () => {
loading.value = true;
setTimeout(() => {
data.value = data.value.concat(
new Array(size * 2).fill(0).map((_, index) => ({
src: Mock.Random.dataImage(),
title: Mock.mock("@ctitle(5, 15)"),
}))
);
loading.value = false;
}, 1000);
};
const fetchData = () => {
loading.value = true;
fetch(
`https://www.vilipix.com/api/v1/picture/public?limit=${size}&offset=${
(page - 1) * size
}&sort=hot&type=0`
)
.then((res) => res.json())
.then((res) => {
page++;
const list = res.data.rows;
data.value = data.value.concat(
list.map((item: any) => ({
src: item.regular_url,
title: item.title,
}))
);
loading.value = false;
});
};
onMounted(() => {
addData();



});
</script>

<style scoped lang="scss">
.list-container {
max-width: 800px;
width: 100%;
height: calc(100vh - 100px);
border: 1px solid #333;
}
</style>

虚拟瀑布流

虚拟瀑布流将虚拟列表和瀑布流相结合,保证在大量图片、DOM元素的情况下,能够正常渲染。在线效果

DOM结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
<div class="virtual-waterfall-panel" v-loading="props.loading">
<component :is="'style'">{{ animationStyle }}</component>
<div class="virtual-waterfall-container" ref="containerRef">
<div
class="virtual-waterfall-list"
ref="listRef"
:style="{
height: state.minHeight + 'px',
}"
>
<div
class="virtual-waterfall-item"
v-for="i in state.renderList"
:style="i.style"
:data-column="i.column"
:data-renderIndex="i.renderIndex"
:data-loaded="i.data.src ? 0 : 1"
:key="i.index"
>
<div class="animation-box">
<slot
name="item"
:item="i"
:index="i.index"
:load="imgLoadedHandle"
>
<img
:src="i.data.src"
@load="imgLoadedHandle"
v-if="props.compute"
/>
<img :src="i.data.src" v-else />
</slot>
</div>
</div>
</div>
</div>
</div>
</template>

数据结构

虚拟瀑布流的数据结构较为复杂,需要额外维护渲染队列和渲染列表。

数据源:允许传入宽高,以减少计算量。

1
2
3
4
5
6
7

interface ImgData {
src: string;
height?: number;
width?: number;
[key: string]: any;
}

虚拟瀑布流需要多维护一个渲染队列,保存瀑布流中每列的渲染列表、列高度,而渲染列表中保存了渲染项的元数据。

1
2
3
4
5

interface columnQueue {
height: number;
renderList: RenderItem[];
}

每个渲染项元数据包括了其在数据源的索引、所在列、渲染索引、Y轴偏移量、样式等。
其中 offsetY 是关键,它参与计算量该项是否要渲染,以及渲染的高度(Y轴位置)。

1
2
3
4
5
6
7
8
9
10

interface RenderItem {
index: number;
column: number;
renderIndex: number;
data: ImgData;
offsetY: number;
height: number;
style: CSSProperties;
}

组件 props:
允许自定义动画、设置缓冲高度、以及设置 compute 动态计算尺寸。

仍然需要传入 estimatedHeight 预设高度,因为其本质也是不定高的,需要预设高度完成每项的初始计算,当然外部传入宽高将在计算时覆盖预设高度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

interface Props {
loading: boolean;
column: number;
estimatedHeight: number;
gap?: number;
dataSource: ImgData[];
compute?: boolean;
animation?: boolean | string;
bufferHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
gap: 0,
compute: true,
animation: true,
bufferHeight: -1,
});

基本状态:
state.renderList 保存了实际需要渲染的渲染元数据。注意与 queueList[number].renderList 区分。
还需记录最高、最低列高,方便计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

const state = reactive({
columnWidth: 0,
viewHeight: 0,

queueList: Array.from({ length: props.column }).map<columnQueue>(() => ({
height: 0,
renderList: [],
})),
renderList: [] as RenderItem[],
maxHeight: 0,
minHeight: 0,
preLen: 0,
isScrollingDown: true,
});

最后,还需要保存渲染高度范围。

1
2
3
4

const start = ref(0);

const end = computed(() => start.value + state.viewHeight);

初始化

除了熟悉的绑定事件外,调用了两个简单的计算函数。

  1. computedViewHeight() 计算容器视口高度。
  2. computedColumWidth() 计算列宽。
1
2
3
4
5
6
onMounted(() => {
computedViewHeight();
computedColumWidth();
containerRef.value?.addEventListener("scroll", handleScroll);
window.addEventListener("resize", resizeHandler);
});

布局计算使用了 watch 监听。当数据源发生变化后,分别计算渲染队列和渲染列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
watch(
() => props.dataSource,
(a, b) => {
state.preLen = b?.length ?? 0;
if (!a.length) return;
if (isReload) {
isReload = false;
return;
}
computedQueueList();
computedRenderList();
},
{
deep: false,
immediate: true,
}
);

计算渲染队列

遍历数据源,每次找到高度最小的队列添加该渲染项的元数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

const computedQueueList = (total: boolean = false) => {


const startIndex = total ? 0 : state.preLen;

total && initQueueList();

for (let i = startIndex; i < props.dataSource.length; i++) {
const img = props.dataSource[i];

const minColumn = getMinHeightColumn();

let imgHeight = props.estimatedHeight ?? 50;

if (img.height && img.width) {
imgHeight = (state.columnWidth / img.width) * img.height;
}

const offsetY = minColumn.column.height;

minColumn.column.renderList.push({
index: i,
column: minColumn.index,
renderIndex: minColumn.column.renderList.length,
data: img,
offsetY: offsetY,
height: imgHeight,
style: getRenderStyle(minColumn.index, offsetY),
});

minColumn.column.height += imgHeight + props.gap;
}

updateMinMaxHeight();
};


const getMinHeightColumn = () => {
let minColumnIndex = 0;
let minColumn = state.queueList[minColumnIndex];
for (let i = 1; i < state.queueList.length; i++) {
if (state.queueList[i].height < minColumn.height) {
minColumn = state.queueList[i];
minColumnIndex = i;
}
}
return {
index: minColumnIndex,
column: minColumn,
};
};

计算渲染列表

state.renderList 是实际需要渲染的渲染列表。在渲染队列中找到所有 offsetY 在 start、end 范围内的渲染项元数据。

可以使用计算属性实现:

1
2
3
4
5
6
7
8
const renderList = computed(() => {
return state.queueList.reduce<RenderItem[]>((prev, cur) => {
const filteredRenderList = cur.renderList.filter(
(i) => i.height + i.offsetY > start.value && i.offsetY < end.value
);
return prev.concat(filteredRenderList);
}, []);
});

但offsetY是有序的,二分查找性能更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

const binarySearch = (arr: any[], target: number) => {
let left = 0;
let right = arr.length - 1;

while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid].offsetY === target) {
return mid;
} else if (arr[mid].offsetY < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}

return left;
};


const computedRenderList = rafThrottle(() => {

const nextRenderList: RenderItem[] = [];
const pre = props.bufferHeight >= 0 ? props.bufferHeight : state.viewHeight / 2;
const top = start.value - pre;
const bottom = end.value + pre;

updateMinMaxHeight();
for (let i = 0; i < state.queueList.length; i++) {
const renderList = state.queueList[i].renderList;
const startIndex = binarySearch(renderList, top);
const endIndex = binarySearch(renderList, bottom);

for (let j = startIndex - 1; j < endIndex + 1; j++) {
const item = renderList[j];
if (item && item.offsetY < state.minHeight) {
nextRenderList.push(item);
}
}
}

state.renderList = nextRenderList;
nextTick(() => {
computedLayoutAll();
});
});

计算布局

在计算完渲染队列且渲染完成后,需要根据实际DOM计算布局。

1
2
3
4
5
6

const computedLayoutAll = () => {
for (let i = 0; i < props.column; i++) {
computedLayout(i);
}
};

computedLayout(column) 计算某列或某个元素的布局。
该函数的逻辑较为复杂,因为涉及到大量计算,进行了较多优化。

  1. 先获取 DOM 上为当前列的元素。
  2. 再确定渲染索引范围,firstRenderIndex 和 lastRenderIndex。
  3. 将第一个元素的 offsetY 作为初始偏移量。
  4. 遍历该列所有元素,根据实际 DOM 更新其元数据信息。
  5. 如果是向下滚动,还需要预加载一部分后续元素,以进行优化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74






const computedLayout = (
column: number,
targetRenderIndex: number | number[] | undefined = undefined
) => {

const isArrayTarget = Array.isArray(targetRenderIndex);

let list = [];
for (let i = 0; i < listRef.value!.children.length; i++) {
let child = listRef.value!.children[i] as HTMLDivElement;
if (child.matches(`[data-column='${column}']`)) {
list.push(child);
}
}
if (!list.length) return;

const queue = state.queueList[column];

const firstRenderIndex = parseInt(
list[0].getAttribute("data-renderIndex") || "0"
);
const lastRenderIndex = firstRenderIndex + list.length - 1;

let offsetYAccount = queue.renderList[firstRenderIndex].offsetY;


for (let i = 0; i < list.length; i++) {
const item = list[i];
const renderItem =
queue.renderList[parseInt(item.getAttribute("data-renderIndex") || "0")];

if (
!targetRenderIndex ||
renderItem.renderIndex === targetRenderIndex ||
(isArrayTarget && targetRenderIndex.includes(renderItem.renderIndex))
) {
if (item.getAttribute("data-loaded") === "1") {

queue.height += item.offsetHeight - renderItem.height;

renderItem.height = item.offsetHeight;
}
}

renderItem.offsetY = offsetYAccount;

renderItem.style = getRenderStyle(column, offsetYAccount);

offsetYAccount += renderItem.height + props.gap;
}

if (!state.isScrollingDown) return;


const i = list.length * props.column + lastRenderIndex;
const preloadIndex =
i > queue.renderList.length ? queue.renderList.length : i;

for (let i = lastRenderIndex + 1; i < preloadIndex; i++) {
const item = queue.renderList[i];
item.offsetY = offsetYAccount;
item.style = getRenderStyle(column, offsetYAccount);
offsetYAccount += item.height + props.gap;
}



};

图片 load

在图片加载完成后,需要更新该元素的布局,并标记已加载,避免重复触发动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28


const imgLoadedHandle = function (e: Event) {
const target = e.target as HTMLImageElement;
const item = target.closest(".virtual-waterfall-item") as HTMLImageElement;
if (!item) return;

item.setAttribute("data-loaded", "1");
if (!props.compute) return;














computedLayout(
parseInt(item.getAttribute("data-column") || "0"),
parseInt(item.getAttribute("data-renderIndex") || "0")
);
};

滚动回调

在滚动过程中重新计算渲染列表,当向下触底、且当前渲染项都加载完毕时,增量加载新数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const createHandleScroll = () => {
let lastScrollTop = 0;
let flag = true;
const fn = () => {
const { scrollTop, scrollHeight } = containerRef.value!;

start.value = scrollTop;

computedRenderList();

state.isScrollingDown = scrollTop > lastScrollTop;

lastScrollTop = scrollTop;

if (
!props.loading &&
state.isScrollingDown &&
scrollTop + state.viewHeight + 5 > scrollHeight
) {


const allLoaded = isAllLoad();
if (allLoaded) {
isReload && (isReload = false);
emit("addData");
}
}
flag = true;
};
const createHandle = (handle: Function) => {
return () => {
if (!flag) return;
flag = false;
handle();
};
};
if ("requestIdleCallback" in window) {
return createHandle(() => {
window.requestIdleCallback(fn);
});
} else if ("requestAnimationFrame" in window) {
return createHandle(() => {
window.requestAnimationFrame(fn);
});
}
return createHandle(fn);
};
const handleScrollFun = createHandleScroll();
const throttleHandleScroll = throttle(handleScrollFun, 250);
const debounceHandleScroll = debounce(handleScrollFun, 50);
const handleScroll = () => {
debounceHandleScroll();
throttleHandleScroll();
};


const isAllLoad = () => {
for (let i = 0; i < listRef.value!.children.length; i++) {
const child = listRef.value!.children[i] as HTMLDivElement;
if (child.matches("[data-loaded='0']")) {
return false;
}
}
return true;
};

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
<template>
<div class="virtual-waterfall-panel" v-loading="props.loading">
<component :is="'style'">{{ animationStyle }}</component>
<div class="virtual-waterfall-container" ref="containerRef">
<div
class="virtual-waterfall-list"
ref="listRef"
:style="{
height: state.minHeight + 'px',
}"
>
<div
class="virtual-waterfall-item"
v-for="i in state.renderList"
:style="i.style"
:data-column="i.column"
:data-renderIndex="i.renderIndex"
:data-loaded="i.data.src ? 0 : 1"
:key="i.index"
>
<div class="animation-box">
<slot
name="item"
:item="i"
:index="i.index"
:load="imgLoadedHandle"
>
<img
:src="i.data.src"
@load="imgLoadedHandle"
v-if="props.compute"
/>
<img :src="i.data.src" v-else />
</slot>
</div>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { CSSProperties, withDefaults } from "vue";


interface ImgData {
src: string;
height?: number;
width?: number;
[key: string]: any;
}

interface RenderItem {
index: number;
column: number;
renderIndex: number;
data: ImgData;
offsetY: number;
height: number;
style: CSSProperties;
}

interface columnQueue {
height: number;
renderList: RenderItem[];
}


interface Props {
loading: boolean;
column: number;
estimatedHeight: number;
gap?: number;
dataSource: ImgData[];
compute?: boolean;
animation?: boolean | string;
bufferHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
gap: 0,
compute: true,
animation: true,
bufferHeight: -1,
});

const emit = defineEmits<{
addData: [];
}>();


const animationStyle = computed(() => {

let animation = "WaterFallItemAnimate 0.25s";

if (props.animation === false) {
animation = "none";
}

if (typeof props.animation === "string") {
animation = props.animation;
}
return `
.virtual-waterfall-list>.virtual-waterfall-item[data-loaded="1"]>.animation-box {
animation: ${animation};
}
`;
});


const state = reactive({
columnWidth: 0,
viewHeight: 0,

queueList: Array.from({ length: props.column }).map<columnQueue>(() => ({
height: 0,
renderList: [],
})),
renderList: [] as RenderItem[],
maxHeight: 0,
minHeight: 0,
preLen: 0,
isScrollingDown: true,
});

const start = ref(0);

const end = computed(() => start.value + state.viewHeight);












const binarySearch = (arr: any[], target: number) => {
let left = 0;
let right = arr.length - 1;

while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid].offsetY === target) {
return mid;
} else if (arr[mid].offsetY < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}

return left;
};


const computedRenderList = rafThrottle(() => {

const nextRenderList: RenderItem[] = [];
const pre = props.bufferHeight >= 0 ? props.bufferHeight : state.viewHeight / 2;
const top = start.value - pre;
const bottom = end.value + pre;

updateMinMaxHeight();
for (let i = 0; i < state.queueList.length; i++) {
const renderList = state.queueList[i].renderList;
const startIndex = binarySearch(renderList, top);
const endIndex = binarySearch(renderList, bottom);

for (let j = startIndex - 1; j < endIndex + 1; j++) {
const item = renderList[j];
if (item && item.offsetY < state.minHeight) {
nextRenderList.push(item);
}
}
}

state.renderList = nextRenderList;
nextTick(() => {
computedLayoutAll();
});
});


const updateMinMaxHeight = () => {

state.maxHeight = 0;
state.minHeight = state.queueList[0].height;
for (let i = 0; i < state.queueList.length; i++) {
const item = state.queueList[i];
if (item.height > state.maxHeight) {
state.maxHeight = item.height;
}
if (item.height < state.minHeight) {
state.minHeight = item.height;
}
}
};


const getRenderStyle = (column: number, offsetY: number) => {
return {
width: state.columnWidth + "px",
transform: `translate3d(${
column * (state.columnWidth + props.gap)
}px, ${offsetY}px, 0)`,
};
};


const initQueueList = () => {
state.queueList = Array.from({ length: props.column }).map<columnQueue>(
() => ({
height: 0,
renderList: [],
})
);
};


const computedQueueList = (total: boolean = false) => {


const startIndex = total ? 0 : state.preLen;

total && initQueueList();

for (let i = startIndex; i < props.dataSource.length; i++) {
const img = props.dataSource[i];

const minColumn = getMinHeightColumn();

let imgHeight = props.estimatedHeight ?? 50;

if (img.height && img.width) {
imgHeight = (state.columnWidth / img.width) * img.height;
}

const offsetY = minColumn.column.height;

minColumn.column.renderList.push({
index: i,
column: minColumn.index,
renderIndex: minColumn.column.renderList.length,
data: img,
offsetY: offsetY,
height: imgHeight,
style: getRenderStyle(minColumn.index, offsetY),
});

minColumn.column.height += imgHeight + props.gap;
}

updateMinMaxHeight();
};


const isAllLoad = () => {
for (let i = 0; i < listRef.value!.children.length; i++) {
const child = listRef.value!.children[i] as HTMLDivElement;
if (child.matches("[data-loaded='0']")) {
return false;
}
}
return true;
};


const getMinHeightColumn = () => {
let minColumnIndex = 0;
let minColumn = state.queueList[minColumnIndex];
for (let i = 1; i < state.queueList.length; i++) {
if (state.queueList[i].height < minColumn.height) {
minColumn = state.queueList[i];
minColumnIndex = i;
}
}
return {
index: minColumnIndex,
column: minColumn,
};
};


const computedViewHeight = () => {
if (!containerRef.value) return;
state.viewHeight = containerRef.value.clientHeight;
};


const listRef = ref<HTMLDivElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);







const computedLayout = (
column: number,
targetRenderIndex: number | number[] | undefined = undefined
) => {

const isArrayTarget = Array.isArray(targetRenderIndex);

let list = [];
for (let i = 0; i < listRef.value!.children.length; i++) {
let child = listRef.value!.children[i] as HTMLDivElement;
if (child.matches(`[data-column='${column}']`)) {
list.push(child);
}
}
if (!list.length) return;

const queue = state.queueList[column];

const firstRenderIndex = parseInt(
list[0].getAttribute("data-renderIndex") || "0"
);
const lastRenderIndex = firstRenderIndex + list.length - 1;

let offsetYAccount = queue.renderList[firstRenderIndex].offsetY;


for (let i = 0; i < list.length; i++) {
const item = list[i];
const renderItem =
queue.renderList[parseInt(item.getAttribute("data-renderIndex") || "0")];

if (
!targetRenderIndex ||
renderItem.renderIndex === targetRenderIndex ||
(isArrayTarget && targetRenderIndex.includes(renderItem.renderIndex))
) {
if (item.getAttribute("data-loaded") === "1") {

queue.height += item.offsetHeight - renderItem.height;

renderItem.height = item.offsetHeight;
}
}

renderItem.offsetY = offsetYAccount;

renderItem.style = getRenderStyle(column, offsetYAccount);

offsetYAccount += renderItem.height + props.gap;
}

if (!state.isScrollingDown) return;


const i = list.length * props.column + lastRenderIndex;
const preloadIndex =
i > queue.renderList.length ? queue.renderList.length : i;

for (let i = lastRenderIndex + 1; i < preloadIndex; i++) {
const item = queue.renderList[i];
item.offsetY = offsetYAccount;
item.style = getRenderStyle(column, offsetYAccount);
offsetYAccount += item.height + props.gap;
}



};


const computedLayoutAll = () => {
for (let i = 0; i < props.column; i++) {
computedLayout(i);
}
};



const imgLoadedHandle = function (e: Event) {
const target = e.target as HTMLImageElement;
const item = target.closest(".virtual-waterfall-item") as HTMLImageElement;
if (!item) return;

item.setAttribute("data-loaded", "1");
if (!props.compute) return;














computedLayout(
parseInt(item.getAttribute("data-column") || "0"),
parseInt(item.getAttribute("data-renderIndex") || "0")
);
};


const computedColumWidth = () => {
if (!listRef.value) return;
state.columnWidth =
(listRef.value.clientWidth - (props.column - 1) * props.gap) / props.column;
};

let isReload = false;
const reload = () => {
isReload = true;

computedQueueList(true);

state.renderList = [];

containerRef.value!.scrollTop = 0;
start.value = 0;
nextTick(() => {
computedRenderList();
});
};

watch(
() => props.dataSource,
(a, b) => {
state.preLen = b?.length ?? 0;
if (!a.length) return;
if (isReload) {
isReload = false;
return;
}
computedQueueList();
computedRenderList();
},
{
deep: false,
immediate: true,
}
);


const createHandleScroll = () => {
let lastScrollTop = 0;
let flag = true;
const fn = () => {
const { scrollTop, scrollHeight } = containerRef.value!;

start.value = scrollTop;

computedRenderList();

state.isScrollingDown = scrollTop > lastScrollTop;

lastScrollTop = scrollTop;

if (
!props.loading &&
state.isScrollingDown &&
scrollTop + state.viewHeight + 5 > scrollHeight
) {


const allLoaded = isAllLoad();
if (allLoaded) {
isReload && (isReload = false);
emit("addData");
}
}
flag = true;
};
const createHandle = (handle: Function) => {
return () => {
if (!flag) return;
flag = false;
handle();
};
};
if ("requestIdleCallback" in window) {
return createHandle(() => {
window.requestIdleCallback(fn);
});
} else if ("requestAnimationFrame" in window) {
return createHandle(() => {
window.requestAnimationFrame(fn);
});
}
return createHandle(fn);
};
const handleScrollFun = createHandleScroll();
const throttleHandleScroll = throttle(handleScrollFun, 250);
const debounceHandleScroll = debounce(handleScrollFun, 50);
const handleScroll = () => {
debounceHandleScroll();
throttleHandleScroll();
};


const resizeHandler = rafThrottle(() => {
computedViewHeight();
computedColumWidth();
computedRenderList();
});

onMounted(() => {
computedViewHeight();
computedColumWidth();
containerRef.value?.addEventListener("scroll", handleScroll);
window.addEventListener("resize", resizeHandler);
});

onUnmounted(() => {
containerRef.value?.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", resizeHandler);
});


watch(
() => props.column,
() => {

computedColumWidth();
reload();
}
);

defineExpose({
reload,
});
</script>

<style lang="scss">
.virtual-waterfall-panel {
height: 100%;
width: 100%;
.virtual-waterfall-container {
height: 100%;
width: 100%;
overflow-y: scroll;
overflow-x: hidden;
.virtual-waterfall-list {
height: 100%;
width: 100%;
position: relative;
.virtual-waterfall-item {
position: absolute;
// transition: all 0.3s;
overflow: hidden;
box-sizing: border-box;
transform: translate3d(0);
> .content {
width: 100%;
height: auto;
}
> .animation-box {
visibility: hidden;
}
&[data-loaded="1"] {
> .animation-box {
visibility: visible;
// animation: WaterFallItemAnimate 0.25s;
}
}
img {
width: 100%;
object-fit: cover;
overflow: hidden;
display: block;
}
}
}
}
}
@keyframes WaterFallItemAnimate {
from {
opacity: 0;
transform: translateY(100px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
<template>
<div class="list-panel">
<div class="btn-box">
<el-button @click="changeMock(MockType.simulated)">模拟数据</el-button>
<el-button @click="changeMock(MockType.real)">真实数据</el-button>
<el-button @click="changeMock(MockType.noImg)">无图片</el-button>
</div>
<div class="list-container">
<virtual-water-fall-list
:dataSource="data"
:loading="loading"
:column="column"
:estimatedHeight="estimatedHeight"
:gap="gap"
:compute="true"
@add-data="addData"
:animation="animation"
ref="list"
>
<template #item="{ item, index, load }">
<div class="item-box">
<img :src="item.data.src" @load="load" />
<span>{{ index + 1 + " " + item.data.title }}</span>
</div>
</template>
</virtual-water-fall-list>
</div>
</div>
</template>

<script setup lang="ts">
import Mock from "mockjs";
import VirtualWaterFallList from "@/components/VirtualWaterFallList.vue";
const data = ref<
{
src: string;
title: string;
}[]
>([]);
const loading = ref(false);
const column = ref(4);
const estimatedHeight = ref(50);
const gap = ref(10);
const list = ref<InstanceType<typeof VirtualWaterFallList> | null>(null);

const animation = ref(true);

enum MockType {
simulated = 0,
real = 1,
noImg = 2,
}

const addData = async () => {
switch (mock.value) {
case MockType.simulated:
await simulatedData();
break;
case MockType.real:
await fetchData();
break;
case MockType.noImg:
await onImgData();
break;
}
};


const simulatedData = () => {
loading.value = true;
return new Promise((resolve) => {
setTimeout(() => {
data.value = data.value.concat(
new Array(size * 2).fill(0).map((_, index) => ({
src: Mock.Random.dataImage(),
title: Mock.mock("@ctitle(5, 15)"),
}))
);
loading.value = false;
resolve(null);
}, 1000);
});
};

let size = 40;
let page = 1;

const fetchData = () => {
loading.value = true;
return new Promise((resolve) => {
fetch(
`https://www.vilipix.com/api/v1/picture/public?limit=${size}&offset=${
(page - 1) * size
}&sort=hot&type=0`
)
.then((res) => res.json())
.then((res) => {
page++;
const list = res.data.rows;
data.value = data.value.concat(
list.map((item: any) => ({
src: item.regular_url,
title: item.title,
height: item.height,
width: item.width,
}))
);
loading.value = false;
resolve(null);
});
});
};


const onImgData = () => {
loading.value = true;
return new Promise((resolve) => {
setTimeout(() => {
data.value = data.value.concat(
new Array(500).fill(0).map((_, index) => ({
src: "",
title: Mock.mock("@ctitle(20, 100)"),
}))
);
loading.value = false;
resolve(null);
}, 1000);
});
};

onMounted(() => {
addData();




});

const mock = ref(MockType.simulated);
const changeMock = async (value: number) => {
if (loading.value) return;
loading.value = true;
mock.value = value;
switch (value) {
case MockType.simulated:
estimatedHeight.value = 50;
break;
case MockType.real:
estimatedHeight.value = 50;
break;
case MockType.noImg:
estimatedHeight.value = 50;
break;
}
page = 1;
data.value = [];
try {
await addData();
} catch (error) {
loading.value = false;
console.error("数据加载出错", error);
}
list.value?.reload();
};
</script>

<style scoped lang="scss">
.list-panel {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
.btn-box {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.list-container {
max-width: 800px;
width: 100%;
height: calc(100vh - 120px);
border: 1px solid #333;
.item-box {
display: flex;
flex-direction: column;
}
}
}
</style>

<style>
@keyframes ItemMoveAnimate {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>