问题:
- 固定高度的虚拟列表实现思路?
- 最外层盒子设置overflow:auto,relative定位,
- 内层盒子根据高度计算出总高度,
- 子元素根据滚动条高度计算startIndex以及endIndex,从而截取可视区域数据,
- 设置marginTop或top属性性将数据展示在可视区域;
- 不固定高度的虚拟列表实现思路?
item固定高度情况
typescript
import React, { useState, useEffect } from 'react';
interface VirtualItemProps {
style: React.CSSProperties;
children: React.ReactNode;
}
const VirtualItem: React.FC<VirtualItemProps> = ({ style, children }) => (
<div style={style}>{children}</div>
);
interface VirtualListProps {
height: number;
itemSize: number;
children: React.ReactNode;
}
const VirtualList: React.FC<VirtualListProps> = (props) => {
const { children, height, itemSize } = props;
const contentBoxStyle: React.CSSProperties = {
height: itemSize * React.Children.count(children),
};
const [renderItems, setRenderItems] = useState<React.ReactNode[]>([]);
function handleScroll(e) {
console.log(e);
const { scrollTop, offsetHeight } = e.currentTarget;
const safeTop = scrollTop - offsetHeight > 0 ? scrollTop - offsetHeight : 0;
const safeBottom =
safeTop + offsetHeight * 3 >= (contentBoxStyle.height as number)
? contentBoxStyle.height
: safeTop + offsetHeight * 3;
const startIndex = Math.floor(safeTop / itemSize);
const endIndex = Math.floor((safeBottom as number) / itemSize);
console.log(startIndex, endIndex);
setRenderItems(() =>
React.Children.toArray(children)
.slice(startIndex, endIndex)
.map((child, index) => {
console.log('child', child);
const originalStyle = {
...((child as React.ReactElement)?.props?.style || {}),
};
return (
<VirtualItem
key={(child as React.ReactElement).key}
style={{
...originalStyle,
position: 'absolute',
top: itemSize * (startIndex + index),
}}
>
{(child as React.ReactElement).props.children}
</VirtualItem>
);
}),
);
}
useEffect(() => {
setRenderItems(() =>
React.Children.toArray(children)
.slice(0, (height as number) / itemSize * 2)
.map((child, index) => {
console.log('child', child);
const originalStyle = {
...((child as React.ReactElement)?.props?.style || {}),
};
return (
<VirtualItem
key={(child as React.ReactElement).key}
style={{
...originalStyle,
position: 'absolute',
top: itemSize * index,
}}
>
{(child as React.ReactElement).props.children}
</VirtualItem>
);
}),
);
}, [])
console.log('children', children);
return (
<div
onScroll={handleScroll}
style={{ height: height + 'px', overflowY: 'auto', position: 'relative' }}
>
<div style={contentBoxStyle}>{renderItems.map((item) => item)}</div>
</div>
);
};
export default VirtualList;
/**
*
* 1. 盒子固定高度
* 2. 盒子不固定高度;
* 3. 子元素高度也不固定
* 4. 考虑margin, gap间距问题
* 5. 添加gap参数,避免用户自行设置间距样式(最后考虑)
*/
使用实例
typescript
<VirtualList height={200} itemSize={50}>
{list.map((_, item) => {
return (
<div
key={item + 'aaaa'}
style={{
width: '100%',
height: '48px',
background: '#f8f8f8',
marginBottom: '2px',
borderRadius: '3px',
lineHeight: '32px',
color: '#ccc',
padding: '0 30px',
}}
>
<div>{`${item}. ${_.desc}`}</div>
</div>
);
})}
</VirtualList>
高度不固定
javascript
import React, { useState } from 'react';
// 元数据
const measuredData = {
measuredDataMap: {},
lastMeasuredItemIndex: -1,
};
const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
let measuredHeight = 0;
const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
// 计算已经获取过真实高度的项的高度之和
if (lastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
}
// 未计算过真实高度的项数
const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;
// 预测总高度
const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
return totalEstimatedHeight;
}
const getItemMetaData = (props, index) => {
const { itemSize, itemEstimatedSize = 50 } = props;
const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
// 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
if (index > lastMeasuredItemIndex) {
let offset = 0;
// 计算当前能计算出来的最大offset值
if (lastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
offset += lastMeasuredItem.offset + lastMeasuredItem.size;
}
// 计算直到index为止,所有未计算过的项
for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
const currentItemSize = itemSize ? itemSize(i) : itemEstimatedSize;
measuredDataMap[i] = { size: currentItemSize, offset };
offset += currentItemSize;
}
// 更新已计算的项的索引值
measuredData.lastMeasuredItemIndex = index;
}
return measuredDataMap[index];
};
const getStartIndex = (props, scrollOffset) => {
const { itemCount } = props;
let index = 0;
while (true) {
const currentOffset = getItemMetaData(props, index).offset;
if (currentOffset >= scrollOffset) return index;
if (index >= itemCount) return itemCount;
index++
}
}
const getEndIndex = (props, startIndex) => {
const { height, itemCount } = props;
// 获取可视区内开始的项
const startItem = getItemMetaData(props, startIndex);
// 可视区内最大的offset值
const maxOffset = startItem.offset + height;
// 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
let offset = startItem.offset + startItem.size;
// 结束索引
let endIndex = startIndex;
// 累加offset
while (offset <= maxOffset && endIndex < (itemCount - 1)) {
endIndex++;
const currentItem = getItemMetaData(props, endIndex);
offset += currentItem.size;
}
return endIndex;
};
const getRangeToRender = (props, scrollOffset) => {
const { itemCount } = props;
const startIndex = getStartIndex(props, scrollOffset);
const endIndex = getEndIndex(props, startIndex);
return [
Math.max(0, startIndex - 2),
Math.min(itemCount - 1, endIndex + 2),
startIndex,
endIndex,
];
};
class ListItem extends React.Component {
constructor(props) {
super(props);
this.domRef = React.createRef();
this.resizeObserver = null;
}
componentDidMount() {
if (this.domRef.current) {
const domNode = this.domRef.current.firstChild;
const { index, onSizeChange } = this.props;
this.resizeObserver = new ResizeObserver(() => {
onSizeChange(index, domNode);
});
this.resizeObserver.observe(domNode);
}
}
componentWillUnmount() {
if (this.resizeObserver && this.domRef.current.firstChild) {
this.resizeObserver.unobserve(this.domRef.current.firstChild);
}
}
render() {
const { index, style, ComponentType } = this.props;
return (
<div style={style} ref={this.domRef}>
<ComponentType index={index} />
</div>
)
}
}
const VariableSizeList = (props) => {
const { height, width, itemCount, itemEstimatedSize = 50, children: Child } = props;
const [scrollOffset, setScrollOffset] = useState(0);
const [, setState] = useState({});
const containerStyle = {
position: 'relative',
width,
height,
overflow: 'auto',
willChange: 'transform'
};
const contentStyle = {
height: estimatedHeight(itemEstimatedSize, itemCount),
width: '100%',
};
const sizeChangeHandle = (index, domNode) => {
const height = domNode.offsetHeight;
const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
const itemMetaData = measuredDataMap[index];
itemMetaData.size = height;
let offset = 0;
for (let i = 0; i <= lastMeasuredItemIndex; i++) {
const itemMetaData = measuredDataMap[i];
itemMetaData.offset = offset;
offset += itemMetaData.size;
}
setState({});
}
const getCurrentChildren = () => {
const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
const items = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = getItemMetaData(props, i);
const itemStyle = {
position: 'absolute',
height: item.size,
width: '100%',
top: item.offset,
};
items.push(
<ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />
);
}
return items;
}
const scrollHandle = (event) => {
const { scrollTop } = event.currentTarget;
setScrollOffset(scrollTop);
}
return (
<div style={containerStyle} onScroll={scrollHandle}>
<div style={contentStyle}>
{getCurrentChildren()}
</div>
</div>
);
};
export default VariableSizeList;
存在问题:
- 滚动时,滚动条和鼠标拖动的距离不一致,由于初始化高度为默认固定高度*itemCount,后期滚动条滚动时,计算当前偏移量,实际高度与默认高度不一致,所以滚动条会产生偏移;