@dot-present/virtual-list 1.0.2-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +512 -0
- package/package.json +23 -0
- package/src/App.tsx +395 -0
- package/src/index.tsx +3 -0
- package/src/types.d.ts +2 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# 虚拟列表
|
|
2
|
+
|
|
3
|
+
## 为什么需要虚拟列表组件?
|
|
4
|
+
|
|
5
|
+
在处理海量数据列表时,传统的滚动列表会导致性能问题,因为浏览器需要渲染所有 DOM 节点,而用户只能看到可见区域。虚拟列表组件通过只渲染可视区域内的少量 DOM 节点,通过动态计算起始/结束索引来模拟完整滚动效果,从而支持海量数据的高性能滚动。
|
|
6
|
+
|
|
7
|
+
## 解决方案
|
|
8
|
+
|
|
9
|
+
### 1.时间分片:
|
|
10
|
+
|
|
11
|
+
通过 requestAnimationFrame 实现滚动事件的时间分片处理,大量数据分片执行,执行完毕后控制权交还主线程处理,避免造成主线程阻塞。
|
|
12
|
+
缺陷:效率低、不直观、性能差。
|
|
13
|
+
|
|
14
|
+
### 2.虚拟列表:
|
|
15
|
+
|
|
16
|
+
**设置一个可视区域,只渲染可视区域内的少量 DOM 节点,通过动态计算起始/结束索引来模拟完整滚动效果,从而支持海量数据的高性能滚动**
|
|
17
|
+
|
|
18
|
+
#### 实现
|
|
19
|
+
|
|
20
|
+
每个列表项的高度是固定的,通过计算可视区域的起始索引和结束索引,动态渲染可视区域内的列表项。
|
|
21
|
+
|
|
22
|
+
- 起始索引:可视区域顶部第一个列表项的索引
|
|
23
|
+
- 结束索引:可视区域底部最后一个列表项的索引
|
|
24
|
+
- 可视区域高度:可视区域的高度,用于计算起始索引和结束索引
|
|
25
|
+
- 偏移量:可视区域顶部与列表顶部的距离,用于计算起始索引
|
|
26
|
+
|
|
27
|
+
监听containerde scroll事件
|
|
28
|
+
|
|
29
|
+
可视区域高度固定 screenHeight
|
|
30
|
+
列表每项高度固定 itemHeight
|
|
31
|
+
列表数据称之为 listData
|
|
32
|
+
滚动高度为 scrollTop
|
|
33
|
+
|
|
34
|
+
计算出一些信息:
|
|
35
|
+
列表总高度 listHeight:`listData.length * itemHeight`
|
|
36
|
+
可现实列表项数 visibleCount:`screenHeight / itemHeight`
|
|
37
|
+
起始索引 startIndex:`Math.floor(scrollTop / itemHeight)`
|
|
38
|
+
结束索引 endIndex:`Math.min(startIndex + visibleCount, listData.length)`
|
|
39
|
+
可显示数据 visibleData:`listData.slice(startIndex, endIndex)`
|
|
40
|
+
|
|
41
|
+
## 偏移量 offsetTop:`scrollTop - startIndex * itemHeight`
|
|
42
|
+
|
|
43
|
+
#### 遗留问题
|
|
44
|
+
|
|
45
|
+
1. 动态高度
|
|
46
|
+
2. 白屏问题
|
|
47
|
+
|
|
48
|
+
## 一、公用组件的设计目标
|
|
49
|
+
|
|
50
|
+
1. **数据无关**:接受任意数据结构,通过作用域插槽(或 render prop)自定义每一项的渲染。
|
|
51
|
+
2. **高度灵活**:支持固定行高(高性能)和动态行高(自适应内容)。
|
|
52
|
+
3. **容器自适应**:滚动容器高度可固定或跟随父元素,能响应容器尺寸变化。
|
|
53
|
+
4. **可控滚动**:提供 `scrollTo`、`scrollToIndex` 等方法,方便外部控制滚动位置。
|
|
54
|
+
5. **性能优先**:滚动事件节流,缓存位置信息,避免重排抖动。
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 二、Props 配置设计
|
|
59
|
+
|
|
60
|
+
| 属性名 | 类型 | 默认值 | 说明 |
|
|
61
|
+
| --------------------- | ----------------- | -------- | -------------------------------------- |
|
|
62
|
+
| `data` | Array | `[]` | 列表数据源 |
|
|
63
|
+
| `itemKey` | string / function | `'id'` | 用于标识每个列表项的唯一键(优化渲染) |
|
|
64
|
+
| `estimatedItemHeight` | number | `50` | 预估的行高(动态高度模式必须提供) |
|
|
65
|
+
| `itemHeight` | number | - | 固定行高(若提供则启用固定高度模式) |
|
|
66
|
+
| `containerHeight` | number / string | `'100%'` | 滚动容器高度(支持 px 或百分比) |
|
|
67
|
+
| `bufferSize` | number | `3` | 可视区域上下额外渲染的条目数,减少白屏 |
|
|
68
|
+
| `overscanCount` | number | `2` | 同上,部分库称 overscan |
|
|
69
|
+
| `scrollThreshold` | number | `0.8` | 滚动到底部阈值(用于加载更多) |
|
|
70
|
+
| `onScroll` | function | - | 滚动事件回调 |
|
|
71
|
+
| `onScrollEnd` | function | - | 滚动停止回调 |
|
|
72
|
+
| `onReachEnd` | function | - | 滚动接近底部时触发(加载更多) |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 三、核心实现思路
|
|
77
|
+
|
|
78
|
+
### 1. 固定高度模式
|
|
79
|
+
|
|
80
|
+
- **原理**:已知每项高度 `itemHeight`,通过滚动位置 `scrollTop` 直接计算起始索引:
|
|
81
|
+
```
|
|
82
|
+
startIndex = Math.floor(scrollTop / itemHeight)
|
|
83
|
+
endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + bufferSize
|
|
84
|
+
```
|
|
85
|
+
- **布局**:外层 `div` 相对定位,内层 `div` 高度为 `data.length * itemHeight` 作为占位符撑开滚动条,每个列表项绝对定位,`top` 为 `index * itemHeight`。
|
|
86
|
+
|
|
87
|
+
### 2. 动态高度模式
|
|
88
|
+
|
|
89
|
+
- **原理**:维护一个位置数组 `positions`,存储每个列表项的 `height`、`offset`(累计偏移)、`index`。
|
|
90
|
+
- 初始时根据 `estimatedItemHeight` 估算所有项的 `height` 和 `offset`。
|
|
91
|
+
- 滚动时通过二分查找找到起始索引(根据 `scrollTop` 查找最后一个 `offset <= scrollTop` 的项)。
|
|
92
|
+
- 每次渲染后,通过 `ResizeObserver` 或 `ref` 回调获取真实高度,更新 `positions`,并修正后续所有项的 `offset`。
|
|
93
|
+
- 渲染区域外的项不再测量,避免性能损耗。
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 四、React 实现示例(动态高度 + 泛型支持)
|
|
98
|
+
|
|
99
|
+
以下代码展示一个完整的公用虚拟滚动组件 `VirtualList`,支持动态高度、自定义渲染、暴露方法。
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import React, {
|
|
103
|
+
useRef,
|
|
104
|
+
useState,
|
|
105
|
+
useEffect,
|
|
106
|
+
useMemo,
|
|
107
|
+
useCallback,
|
|
108
|
+
forwardRef,
|
|
109
|
+
useImperativeHandle,
|
|
110
|
+
} from "react";
|
|
111
|
+
|
|
112
|
+
// 位置缓存项
|
|
113
|
+
interface Position {
|
|
114
|
+
index: number;
|
|
115
|
+
height: number;
|
|
116
|
+
offset: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface VirtualListRef {
|
|
120
|
+
scrollTo: (scrollTop: number) => void;
|
|
121
|
+
scrollToIndex: (index: number, align?: "start" | "center" | "end") => void;
|
|
122
|
+
getCurrentRange: () => { start: number; end: number };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface VirtualListProps<T> {
|
|
126
|
+
data: T[];
|
|
127
|
+
itemKey: keyof T | ((item: T, index: number) => string);
|
|
128
|
+
estimatedItemHeight?: number; // 预估高度,动态高度模式必须
|
|
129
|
+
itemHeight?: number; // 若提供,则启用固定高度模式
|
|
130
|
+
containerHeight?: number | string;
|
|
131
|
+
bufferSize?: number;
|
|
132
|
+
overscanCount?: number; // 与 bufferSize 同义,可选
|
|
133
|
+
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
|
134
|
+
onReachEnd?: () => void;
|
|
135
|
+
renderItem: (item: T, index: number) => React.ReactNode;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const VirtualList = forwardRef(
|
|
139
|
+
<T,>(props: VirtualListProps<T>, ref: React.Ref<VirtualListRef>) => {
|
|
140
|
+
const {
|
|
141
|
+
data,
|
|
142
|
+
itemKey,
|
|
143
|
+
estimatedItemHeight = 50,
|
|
144
|
+
itemHeight: fixedItemHeight,
|
|
145
|
+
containerHeight = "100%",
|
|
146
|
+
bufferSize = 3,
|
|
147
|
+
overscanCount,
|
|
148
|
+
onScroll,
|
|
149
|
+
onReachEnd,
|
|
150
|
+
renderItem,
|
|
151
|
+
} = props;
|
|
152
|
+
|
|
153
|
+
const buffer = overscanCount ?? bufferSize;
|
|
154
|
+
const isFixedHeight = fixedItemHeight !== undefined;
|
|
155
|
+
|
|
156
|
+
// DOM refs
|
|
157
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
158
|
+
const itemsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
159
|
+
|
|
160
|
+
// 状态
|
|
161
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
162
|
+
const [containerHeightVal, setContainerHeightVal] = useState(0);
|
|
163
|
+
|
|
164
|
+
// ---------- 动态高度模式的位置缓存 ----------
|
|
165
|
+
const positions = useRef<Position[]>([]);
|
|
166
|
+
|
|
167
|
+
// 初始化 / 数据变化时重建位置缓存
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (isFixedHeight) return;
|
|
170
|
+
const total = data.length;
|
|
171
|
+
let offset = 0;
|
|
172
|
+
positions.current = data.map((_, idx) => {
|
|
173
|
+
const height = estimatedItemHeight;
|
|
174
|
+
const pos = { index: idx, height, offset };
|
|
175
|
+
offset += height;
|
|
176
|
+
return pos;
|
|
177
|
+
});
|
|
178
|
+
}, [data, estimatedItemHeight, isFixedHeight]);
|
|
179
|
+
|
|
180
|
+
// 更新某个索引的高度,并修正后续偏移
|
|
181
|
+
const updateItemHeight = useCallback(
|
|
182
|
+
(index: number, newHeight: number) => {
|
|
183
|
+
if (isFixedHeight) return;
|
|
184
|
+
const pos = positions.current[index];
|
|
185
|
+
if (!pos || pos.height === newHeight) return;
|
|
186
|
+
const oldHeight = pos.height;
|
|
187
|
+
pos.height = newHeight;
|
|
188
|
+
// 修正从 index+1 开始的所有偏移量
|
|
189
|
+
let diff = newHeight - oldHeight;
|
|
190
|
+
for (let i = index + 1; i < positions.current.length; i++) {
|
|
191
|
+
positions.current[i].offset += diff;
|
|
192
|
+
}
|
|
193
|
+
// 触发重新渲染,确保滚动位置正确
|
|
194
|
+
setScrollTop((prev) => prev); // 微小 hack:强制刷新(实际可用更优雅的方式)
|
|
195
|
+
},
|
|
196
|
+
[isFixedHeight],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// 监听容器高度变化
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!containerRef.current) return;
|
|
202
|
+
const observer = new ResizeObserver((entries) => {
|
|
203
|
+
const height = entries[0].contentRect.height;
|
|
204
|
+
setContainerHeightVal(height);
|
|
205
|
+
});
|
|
206
|
+
observer.observe(containerRef.current);
|
|
207
|
+
return () => observer.disconnect();
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
// 计算当前可视区的起始/结束索引
|
|
211
|
+
const { startIndex, endIndex, totalHeight } = useMemo(() => {
|
|
212
|
+
if (isFixedHeight) {
|
|
213
|
+
const start = Math.max(0, Math.floor(scrollTop / fixedItemHeight));
|
|
214
|
+
const visibleCount = Math.ceil(containerHeightVal / fixedItemHeight);
|
|
215
|
+
const end = Math.min(data.length - 1, start + visibleCount + buffer);
|
|
216
|
+
const total = data.length * fixedItemHeight;
|
|
217
|
+
return { startIndex: start, endIndex: end, totalHeight: total };
|
|
218
|
+
} else {
|
|
219
|
+
// 二分查找起始索引
|
|
220
|
+
const binarySearch = (scrollTop: number) => {
|
|
221
|
+
let low = 0,
|
|
222
|
+
high = data.length - 1;
|
|
223
|
+
while (low <= high) {
|
|
224
|
+
const mid = Math.floor((low + high) / 2);
|
|
225
|
+
const offset = positions.current[mid]?.offset ?? 0;
|
|
226
|
+
if (offset === scrollTop) return mid;
|
|
227
|
+
if (offset < scrollTop) low = mid + 1;
|
|
228
|
+
else high = mid - 1;
|
|
229
|
+
}
|
|
230
|
+
return low;
|
|
231
|
+
};
|
|
232
|
+
const start = binarySearch(scrollTop);
|
|
233
|
+
let end = start;
|
|
234
|
+
let offsetEnd = positions.current[start]?.offset ?? 0;
|
|
235
|
+
while (
|
|
236
|
+
end < data.length - 1 &&
|
|
237
|
+
offsetEnd - scrollTop < containerHeightVal
|
|
238
|
+
) {
|
|
239
|
+
end++;
|
|
240
|
+
offsetEnd =
|
|
241
|
+
positions.current[end]?.offset +
|
|
242
|
+
(positions.current[end]?.height ?? 0);
|
|
243
|
+
}
|
|
244
|
+
// 增加缓冲区
|
|
245
|
+
const startWithBuffer = Math.max(0, start - buffer);
|
|
246
|
+
const endWithBuffer = Math.min(data.length - 1, end + buffer);
|
|
247
|
+
const total = positions.current.length
|
|
248
|
+
? positions.current[positions.current.length - 1]?.offset +
|
|
249
|
+
(positions.current[positions.current.length - 1]?.height ?? 0)
|
|
250
|
+
: 0;
|
|
251
|
+
return {
|
|
252
|
+
startIndex: startWithBuffer,
|
|
253
|
+
endIndex: endWithBuffer,
|
|
254
|
+
totalHeight: total,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}, [
|
|
258
|
+
scrollTop,
|
|
259
|
+
containerHeightVal,
|
|
260
|
+
data.length,
|
|
261
|
+
buffer,
|
|
262
|
+
fixedItemHeight,
|
|
263
|
+
isFixedHeight,
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
// 滚动事件处理(含节流)
|
|
267
|
+
const handleScroll = useCallback(
|
|
268
|
+
(e: React.UIEvent<HTMLDivElement>) => {
|
|
269
|
+
const target = e.currentTarget;
|
|
270
|
+
const newScrollTop = target.scrollTop;
|
|
271
|
+
setScrollTop(newScrollTop);
|
|
272
|
+
onScroll?.(e);
|
|
273
|
+
// 触底检测
|
|
274
|
+
if (
|
|
275
|
+
onReachEnd &&
|
|
276
|
+
target.scrollHeight - target.scrollTop - target.clientHeight < 5
|
|
277
|
+
) {
|
|
278
|
+
onReachEnd();
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
[onScroll, onReachEnd],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// 暴露方法
|
|
285
|
+
useImperativeHandle(
|
|
286
|
+
ref,
|
|
287
|
+
() => ({
|
|
288
|
+
scrollTo: (top: number) => {
|
|
289
|
+
if (containerRef.current) containerRef.current.scrollTop = top;
|
|
290
|
+
},
|
|
291
|
+
scrollToIndex: (
|
|
292
|
+
index: number,
|
|
293
|
+
align: "start" | "center" | "end" = "start",
|
|
294
|
+
) => {
|
|
295
|
+
if (!containerRef.current) return;
|
|
296
|
+
let targetTop = 0;
|
|
297
|
+
if (isFixedHeight) {
|
|
298
|
+
targetTop = index * fixedItemHeight;
|
|
299
|
+
} else {
|
|
300
|
+
targetTop = positions.current[index]?.offset ?? 0;
|
|
301
|
+
}
|
|
302
|
+
if (align === "center") {
|
|
303
|
+
targetTop -= containerHeightVal / 2;
|
|
304
|
+
} else if (align === "end") {
|
|
305
|
+
targetTop -= containerHeightVal;
|
|
306
|
+
}
|
|
307
|
+
containerRef.current.scrollTop = Math.max(0, targetTop);
|
|
308
|
+
},
|
|
309
|
+
getCurrentRange: () => ({ start: startIndex, end: endIndex }),
|
|
310
|
+
}),
|
|
311
|
+
[
|
|
312
|
+
startIndex,
|
|
313
|
+
endIndex,
|
|
314
|
+
containerHeightVal,
|
|
315
|
+
fixedItemHeight,
|
|
316
|
+
isFixedHeight,
|
|
317
|
+
],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// 获取唯一 key
|
|
321
|
+
const getKey = useCallback(
|
|
322
|
+
(item: T, idx: number): string => {
|
|
323
|
+
if (typeof itemKey === "function") return itemKey(item, idx);
|
|
324
|
+
return (item[itemKey] as any)?.toString() ?? `item_${idx}`;
|
|
325
|
+
},
|
|
326
|
+
[itemKey],
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// 渲染可视区内的项
|
|
330
|
+
const visibleItems = useMemo(() => {
|
|
331
|
+
const items = [];
|
|
332
|
+
for (let i = startIndex; i <= endIndex && i < data.length; i++) {
|
|
333
|
+
const item = data[i];
|
|
334
|
+
const key = getKey(item, i);
|
|
335
|
+
let top = 0;
|
|
336
|
+
if (isFixedHeight) {
|
|
337
|
+
top = i * fixedItemHeight;
|
|
338
|
+
} else {
|
|
339
|
+
top = positions.current[i]?.offset ?? 0;
|
|
340
|
+
}
|
|
341
|
+
items.push(
|
|
342
|
+
<div
|
|
343
|
+
key={key}
|
|
344
|
+
ref={(el) => {
|
|
345
|
+
if (!el) return;
|
|
346
|
+
if (isFixedHeight) return;
|
|
347
|
+
itemsRef.current.set(key, el);
|
|
348
|
+
// 测量真实高度(初次渲染或内容变化)
|
|
349
|
+
const actualHeight = el.getBoundingClientRect().height;
|
|
350
|
+
if (
|
|
351
|
+
actualHeight &&
|
|
352
|
+
positions.current[i]?.height !== actualHeight
|
|
353
|
+
) {
|
|
354
|
+
updateItemHeight(i, actualHeight);
|
|
355
|
+
}
|
|
356
|
+
}}
|
|
357
|
+
style={{
|
|
358
|
+
position: "absolute",
|
|
359
|
+
top: 0,
|
|
360
|
+
left: 0,
|
|
361
|
+
width: "100%",
|
|
362
|
+
transform: `translateY(${top}px)`,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{renderItem(item, i)}
|
|
366
|
+
</div>,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return items;
|
|
370
|
+
}, [
|
|
371
|
+
startIndex,
|
|
372
|
+
endIndex,
|
|
373
|
+
data,
|
|
374
|
+
getKey,
|
|
375
|
+
isFixedHeight,
|
|
376
|
+
fixedItemHeight,
|
|
377
|
+
renderItem,
|
|
378
|
+
updateItemHeight,
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
// 容器样式
|
|
382
|
+
const containerStyle: React.CSSProperties = {
|
|
383
|
+
height: containerHeight,
|
|
384
|
+
overflowY: "auto",
|
|
385
|
+
position: "relative",
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const innerStyle: React.CSSProperties = {
|
|
389
|
+
position: "relative",
|
|
390
|
+
height: totalHeight,
|
|
391
|
+
width: "100%",
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div ref={containerRef} style={containerStyle} onScroll={handleScroll}>
|
|
396
|
+
<div style={innerStyle}>{visibleItems}</div>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
export default VirtualList;
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## 五、使用示例
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
import VirtualList from "./VirtualList";
|
|
411
|
+
|
|
412
|
+
const BigDataList = () => {
|
|
413
|
+
const data = Array.from({ length: 10000 }, (_, i) => ({
|
|
414
|
+
id: i,
|
|
415
|
+
text: `Item ${i}`,
|
|
416
|
+
}));
|
|
417
|
+
const ref = useRef<VirtualListRef>(null);
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<VirtualList
|
|
421
|
+
ref={ref}
|
|
422
|
+
data={data}
|
|
423
|
+
itemKey="id"
|
|
424
|
+
estimatedItemHeight={60}
|
|
425
|
+
containerHeight="500px"
|
|
426
|
+
bufferSize={5}
|
|
427
|
+
onReachEnd={() => console.log("load more")}
|
|
428
|
+
renderItem={(item, index) => (
|
|
429
|
+
<div style={{ padding: "12px", borderBottom: "1px solid #ccc" }}>
|
|
430
|
+
{index}: {item.text}
|
|
431
|
+
<p style={{ margin: 0, fontSize: "12px", color: "#666" }}>
|
|
432
|
+
This is a dynamic height example. Content may vary.
|
|
433
|
+
</p>
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
/>
|
|
437
|
+
);
|
|
438
|
+
};
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## 六、封装为公用组件的关键点
|
|
444
|
+
|
|
445
|
+
### 1. 支持固定高度与动态高度
|
|
446
|
+
|
|
447
|
+
- 通过 `itemHeight` 的存在与否区分模式,固定高度性能更优,动态高度更灵活。
|
|
448
|
+
- 动态高度需要引入位置缓存与高度测量,注意避免频繁重绘。
|
|
449
|
+
|
|
450
|
+
### 2. 自定义渲染
|
|
451
|
+
|
|
452
|
+
- 使用 `renderItem` 作为作用域插槽,将每一项的数据和索引传递给调用方,保证 UI 完全可定制。
|
|
453
|
+
|
|
454
|
+
### 3. 暴露命令式方法
|
|
455
|
+
|
|
456
|
+
- 使用 `forwardRef` + `useImperativeHandle` 提供 `scrollTo`、`scrollToIndex` 等 API,方便外部控制滚动。
|
|
457
|
+
|
|
458
|
+
### 4. 响应式更新
|
|
459
|
+
|
|
460
|
+
- 当 `data` 变化时,需要重置位置缓存并重新计算可视区。注意保留或重置滚动位置(可根据业务需求决定)。
|
|
461
|
+
- 当容器尺寸变化时,重新计算可视区范围(ResizeObserver)。
|
|
462
|
+
|
|
463
|
+
### 5. 性能优化
|
|
464
|
+
|
|
465
|
+
- 滚动事件:无需额外节流,因为 `scrollTop` 状态更新本身会触发重新渲染,但要注意 `setScrollTop` 不应过于频繁(React 18 的自动批处理可缓解)。
|
|
466
|
+
- 高度测量:使用 `ResizeObserver` 或 `ref` 回调 + `getBoundingClientRect`,只在必要时更新,避免同步循环。
|
|
467
|
+
- 虚拟列表内部使用 `useMemo` 缓存计算结果。
|
|
468
|
+
|
|
469
|
+
### 6. 边界处理
|
|
470
|
+
|
|
471
|
+
- 数据为空时,不渲染任何项。
|
|
472
|
+
- 滚动到边界时的索引越界处理。
|
|
473
|
+
- 动态高度模式下,快速滚动时可能出现未测量的项,此时利用预估高度保证占位正确。
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## 七、扩展与变体
|
|
478
|
+
|
|
479
|
+
- **水平虚拟滚动**:只需将垂直方向改为水平方向,计算逻辑类似。
|
|
480
|
+
- **网格布局**:可基于行数计算,但通常需同时知道列数,复杂度稍高。
|
|
481
|
+
- **树形数据**:需配合展开/收起动态调整位置,难度更大,可考虑结合递归结构。
|
|
482
|
+
- **无限滚动**:利用 `onReachEnd` 加载更多,追加数据后需保持滚动位置(需记录当前滚动偏移)。
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## 八、总结
|
|
487
|
+
|
|
488
|
+
封装一个高质量的虚拟滚动公用组件,核心在于:
|
|
489
|
+
|
|
490
|
+
1. **清晰分离数据、渲染与滚动逻辑**。
|
|
491
|
+
2. **提供足够的配置项(高度类型、缓冲、滚动阈值等)以适应不同业务场景**。
|
|
492
|
+
3. **通过插槽/渲染函数保证 UI 完全可定制**。
|
|
493
|
+
4. **暴露必要的方法,满足外部控制需求**。
|
|
494
|
+
5. **做好性能优化(缓存、测量策略、避免多余重绘)**。
|
|
495
|
+
|
|
496
|
+
实际开发中,也可以基于成熟库(如 `react-window`、`vue-virtual-scroller`)进行二次封装,但理解其内部原理有助于写出更贴合业务需求的自定义组件。
|
|
497
|
+
|
|
498
|
+
以上思路和代码可直接用于生产环境,也可轻松移植到 Vue 或其他框架(核心逻辑不变,仅语法适配)。
|
|
499
|
+
|
|
500
|
+
# 首屏样式挤在一起原因
|
|
501
|
+
|
|
502
|
+
原因说明(结合代码与假设)
|
|
503
|
+
假设 说明
|
|
504
|
+
H1 updateItemHeight 里用 setScrollTop((prev) => prev) 想“强刷”。在 React 18 里,若新状态与旧状态相同(Object.is),会 直接跳过更新,组件 不会重渲染。
|
|
505
|
+
H2 visibleItems 的 useMemo 没有把高度测量结果放进依赖。positions.current 虽在内存里被改掉,但 memo 仍返回旧的 JSX,里面的 translateY 仍是 预估高度,所以会 挤在一起 / 重叠。
|
|
506
|
+
H3 原先二分查找用 return low,等价于找「第一个 offset > scrollTop」,而不是「最后一个 offset <= scrollTop」的可见首项,滚动后 起始索引会错。
|
|
507
|
+
H4 展开/折叠只改变 子节点高度 时,同一 DOM 上的 ref 不一定会再次执行,仅靠 getBoundingClientRect 测一次不够;需要 ResizeObserver 在高度变化时再次 updateItemHeight。
|
|
508
|
+
已做修改(调试日志仍保留)
|
|
509
|
+
使用 useReducer 的 measureVersion:每次 updateItemHeight 成功后 bumpMeasure(),并把 measureVersion 写入 计算可视区与 visibleItems 的两个 useMemo 的依赖,保证 真实 offset 会反映到 translateY。
|
|
510
|
+
修正 findStartIndex:改为「最大下标 i 满足 offset[i] <= scrollTop」。
|
|
511
|
+
修正向下扩展可视区时的 offsetEnd 初值:从「当前首项的 底边」开始算,避免少算一行。
|
|
512
|
+
为每项行容器加 ResizeObserver,并在卸载时 disconnect,避免展开动态内容后高度不更新。
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dot-present/virtual-list",
|
|
3
|
+
"version": "1.0.2-alpha.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "./src/index.tsx",
|
|
7
|
+
"types": "./src/types.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"packageManager": "pnpm@10.26.2",
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": ">=18",
|
|
20
|
+
"react-dom": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"gitHead": "72fefa9b04e7508cfea2590e760340770038aee0"
|
|
23
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useRef,
|
|
3
|
+
useState,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useCallback,
|
|
8
|
+
forwardRef,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useReducer,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
// 位置缓存项
|
|
14
|
+
interface Position {
|
|
15
|
+
index: number;
|
|
16
|
+
height: number;
|
|
17
|
+
offset: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface VirtualListRef {
|
|
21
|
+
scrollTo: (scrollTop: number) => void;
|
|
22
|
+
scrollToIndex: (index: number, align?: "start" | "center" | "end") => void;
|
|
23
|
+
getCurrentRange: () => { start: number; end: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface VirtualListProps<T> {
|
|
27
|
+
data: T[];
|
|
28
|
+
itemKey: keyof T | ((item: T, index: number) => string);
|
|
29
|
+
estimatedItemHeight?: number; // 预估高度,动态高度模式必须
|
|
30
|
+
itemHeight?: number; // 若提供,则启用固定高度模式
|
|
31
|
+
containerHeight?: number | string;
|
|
32
|
+
bufferSize?: number;
|
|
33
|
+
overscanCount?: number; // 与 bufferSize 同义,可选
|
|
34
|
+
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
|
35
|
+
onReachEnd?: () => void;
|
|
36
|
+
renderItem: (item: T, index: number) => React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const VirtualList = forwardRef(
|
|
40
|
+
<T,>(props: VirtualListProps<T>, ref: React.Ref<VirtualListRef>) => {
|
|
41
|
+
const {
|
|
42
|
+
data,
|
|
43
|
+
itemKey,
|
|
44
|
+
estimatedItemHeight = 50,
|
|
45
|
+
itemHeight: fixedItemHeight,
|
|
46
|
+
containerHeight = "100%",
|
|
47
|
+
bufferSize = 3,
|
|
48
|
+
overscanCount,
|
|
49
|
+
onScroll,
|
|
50
|
+
onReachEnd,
|
|
51
|
+
renderItem,
|
|
52
|
+
} = props;
|
|
53
|
+
|
|
54
|
+
const buffer = overscanCount ?? bufferSize;
|
|
55
|
+
const isFixedHeight = fixedItemHeight !== undefined;
|
|
56
|
+
|
|
57
|
+
// DOM refs
|
|
58
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
59
|
+
const itemsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
60
|
+
const itemObserversRef = useRef<Map<string, ResizeObserver>>(new Map());
|
|
61
|
+
const isNearEndRef = useRef(false);
|
|
62
|
+
|
|
63
|
+
// 状态
|
|
64
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
65
|
+
const [containerHeightVal, setContainerHeightVal] = useState(0);
|
|
66
|
+
/** 动态高度测量后递增,驱动 range / visibleItems 的 memo 与真实 offset 同步 */
|
|
67
|
+
const [measureVersion, bumpMeasure] = useReducer((v: number) => v + 1, 0);
|
|
68
|
+
|
|
69
|
+
const parsedContainerHeight = useMemo(() => {
|
|
70
|
+
if (typeof containerHeight === "number") return containerHeight;
|
|
71
|
+
if (typeof containerHeight === "string") {
|
|
72
|
+
const matched = containerHeight.trim().match(/^(\d+(?:\.\d+)?)px$/i);
|
|
73
|
+
if (matched) return Number(matched[1]);
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}, [containerHeight]);
|
|
77
|
+
|
|
78
|
+
// ---------- 动态高度模式的位置缓存 ----------
|
|
79
|
+
const positions = useRef<Position[]>([]);
|
|
80
|
+
|
|
81
|
+
// 初始化 / 数据变化时重建位置缓存
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (isFixedHeight) return;
|
|
84
|
+
const total = data.length;
|
|
85
|
+
let offset = 0;
|
|
86
|
+
positions.current = data.map((_, idx) => {
|
|
87
|
+
const height = estimatedItemHeight;
|
|
88
|
+
const pos = { index: idx, height, offset };
|
|
89
|
+
offset += height;
|
|
90
|
+
return pos;
|
|
91
|
+
});
|
|
92
|
+
}, [data, estimatedItemHeight, isFixedHeight]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
return () => {
|
|
96
|
+
itemObserversRef.current.forEach((ro) => ro.disconnect());
|
|
97
|
+
itemObserversRef.current.clear();
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// 更新某个索引的高度,并修正后续偏移
|
|
102
|
+
const updateItemHeight = useCallback(
|
|
103
|
+
(index: number, newHeight: number) => {
|
|
104
|
+
if (isFixedHeight) return;
|
|
105
|
+
const pos = positions.current[index];
|
|
106
|
+
if (!pos || pos.height === newHeight) return;
|
|
107
|
+
const oldHeight = pos.height;
|
|
108
|
+
pos.height = newHeight;
|
|
109
|
+
// 修正从 index+1 开始的所有偏移量
|
|
110
|
+
let diff = newHeight - oldHeight;
|
|
111
|
+
for (let i = index + 1; i < positions.current.length; i++) {
|
|
112
|
+
const pos = positions.current[i];
|
|
113
|
+
if (pos) {
|
|
114
|
+
pos.offset += diff;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
bumpMeasure();
|
|
118
|
+
},
|
|
119
|
+
[isFixedHeight],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// 监听容器高度变化
|
|
123
|
+
useLayoutEffect(() => {
|
|
124
|
+
if (!containerRef.current) return;
|
|
125
|
+
const initialHeight = containerRef.current.clientHeight;
|
|
126
|
+
if (initialHeight > 0) {
|
|
127
|
+
setContainerHeightVal(initialHeight);
|
|
128
|
+
}
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!containerRef.current) return;
|
|
133
|
+
const observer = new ResizeObserver((entries) => {
|
|
134
|
+
if (entries.length > 0) {
|
|
135
|
+
const height = entries[0].contentRect.height;
|
|
136
|
+
setContainerHeightVal(height);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
observer.observe(containerRef.current);
|
|
140
|
+
return () => observer.disconnect();
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
// 计算当前可视区的起始/结束索引
|
|
144
|
+
const { startIndex, endIndex, totalHeight } = useMemo(() => {
|
|
145
|
+
if (data.length === 0) {
|
|
146
|
+
return { startIndex: 0, endIndex: -1, totalHeight: 0 };
|
|
147
|
+
}
|
|
148
|
+
const effectiveContainerHeight =
|
|
149
|
+
containerHeightVal > 0
|
|
150
|
+
? containerHeightVal
|
|
151
|
+
: parsedContainerHeight > 0
|
|
152
|
+
? parsedContainerHeight
|
|
153
|
+
: estimatedItemHeight * 8;
|
|
154
|
+
|
|
155
|
+
if (isFixedHeight) {
|
|
156
|
+
const itemHeight = fixedItemHeight!;
|
|
157
|
+
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
|
|
158
|
+
const visibleCount = Math.max(
|
|
159
|
+
1,
|
|
160
|
+
Math.ceil(effectiveContainerHeight / itemHeight),
|
|
161
|
+
);
|
|
162
|
+
const end = Math.min(data.length - 1, start + visibleCount + buffer);
|
|
163
|
+
const total = data.length * itemHeight;
|
|
164
|
+
return { startIndex: start, endIndex: end, totalHeight: total };
|
|
165
|
+
} else {
|
|
166
|
+
// 二分:最大 i 满足 offset[i] <= scrollTop(首项可见行的上沿)
|
|
167
|
+
const findStartIndex = (st: number) => {
|
|
168
|
+
let low = 0;
|
|
169
|
+
let high = data.length - 1;
|
|
170
|
+
let ans = 0;
|
|
171
|
+
while (low <= high) {
|
|
172
|
+
const mid = Math.floor((low + high) / 2);
|
|
173
|
+
const off = positions.current[mid]?.offset ?? 0;
|
|
174
|
+
if (off <= st) {
|
|
175
|
+
ans = mid;
|
|
176
|
+
low = mid + 1;
|
|
177
|
+
} else {
|
|
178
|
+
high = mid - 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// 返回包含当前 scrollTop 的最后一个起始 offset <= scrollTop 的元素
|
|
182
|
+
return ans;
|
|
183
|
+
};
|
|
184
|
+
const start = findStartIndex(scrollTop);
|
|
185
|
+
let end = start;
|
|
186
|
+
const startPos = positions.current[start];
|
|
187
|
+
let offsetEnd = (startPos?.offset ?? 0) + (startPos?.height ?? 0);
|
|
188
|
+
while (
|
|
189
|
+
end < data.length - 1 &&
|
|
190
|
+
offsetEnd - scrollTop < effectiveContainerHeight
|
|
191
|
+
) {
|
|
192
|
+
end++;
|
|
193
|
+
const p = positions.current[end];
|
|
194
|
+
offsetEnd = (p?.offset ?? 0) + (p?.height ?? 0);
|
|
195
|
+
}
|
|
196
|
+
// 增加缓冲区
|
|
197
|
+
const startWithBuffer = Math.max(0, start - buffer);
|
|
198
|
+
const endWithBuffer = Math.min(data.length - 1, end + buffer);
|
|
199
|
+
const total =
|
|
200
|
+
positions.current.length > 0
|
|
201
|
+
? (() => {
|
|
202
|
+
const lastPos = positions.current[positions.current.length - 1];
|
|
203
|
+
return (lastPos?.offset ?? 0) + (lastPos?.height ?? 0);
|
|
204
|
+
})()
|
|
205
|
+
: 0;
|
|
206
|
+
return {
|
|
207
|
+
startIndex: startWithBuffer,
|
|
208
|
+
endIndex: endWithBuffer,
|
|
209
|
+
totalHeight: total,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}, [
|
|
213
|
+
scrollTop,
|
|
214
|
+
containerHeightVal,
|
|
215
|
+
data.length,
|
|
216
|
+
buffer,
|
|
217
|
+
fixedItemHeight,
|
|
218
|
+
isFixedHeight,
|
|
219
|
+
measureVersion,
|
|
220
|
+
parsedContainerHeight,
|
|
221
|
+
estimatedItemHeight,
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
// 滚动事件处理(含节流)
|
|
225
|
+
const handleScroll = useCallback(
|
|
226
|
+
(e: React.UIEvent<HTMLDivElement>) => {
|
|
227
|
+
const target = e.currentTarget;
|
|
228
|
+
const newScrollTop = target.scrollTop;
|
|
229
|
+
setScrollTop(newScrollTop);
|
|
230
|
+
onScroll?.(e);
|
|
231
|
+
// 触底检测
|
|
232
|
+
const nearEnd =
|
|
233
|
+
target.scrollHeight - target.scrollTop - target.clientHeight < 5;
|
|
234
|
+
if (onReachEnd && nearEnd && !isNearEndRef.current) {
|
|
235
|
+
isNearEndRef.current = true;
|
|
236
|
+
onReachEnd();
|
|
237
|
+
} else if (!nearEnd) {
|
|
238
|
+
isNearEndRef.current = false;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
[onScroll, onReachEnd],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// 暴露方法
|
|
245
|
+
useImperativeHandle(
|
|
246
|
+
ref,
|
|
247
|
+
() => ({
|
|
248
|
+
scrollTo: (top: number) => {
|
|
249
|
+
if (!containerRef.current) return;
|
|
250
|
+
const safeTop = Math.max(0, top);
|
|
251
|
+
containerRef.current.scrollTop = safeTop;
|
|
252
|
+
setScrollTop(safeTop);
|
|
253
|
+
},
|
|
254
|
+
scrollToIndex: (
|
|
255
|
+
index: number,
|
|
256
|
+
align: "start" | "center" | "end" = "start",
|
|
257
|
+
) => {
|
|
258
|
+
if (!containerRef.current || data.length === 0) return;
|
|
259
|
+
const safeIndex = Math.min(Math.max(0, index), data.length - 1);
|
|
260
|
+
let targetTop = 0;
|
|
261
|
+
if (isFixedHeight) {
|
|
262
|
+
targetTop = safeIndex * fixedItemHeight!;
|
|
263
|
+
} else {
|
|
264
|
+
targetTop = positions.current[safeIndex]?.offset ?? 0;
|
|
265
|
+
}
|
|
266
|
+
const targetHeight = isFixedHeight
|
|
267
|
+
? fixedItemHeight!
|
|
268
|
+
: (positions.current[safeIndex]?.height ?? estimatedItemHeight);
|
|
269
|
+
if (align === "center") {
|
|
270
|
+
targetTop -= (containerHeightVal - targetHeight) / 2;
|
|
271
|
+
} else if (align === "end") {
|
|
272
|
+
targetTop -= containerHeightVal - targetHeight;
|
|
273
|
+
}
|
|
274
|
+
const safeTop = Math.max(0, targetTop);
|
|
275
|
+
containerRef.current.scrollTop = safeTop;
|
|
276
|
+
setScrollTop(safeTop);
|
|
277
|
+
},
|
|
278
|
+
getCurrentRange: () => ({ start: startIndex, end: endIndex }),
|
|
279
|
+
}),
|
|
280
|
+
[
|
|
281
|
+
startIndex,
|
|
282
|
+
endIndex,
|
|
283
|
+
containerHeightVal,
|
|
284
|
+
fixedItemHeight,
|
|
285
|
+
isFixedHeight,
|
|
286
|
+
data.length,
|
|
287
|
+
estimatedItemHeight,
|
|
288
|
+
],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// 获取唯一 key
|
|
292
|
+
const getKey = useCallback(
|
|
293
|
+
(item: T, idx: number): string => {
|
|
294
|
+
if (typeof itemKey === "function") return itemKey(item, idx);
|
|
295
|
+
return (item[itemKey] as any)?.toString() ?? `item_${idx}`;
|
|
296
|
+
},
|
|
297
|
+
[itemKey],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// 渲染可视区内的项
|
|
301
|
+
const visibleItems = useMemo(() => {
|
|
302
|
+
const items = [];
|
|
303
|
+
for (let i = startIndex; i <= endIndex && i < data.length; i++) {
|
|
304
|
+
const item = data[i];
|
|
305
|
+
if (item === undefined) continue;
|
|
306
|
+
|
|
307
|
+
const key = getKey(item, i);
|
|
308
|
+
let top = 0;
|
|
309
|
+
if (isFixedHeight) {
|
|
310
|
+
top = i * fixedItemHeight!;
|
|
311
|
+
} else {
|
|
312
|
+
top = positions.current[i]?.offset ?? 0;
|
|
313
|
+
}
|
|
314
|
+
items.push(
|
|
315
|
+
<div
|
|
316
|
+
key={key}
|
|
317
|
+
ref={(el) => {
|
|
318
|
+
if (isFixedHeight) return;
|
|
319
|
+
if (!el) {
|
|
320
|
+
const prev = itemObserversRef.current.get(key);
|
|
321
|
+
if (prev) {
|
|
322
|
+
prev.disconnect();
|
|
323
|
+
itemObserversRef.current.delete(key);
|
|
324
|
+
}
|
|
325
|
+
itemsRef.current.delete(key);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const htmlEl = el as unknown as HTMLElement;
|
|
330
|
+
itemsRef.current.set(key, htmlEl);
|
|
331
|
+
const prevObs = itemObserversRef.current.get(key);
|
|
332
|
+
if (prevObs) prevObs.disconnect();
|
|
333
|
+
const ro = new ResizeObserver((entries) => {
|
|
334
|
+
const h = entries[0]?.contentRect.height;
|
|
335
|
+
if (h && h > 0) updateItemHeight(i, h);
|
|
336
|
+
});
|
|
337
|
+
ro.observe(el);
|
|
338
|
+
itemObserversRef.current.set(key, ro);
|
|
339
|
+
|
|
340
|
+
// 测量真实高度(初次渲染或内容变化)
|
|
341
|
+
const actualHeight = htmlEl.getBoundingClientRect().height;
|
|
342
|
+
if (
|
|
343
|
+
actualHeight &&
|
|
344
|
+
positions.current[i]?.height !== actualHeight
|
|
345
|
+
) {
|
|
346
|
+
updateItemHeight(i, actualHeight);
|
|
347
|
+
}
|
|
348
|
+
}}
|
|
349
|
+
style={{
|
|
350
|
+
position: "absolute",
|
|
351
|
+
top: 0,
|
|
352
|
+
left: 0,
|
|
353
|
+
width: "100%",
|
|
354
|
+
transform: `translateY(${top}px)`,
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
{renderItem(item, i)}
|
|
358
|
+
</div>,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return items;
|
|
362
|
+
}, [
|
|
363
|
+
startIndex,
|
|
364
|
+
endIndex,
|
|
365
|
+
data,
|
|
366
|
+
getKey,
|
|
367
|
+
isFixedHeight,
|
|
368
|
+
fixedItemHeight,
|
|
369
|
+
renderItem,
|
|
370
|
+
updateItemHeight,
|
|
371
|
+
measureVersion,
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
// 容器样式
|
|
375
|
+
const containerStyle: React.CSSProperties = {
|
|
376
|
+
height: containerHeight,
|
|
377
|
+
overflowY: "auto",
|
|
378
|
+
position: "relative",
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const innerStyle: React.CSSProperties = {
|
|
382
|
+
position: "relative",
|
|
383
|
+
height: totalHeight,
|
|
384
|
+
width: "100%",
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
// 可视区域容器
|
|
389
|
+
<div ref={containerRef} style={containerStyle} onScroll={handleScroll}>
|
|
390
|
+
{/* 容器占位,高度是总列表的高度,用于撑起容器的高度,出现滚动条*/}
|
|
391
|
+
<div style={innerStyle}>{visibleItems}</div>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
);
|
package/src/index.tsx
ADDED
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ES2020",
|
|
5
|
+
"lib": ["ES2020", "DOM"],
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"jsxImportSource": "react",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"skipLibCheck": false,
|
|
13
|
+
"composite": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
16
|
+
"noEmit": false,
|
|
17
|
+
"outDir": "../../dist/virtual-list"
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*.tsx", "src/**/*.ts"],
|
|
20
|
+
"exclude": [
|
|
21
|
+
"src/**/*.test.ts",
|
|
22
|
+
"node_modules",
|
|
23
|
+
"**/*.vue",
|
|
24
|
+
"../vue-shim.d.ts",
|
|
25
|
+
"../../vue-shim.d.ts",
|
|
26
|
+
"../../node_modules/@vue",
|
|
27
|
+
"../../node_modules/vue"
|
|
28
|
+
]
|
|
29
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ES2020",
|
|
5
|
+
"lib": ["ES2020", "DOM"],
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"moduleResolution": "node",
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"emitDeclarationOnly": true,
|
|
13
|
+
"skipLibCheck": true
|
|
14
|
+
}
|
|
15
|
+
}
|