工作中一直有接触大量数据渲染的业务,使用react-window多之又多,所以对虚拟列表有了些浅显的理解。今天,我们就照着react-window的使用方式来实现三种虚拟列表。
我们先来看一下整个虚拟列表元素的表现。
看右边的元素个数,会发现起初只有6个,之后无论怎么滚动,他都保持着8个元素,由此我们可以得出他的静态原理图是这样的。
当我们进行了滚动后。
static-height-scroll.png从上面两图我们可以总结出,整个虚拟列表划分为三个区域,分别是上缓冲区(0/2个元素),可视区(n个元素),下缓冲区(2个元素)。当我们滚动到一个元素离开可视区范围内时,就去掉上缓冲区顶上的一个元素,然后再下缓冲区增加一个元素。这就是虚拟列表的核心原理了。
const Row = ({ index, style, forwardRef }) => {
return (
<div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} ref={forwardRef}>
{`Row ${index}`}
</div>
)
}
const App = () => {
return (
<FixedSizeList
className="list"
height={200}
width={200}
itemSize={50}
itemCount={1000}
>
{Row}
</FixedSizeList>
);
}
(1)首先先计算出由1000个元素撑起的盒子(称之为container)的高度,撑开盒子,让用户能进行滚动操作。
(2)计算出可视区的起始索引、上缓冲区的起始索引以及下缓冲区的结束索引(就像上图滚动后,上缓冲区的起始索引为2,可视区起始索引为4,下缓冲区结束索引为9)。
(3)采用绝对定位,计算上缓冲区到下缓冲区之间的每一个元素在contianer中的top值,只有知道top值才能让元素出现在可视区内。
static-height-scroll-over.png(4)将上缓冲区到下缓冲区的元素塞到container中。
import { useState } from 'react';
const FixedSizeList = (props) => {
const { height, width, itemSize, itemCount, children: Child } = props;
// 记录滚动掉的高度
const [scrollOffset, setScrollOffset] = useState(0);
// 外部容器高度
const containerStyle = {
position: 'relative',
width,
height,
overflow: 'auto',
};
// 1000个元素撑起盒子的实际高度
const contentStyle = {
height: itemSize * itemCount,
width: '100%',
};
const getCurrentChildren = () => {
// 可视区起始索引
const startIndex = Math.floor(scrollOffset / itemSize);
// 上缓冲区起始索引
const finialStartIndex = Math.max(0, startIndex - 2);
// 可视区能展示的元素的最大个数
const numVisible = Math.ceil(height / itemSize);
// 下缓冲区结束索引
const endIndex = Math.min(itemCount, startIndex + numVisible + 2);
const items = [];
// 根据上面计算的索引值,不断添加元素给container
for (let i = finialStartIndex; i < endIndex; i++) {
const itemStyle = {
position: 'absolute',
height: itemSize,
width: '100%',
// 计算每个元素在container中的top值
top: itemSize * i,
};
items.push(
<Child key={i} index={i} style={itemStyle} />
);
}
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 FixedSizeList;
动画.gif
const rowSizes = new Array(1000).fill(true).map(() => 25 + Math.round(Math.random() * 55))
const getItemSize = (index) => rowSizes[index];
const Row = ({ index, style }) => {
return (
<div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} >
Row {index}
</div>
)
}
const App = () => {
return (
<VariableSizeList
className="list"
height={200}
width={200}
itemSize={getItemSize}
itemCount={1000}
>
{Row}
</VariableSizeList>
);
}
从代码可以看出,Row每一个高度都是随机的,就不能像第一种虚拟列表那样简单得通过itemSize * index计算出top值了。
由于每个元素高度不一,我们起先无法直接计算出container的总高度。
每个元素高度不一,每个元素的top值不能通过itemSize * index直接计算出top值。
每个元素高度不一,不能直接通过scrollOffset / itemSize计算出已被滚动掉的元素的个数,很难获取到可视区的起始索引。
可以通过遍历所有的Row计算出总高度,但我认为计算出精确总高度的必要性不大,同时也为了兼容第三种虚拟列表,我们不去计算精确的总高度。现在我们回到出发点,思考container的高度的作用是什么?其实就是为了足够大,让用户能进行滚动操作,那我们可以自己假设每一个元素的高度,在乘上个数,弄出一个假的但足够高的container让用户去触发滚动事件。当然这种方案会带来一些小bug(这个bug的影响不大,我认为是可以忽略的)。
其实难点二和难点三本质都一样,元素高度不一,导致不知道被滚动掉了多少元素,只要知道被滚动掉的元素的个数,top值和索引都迎刃而解。
我们可以采用这种解决方案,那就是每次只需要计算上缓冲区到下缓冲区之间的元素,并记录他们,并且记录下最底下的那个元素的索引,当用户进行滚动时,如果我们是向上滚动,就可以直接从已经计算好的记录里取,如果向下滚动,我们根据上一次记录的最大的索引的那个元素不断累加新元素的高度,直到它大于已经滚动掉的高度,此时的索引值就是可视区的起始索引了,这个起始索引所对应的top就是累加的高度。
文字看起来生硬拗口,我们可以看下面这张图。
每一个元素的top值都能通过上一个元素的top值 + 上一个元素的height计算出来。
举个例子,假设我们需要知道item14的top值
(1)我们先在记录里找有没有item13的数据,如果有,我们就拿item13.top + item13.heighht得到item14的top。
(2)如果记录中(由上图得知我们只记录了item1-item10的数据)没有,我们就拿到记录中最后一个元素的数据(item10)进行累加,先计算并记录item11的,再计算并记录item12的,再计算并记录item13的,最后就是item14的了。
import { 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 } = 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(i);
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,
];
};
const VariableSizeList = (props) => {
const { height, width, itemCount, itemEstimatedSize, children: Child } = props;
const [scrollOffset, setScrollOffset] = useState(0);
const containerStyle = {
position: 'relative',
width,
height,
overflow: 'auto',
willChange: 'transform'
};
const contentStyle = {
height: estimatedHeight(itemEstimatedSize, itemCount),
width: '100%',
};
const getCurrentChildren = () => {
const [startIndex, endIndex, originStartIndex, originEndIndex] = 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(
<Child key={i} index={i} style={itemStyle} />
);
}
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;
难点的地方都给了注释,如果一遍看不懂的话,可以去调试调试。
以上代码主要写了个思路和功能,其实优化点是很多的,这里给出两个显而易见的优化点。
结果还是挺满意的了,这里提一下上文提到的小bug,那就是在向下拉动滚动条时,鼠标和滚动条时脱节的。
最后这一种虚拟列表其实就是基于第二种来实现的,只不过增加监听元素高度变化事件,在某个元素发生变化的时候重新计算各种数据。
const items = [];
const itemCount = 1000;
for (let i = 0; i < itemCount; i++) {
const height = (30 + Math.floor(Math.random() * 30));
const style = {
height,
width: '100%',
}
items.push(
<div className={i % 2 ? 'list-item-odd' : 'list-item-even'} style={style}>Row {i}</div>
)
}
const Row = ({ index }) => items[index];
const App = () => {
// 注意:这里我没有把itemSize传过去
return (
<VariableSizeList
className="list"
height={200}
width={200}
itemCount={itemCount}
isDynamic
>
{Row}
</VariableSizeList>
);
}
从上面代码可以看出,我们没将itemSize传过去,虚拟列表是不知道每一个元素的高度的,只有在渲染的时候执行了Row才知道。
在上面那种虚拟列表进行改动
// 修改getCurrentChildren函数
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;
}
// 增加sizeChangeHandle
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({});
}
// 增加一个ListItem组件
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>
)
}
}
完整代码
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;
react-window只有前两种虚拟列表,最后一种虚拟列表是在别的虚拟列表库中有,借鉴了一下各路大佬的思路实现的,总得来说三种虚拟列表虽然表现和实现都不同,但只要掌握了核心原理,手撸出来虚拟列表还是手到擒来的。
最后,希望这篇文章能帮助到各位读者。同时也非常欢迎各位大佬对上面的各种实现提出建议,也希望各位大佬对于第二种虚拟列表提出更多的优化点。
本文由微信公众号奇舞精选原创,哈喽比特收录。
文章来源:https://mp.weixin.qq.com/s/i8Fw-7imXTcIQUEm45vqLQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。