Vlist 虚拟网格组件 -- 解读🤓

Vlist 虚拟网格组件 – 解读 🤓

前言, 前几天刚完成一个需求,长(网格)列表优化,主要参考了虚拟列表原理。不同的是,虚拟列表是一维线性下来的,而虚拟网格是二维,要考虑不仅是屏幕高度,还要屏幕的宽度。不废话辽,上干货。

01 先浅用下,感受下它的神奇

虚拟网格gif

可以很清晰看到,随着页面的滚动,DOM 也是跟着渲染的,极大减少了浏览器的开销(卡顿是因为录制 gif,只有 10fps😅 )

02 虚拟网格实践

虚拟网格的原理: 其实就是根据外层容器尺寸,进行数据分组,以组为单位渲染数据列(column - n),然后剩下就是和虚拟列表的原理大致一样嘚

image-20220330181540874

  1. 首先,明确几个变量

    • column :内层容器纵向最大可放置的数量

    • row:内层容器横向最大可放置的数量

    • itemWidth:子组件的 width

    • itemHeight: 子组件的 height

    • startGroupIndex: 渲染,开始渲染的位置(组)

    • endtGroupIndex: 渲染,结束渲染的位置(组)

  2. 我们只对可视层做渲染,但是为了保持整个容器像渲染正常长列表一样,里面的容器还需要保持原有的高度。这边把 Html 设计这样

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!-- 外层容器 -->
    <div className="vListContainer">
    <!-- 内层容器 -->
    <div className="phantomContent">
    ...
    <!-- column-1 -->
    <!-- column-2 -->
    <!-- column-3 -->
    ....
    </div>
    </div>
  3. 通过计算_.chunk(itemsArray, column ),拿到分组的数组 groupItems

    1
    2
    // groupItems 二维数组
    ;[[1, 2, 3][(4, 5, 6)][(7, 8)]]
  4. 再通过监听 外层容器 onscroll 计算出 startGroupIndexendtGroupIndex

  5. 通过 renderDisplayContent 渲染到页面上

  6. 另外当页面发生变化时,通过监听resize 事件(computeGridSize)更新 columnrowgroupItems,以重新渲染

03 虚拟网格组件源码细节

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
import React, { useState, useCallback, useMemo, useEffect } from 'react'
import useThrottle from 'hooks/useThrottle'
import _ from 'lodash'
import { VListStyle } from './style'
import { useRef } from 'react'

/**
* @description: 虚拟列表
* @param {*} containerRef 外容器ref
* @param {*} itemsArray 列表数据
* @param {*} children 子组件
* @param {*} containerInitHeight 外容器初始高度
* @param {*} containeInitWidth 外容器初始宽度
* @param {*} itemHeight 子组件高度
* @param {*} itemWidth 子组件宽度
* @param {*} BufferSize 列表缓冲区大小
* @return {*}
*/
function VList({
containerRef,
itemsArray = [],
children,
containerInitHeight,
containeInitWidth,
itemHeight,
itemWidth,
BufferSize = 4,
}) {
// 内层容器的 ref
const phantomContentRef = useRef(null)

// 内层容器纵向最大可放置的数量
const [column, setColumn] = useState(() =>
Math.floor(containeInitWidth / itemWidth)
)

// 内层容器横向最大可放置的数量
const [row, setRow] = useState(() =>
Math.ceil(containerInitHeight / itemHeight)
)

// 列表数据分组,按照column分组
const [groupItems, setGroupItems] = useState(() =>
_.chunk(itemsArray, column)
)

// 渲染,开始渲染的位置(组)
const [startGroupIndex, setStartGroupIndex] = useState(0)

// 渲染,结束渲染的位置(组)
const [endtGroupIndex, setEndGroupIndex] = useState(() => {
return Math.min(row * column, groupItems.length - 1)
})

/**
* @description: 滚动条 onscroll事件,计算 startGroupIndex, endtGroupIndex
* @param
* @return
*/
const onScrollListening = useCallback(() => {
// 内层容器滚动偏移量
const { scrollTop } = containerRef.current

// 计算滚动到的组索引号
const currentstartGroupIndex = Math.floor(scrollTop / itemHeight)

// 当前滚动到的组索引号 与 startGroupIndex 不同时,才更新 startGroupIndex, endtGroupIndex
if (currentstartGroupIndex !== startGroupIndex) {
setStartGroupIndex(currentstartGroupIndex)
setEndGroupIndex(
Math.min(startGroupIndex + row + BufferSize, groupItems.length - 1)
)
}
}, [itemHeight, startGroupIndex, containerRef, BufferSize, groupItems, row])

/**
* @description: 用于通过 startGroupIndex, endtGroupIndex 遍历 groupItems 渲染到页面上
* @param
* @return
*/
const renderDisplayContent = useMemo(() => {
// itemsArray 数据为空,不渲染
if (!_.isEmpty(itemsArray)) {
// 存放需要需要渲染 DOM 数组
const content = []

for (let i = startGroupIndex; i <= endtGroupIndex; i++) {
content.push(
<div
key={i}
className="phantomItem"
style={{
gridTemplateColumns: `repeat(auto-fill, ${itemWidth}px)`,
}}
>
{
// 遍历 groupItems
groupItems[i].map((item, j) => children(item, `${i}-${j} `))
}
</div>
)
}
return content
}
}, [
startGroupIndex,
endtGroupIndex,
groupItems,
children,
itemsArray,
itemWidth,
])

// 给 containerBobyRef 加上 onscroll 事件
containerRef.current.onscroll = useThrottle(onScrollListening, 100, [])

/**
* @description: 用于监听外层容器的可视区域大小变化
* @param {*}
* @return {*}
*/
const computeGridSize = useCallback(() => {
// 外层容器当前可视高度
const Height = containerRef.current.clientHeight

// 外层容器当前可视宽度
const Width = containerRef.current.clientWidth

// 内层容器纵向当前最大可放置的数量
const currentColumn = Math.floor(Width / itemWidth)

// 内层容器横向当前最大可放置的数量
const currentRow = Math.ceil(Height / itemHeight)

// 不相同时,说明页面发生变化,更新 column, row
if (currentColumn !== column || currentRow !== row) {
setColumn(currentColumn)
setRow(currentRow)
setGroupItems(_.chunk(itemsArray, currentColumn))
}
}, [containerRef, itemHeight, itemWidth, column, row, itemsArray])

// 监听外层容器的可视区域大小变化
useEffect(() => {
window.addEventListener('resize', computeGridSize)
return () => window.removeEventListener('resize', computeGridSize)
}, [computeGridSize])

// height: `${groupItems.length * itemHeight}px`
return (
<>
<VListStyle
className="phantomContent"
ref={phantomContentRef}
style={{
marginTop: `${startGroupIndex * itemHeight}px`,
marginBottom: `${
(groupItems.length - endtGroupIndex - 1) * itemHeight
}px`,
}}
>
{renderDisplayContent}
</VListStyle>
</>
)
}

export default VList

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!