本节,我们实现一个极简版的虚拟列表,固定尺寸的虚拟列表,麻雀虽小,却是五脏俱全哦!
需求
实现一个固定尺寸的虚拟渲染列表组件,props属性如下:
props: {
width: number;
height: number;
itemCount: number;
itemSize: number;
}
使用方式:
const Row = (..args) => (<div className="Row"></div>);
<List className={"List"} width={300} height={300} itemCount={10000} itemSize={40}>
{Row}
</List>
实现
什么技术栈都可以,这里项目使用的react,那就选用react来实现。
初始化项目
使用create-react-app初始化一个应用,然后启动,清理掉demo的代码。
虚拟列表
根据上一节的分析,我们核心技术实现是 一个render渲染函数,用来渲染数据;一个onScroll函数监听滚动事件,去更新 数据区间[startIndex, endIndex],然后重新render。大概伪代码如下:
class List extends React.PureComponent {
state = {};
render() {};
onScroll() {};
}
接下来我们进行细节填充实现,首先我们需要根据数据渲染出第一屏初始化的dom,即要先实现render函数逻辑,我们采用绝对定位的方式进行dom排版。
render() {
// 从props解析属性
const {
children,
width,
height,
itemCount,
layout,
itemKey = defaultItemKey,
} = this.props;
// 预留方向设定属性
const isHorizontal = layout === "horizontal";
// 假设有一个函数_getRangeToRender可以帮我们计算出 渲染区间
const [startIndex, stopIndex] = this._getRangeToRender();
const items = [];
if (itemCount > 0) {
// 循环创建元素
for (let index = startIndex; index <= stopIndex; index++) {
items.push(
createElement(children, {
data: {},
key: itemKey(index),
index,
style: this._getItemStyle(index), // 帮助计算dom的位置样式
})
);
}
}
// 假设getEstimatedTotalSize函数可以帮助我们计算出总尺寸
const estimatedTotalSize = getEstimatedTotalSize(
this.props,
);
return createElement(
"div",
{
onScroll: this.onScroll,
style: {
position: "relative",
height,
width,
overflow: "auto",
WebkitOverflowScrolling: "touch",
willChange: "transform",
},
},
createElement("div", {
children: items,
style: {
height: isHorizontal ? "100%" : estimatedTotalSize,
pointerEvents: "none",
width: isHorizontal ? estimatedTotalSize : "100%",
},
})
);
}
OK,到了这里render函数的逻辑就写完了,是不是超级简单。接下来我们实现以下 render函数里面使用到的辅助函数.
getEstimatedTotalSize
先看getEstimatedTotalSize计算总尺寸函数的实现:
// 总尺寸 = 总个数 * 每个size
export const getEstimatedTotalSize = ({ itemCount, itemSize }) =>
itemSize * itemCount;
_getRangeToRender
计算需要渲染的数据区间函数实现
_getRangeToRender() {
// overscanCount是缓冲区的数量,默认设置1
const { itemCount, overscanCount = 1 } = this.props;
// 已经滚动的距离,初始默认0
const { scrollOffset } = this.state;
if (itemCount === 0) {
return [0, 0, 0, 0];
}
// 辅助函数,根据 滚动距离计算出 区间开始的索引
const startIndex = getStartIndexForOffset(
this.props,
scrollOffset,
);
// 辅助函数,根据 区间开始的索引计算出 区间结束的索引
const stopIndex = getStopIndexForStartIndex(
this.props,
startIndex,
scrollOffset,
);
return [
Math.max(0, startIndex - overscanCount),
Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)),
startIndex,
stopIndex,
];
}
}
// 计算区间开始索引,滚动距离 除以 每个单元尺寸 就是 startIndex
export const getStartIndexForOffset = ({ itemCount, itemSize }, offset) =>
Math.max(0, Math.min(itemCount - 1, Math.floor(offset / itemSize)));
// 计算区间结束索引,开始索引 + 可见区域size / itemSize 即可
export const getStopIndexForStartIndex = (
{ height, itemCount, itemSize, layout, width },
startIndex,
scrollOffset
) => {
const isHorizontal = layout === "horizontal";
const offset = startIndex * itemSize;
const size = isHorizontal ? width : height;
const numVisibleItems = Math.ceil((size + scrollOffset - offset) / itemSize);
return Math.max(
0,
Math.min(
itemCount - 1,
startIndex + numVisibleItems - 1
)
);
};
计算元素位置 _getItemStyle
计算方式:根据index * itemSize 即可计算出position
_getItemStyle = (index) => {
const { layout } = this.props;
let style;
const offset = index * itemSize;
const size = itemSize;
const isHorizontal = layout === "horizontal";
const offsetHorizontal = isHorizontal ? offset : 0;
style = {
position: "absolute",
left: offsetHorizontal,
top: !isHorizontal ? offset : 0,
height: !isHorizontal ? size : "100%",
width: isHorizontal ? size : "100%",
};
return style;
};
好了,到此位置,render函数的所有逻辑全部实现完毕了。
监听滚动onScroll实现
最后一步,只需要监听onScroll事件,更新 数据索引区间,我们的功能就完善了
// 非常简单,只是一个 setState操作,更新滚动距离即可
_onScrollVertical = (event) => {
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
this.setState((prevState) => {
if (prevState.scrollOffset === scrollTop) {
return null;
}
const scrollOffset = Math.max(
0,
Math.min(scrollTop, scrollHeight - clientHeight)
);
return {
scrollOffset,
};
});
};
完整代码
class List extends PureComponent {
_outerRef;
static defaultProps = {
layout: "vertical",
overscanCount: 2,
};
state = {
instance: this,
scrollDirection: "forward",
scrollOffset: 0,
};
render() {
const {
children,
width,
height,
itemCount,
layout,
itemKey = defaultItemKey,
} = this.props;
const isHorizontal = layout === "horizontal";
// 监听滚动函数
const onScroll = isHorizontal
? this._onScrollHorizontal
: this._onScrollVertical;
const [startIndex, stopIndex] = this._getRangeToRender();
const items = [];
if (itemCount > 0) {
for (let index = startIndex; index <= stopIndex; index++) {
items.push(
createElement(children, {
data: {},
key: itemKey(index),
index,
style: this._getItemStyle(index),
})
);
}
}
const estimatedTotalSize = getEstimatedTotalSize(
this.props
);
return createElement(
"div",
{
onScroll,
style: {
position: "relative",
height,
width,
overflow: "auto",
WebkitOverflowScrolling: "touch",
willChange: "transform",
},
},
createElement("div", {
children: items,
style: {
height: isHorizontal ? "100%" : estimatedTotalSize,
pointerEvents: "none",
width: isHorizontal ? estimatedTotalSize : "100%",
},
})
);
}
_onScrollHorizontal = (event) => {};
_onScrollVertical = (event) => {
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
this.setState((prevState) => {
if (prevState.scrollOffset === scrollTop) {
return null;
}
const scrollOffset = Math.max(
0,
Math.min(scrollTop, scrollHeight - clientHeight)
);
return {
scrollOffset,
};
});
};
_getItemStyle = (index) => {
const { layout } = this.props;
let style;
const offset = getItemOffset(this.props, index, this._instanceProps);
const size = getItemSize(this.props, index, this._instanceProps);
const isHorizontal = layout === "horizontal";
const offsetHorizontal = isHorizontal ? offset : 0;
style = {
position: "absolute",
left: offsetHorizontal,
top: !isHorizontal ? offset : 0,
height: !isHorizontal ? size : "100%",
width: isHorizontal ? size : "100%",
};
return style;
};
// 计算出需要渲染的数据索引区间
_getRangeToRender() {
const { itemCount, overscanCount = 1 } = this.props;
const { scrollOffset } = this.state;
if (itemCount === 0) {
return [0, 0, 0, 0];
}
const startIndex = getStartIndexForOffset(
this.props,
scrollOffset
);
const stopIndex = getStopIndexForStartIndex(
this.props,
startIndex,
scrollOffset
);
return [
Math.max(0, startIndex - overscanCount),
Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)),
startIndex,
stopIndex,
];
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。