theme: channing-cyan
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
hello,小伙伴们好呀,我是小羽同学
~
性能优化
系列文章又更新啦。
小伙伴们在面试的时候,是不是会碰到面试官
这样子刁难
你呢?
面试官:如果现在有一个这样子的需求,让你
渲染一个长列表
,这个列表的数据量很大
,可能会有上万条
的数据,如果直接渲染那么多得dom节点
会导致页面的卡顿
,这时候你会怎么处理这个问题呢?你:easy,做下分页就可以了呀
面试官:如果咱们现在不允许
分页
呢?想实现的是手机端的淘宝商品列表那种效果。你:哦,那咱们可以使用
分页请求
+模块化的懒渲染
呀(ps:模块化懒渲染
其实就是上篇文章中的模块化懒加载
的改版,具体可以看这篇文章——《原来懒加载有这些玩法,你确定不看看?》)面试官:嗯,这种方案主要是将
卡顿
的时间延后
了,它确实可以处理列表初始化渲染
的卡顿
的问题,但是注意哦,咱们讨论的上万条数据
的场景,当数据量上来后,还是会卡顿的。你:这样子呀,那咱们可以使用
虚拟滚动
(虚拟列表)面试官:那你可以说下,虚拟滚动的
原理
吗?以及如何避免/减少
虚拟滚动快速滚动
时的白屏
问题?你:emmm,这个我不懂,我都是直接用第三方库的。
面试官:好的,你的情况我这边都已经了解了,可以先回去了,如果有进展的话咱们尽快会联系你。
等你走后,面试官:只会调用库的
渣渣
,随便换个前端来都可以。没有一点核心竞争力
,pass!!!
虚拟滚动是什么
虚拟滚动
,又称虚拟列表
。简单点来说,就是只渲染可视区域
的列表项
,非可视区域的列表项则直接不渲染,当咱们的列表滚动到对应位置的时候才将对应的列表项渲染出来。它通常应用在那些需要渲染大量数据
的场景中,如商品列表。
这就是虚拟滚动的基本概念
和应用场景
,那对于虚拟滚动的话,目前市面上基本上实现的都是定高的虚拟滚动
,对于不定高的虚拟滚动
却好像还没有太多的解决方案。
定高的虚拟滚动
那咱们先来说说定高的虚拟滚动
怎么实现的吧。
假设目前咱们有1w条数据
, 每个列表项的高度是100
,咱可视区域的高度为500
,那么咱们就只会显示5条数据
,当咱们滚动到scrollTop
为15000
的时候,50000/500 = 100
,即从100开始往后渲染5条数据即可。
如下图:
小伙伴们还记得面试官除了问过虚拟滚动是如何实现外,还问过咱们如何避免或者减少
虚拟滚动在快速滚动时候的白屏
问题吗?
对于这个问题,咱们通常会预留2-3页
的缓冲页面
,即多渲染一些在可视区域外
的数据,从而减少
这种白屏的问题。
然后小羽看到知乎
上有一个不错的方案,主要是分页
+缓存页面
,这样子的虚拟滚动的会更好,这里也是直接使用代码做一些小的调整。
import { createRef, useEffect, useState } from 'react';
import { getData } from '@/helper';
import './index.less';
export default function LimitVirtualList() {
// 已知item高度、需要与item css高度保持一致
const itemHeight = 100;
const pageSize = 10;
// 原始数据源
const [dataSource, setDataSource] = useState([]);
const scrollRef = createRef<HTMLDivElement>();
const [totalCount, setTotalCount] = useState(0);
const [beforeCount, setBeforeCount] = useState(0);
const [pageNum, setPageNum] = useState(1);
// 真正渲染的数据
const [showDataSource, setShowDataSource] = useState([]);
useEffect(() => {
init();
}, []);
useEffect(() => {
sliceShowDataSource();
}, [pageNum, dataSource.length]);
// 数据初始化
const init = () => {
setDataSource(getData());
};
// 获取需要展示的数据
const sliceShowDataSource = () => {
const { showDataSource, beforeCount, totalCount } = getRenderData({
pageNum: pageNum,
pageSize: pageSize,
dataSource: dataSource,
});
setShowDataSource(showDataSource);
setBeforeCount(beforeCount);
setTotalCount(totalCount);
};
// 获取最大页数
const getMaxPageNum = () => {
return getPageNum({
scrollTop:
scrollRef.current.scrollHeight - scrollRef.current.clientHeight,
pageSize: pageSize,
itemHeight: itemHeight,
});
};
// 1、监听用户scroll事件
// 2、实时计算页码
// 3、如果页码发生改变,进行数据切片,重新渲染数据
// 4、如果页码没有发生改变,保持不动
const onScroll = () => {
const maxPageNum = getMaxPageNum();
const scrollPageNum = getPageNum({
scrollTop: scrollRef.current.scrollTop,
pageSize: pageSize,
itemHeight: itemHeight,
});
const currPageNum = Math.min(scrollPageNum, maxPageNum);
// 如果当前页数保持不变
if (currPageNum === pageNum) return;
setPageNum(currPageNum);
};
// 计算分页
const getPageNum = ({ scrollTop, pageSize, itemHeight }) => {
const pageHeight = pageSize * itemHeight;
return Math.max(Math.floor(scrollTop / pageHeight), 1);
};
// 数据切片
const getRenderData = ({ pageNum, pageSize, dataSource }) => {
const startIndex = (pageNum - 1) * pageSize;
// 这里+2:想要保证顺畅的滑动,快速滑动不白屏,需要至少预留3页数据,前+中+后
const endIndex = Math.min((pageNum + 2) * pageSize, dataSource.length);
return {
showDataSource: dataSource.slice(startIndex, endIndex),
// 前置数量
beforeCount: startIndex,
totalCount: dataSource.length,
};
};
return (
<div className='limit-virtual-list'>
{/* 容器层:固定高度 */}
<div className='scroll' ref={scrollRef} onScroll={onScroll}>
{/* 滚动层:实际滚动区域高度 */}
<div style={{ height: `${totalCount * itemHeight}px` }}>
{/* 通过translateY撑起滚动条 */}
<div
className='inner'
style={{ transform: `translateY(${beforeCount * itemHeight}px)` }}>
{/* 列表层:实际渲染的数据 */}
{showDataSource.map((item, index) => {
return (
<div className='scroll-item' key={index}>
<p>{item.words}</p>
<p>{item.content}</p>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
.limit-virtual-list{
.scroll {
height: calc(~'100vh - 80px');
width: 100%;
overflow: auto;
background: #fff;
padding: 20px;
}
.scroll-item {
height: 80px;
margin-bottom: 20px;
padding: 0;
border: 1px solid #eee;
}
}
效果如下图,这里是固定每屏显示10条
,并且缓存前后两页
,即一共会显示30个dom
节点:
通过chrome performance查看普通列表和虚拟滚动的性能,在js生成2w条数据
并渲染
的情况下,普通列表
需要接近5s
的时间,而虚拟滚动
仅需要500ms
。
不定高的虚拟滚动
OK,咱们前面简单的介绍了一下定高的虚拟滚动
,并且使用了知乎上的代码来实现了demo。
定高的虚拟滚动的原理其实很简单,就是直接展示(滚动的高度/每项的高度) *展示的数量
即可。
但是现在问题来了,咱们的产品经理
提需求了,每项的高度由数据自动撑开
,不要那种固定高度的。那这样的话,咱们原来得那套定高的虚拟滚动方案就无法使用了啊。
没关系,小羽
会出手滴!
对于定高的虚拟滚动的话,咱们由于可以计算出对应高度
,然后直接显示对应的item节点
。那对于不定高的话,咱们是不是可以换一个思路
呢?
咱们可以记录每一个item的高度
,缓存到对象
中,当咱们滚动的时候只需要寻找对应的高度即可。
但是item的高度怎么获取
呢?从后端直接拿?
虽然可行,但是这样子的话每个item的高度和宽度都是不可变化的,对屏幕的适配
来说是十分不友好的。
那还有什么办法呢?
小羽肝了很久,得到如下的一种不定高的虚拟滚动
的方案
。
首先咱们预估一下每个item的defaultHeight
(默认高度,如果没有这个高度的话,会导致滚动条只有一点点,看起来会很别扭),滚动到对应位置的时候,渲染
并将高度记录
到咱们的cache对象
中。随着滚动条的滚动,咱们会陆续更新
每一个item的高度,使得滚动条的高度越来越真实
。
图解如下:
具体实现如下,不确定的高度目前是通过生成一个随机高度
的空白div
来撑开item的高度,如果有图片这种异步获取
的资源
,建议固定
图片的宽高,然后利用object-fit
来处理图片被拉伸的问题。
import { useEffect, useRef, useState } from 'react';
import Item from './item';
const VirtualScroll = ({
list,
containerHeight,
defaultHeight = 100,
bufferSize = 20,
children,
}) => {
const [state, setState] = useState({
startOffset: 0,
endOffset: 0,
visibleData: [],
});
const [anchorItem, setAnchorItem] = useState({
index: 0, // 锚点元素的索引值
top: 0, // 锚点元素的顶部距离第一个元素的顶部的偏移量(即 startOffset)
bottom: 0, // 锚点元素的底部距离第一个元素的顶部的偏移量
});
const scrollDataRef = useRef({
startIndex: 0,
endIndex: 0,
scrollTop: 0,
});
const cacheRef = useRef({});
const visibleCountRef = useRef(0);
const containerRef = useRef(null);
const wrapperRef = useRef(null);
const anchorItemRef = useRef({
index: 0,
top: 0,
bottom: 0,
});
const changeState = (data) => {
const newState = {
...state,
...data,
};
setState(newState);
};
// 更新锚点位置
const changeAnchorItem = (data) => {
const newAnchorItem = {
...anchorItem,
...data,
};
anchorItemRef.current = newAnchorItem;
setAnchorItem(newAnchorItem);
};
// 缓存位置数据
const cachePosition = (node, index) => {
const rect = node.getBoundingClientRect();
const wrapperRect = wrapperRef.current.getBoundingClientRect();
const top = rect.top - wrapperRect.top;
cacheRef.current[index] = {
index,
top,
bottom: top + rect.height,
};
};
// 处理滚动事件
const handleScroll = (e) => {
const curScrollTop = containerRef.current.scrollTop;
if (
(curScrollTop > scrollDataRef.current.scrollTop &&
curScrollTop > anchorItemRef.current.bottom) ||
(curScrollTop < scrollDataRef.current.scrollTop &&
curScrollTop < anchorItemRef.current.top)
) {
updateBoundaryIndex(curScrollTop);
updateVisibleData();
}
scrollDataRef.current.scrollTop = curScrollTop;
};
// 计算 startIndex 和 endIndex
const updateBoundaryIndex = (scrollTop) => {
scrollTop = scrollTop || 0;
// 用户正常滚动下,根据 scrollTop 找到新的锚点元素位置
const newAnchorItem =
cacheRef.current[
Object.keys(cacheRef.current)?.find(
(key) => cacheRef.current[key].bottom >= scrollTop,
)
];
if (!newAnchorItem) {
return;
}
changeAnchorItem({ ...newAnchorItem });
scrollDataRef.current.startIndex = newAnchorItem.index;
scrollDataRef.current.endIndex =
newAnchorItem.index + visibleCountRef.current;
};
// 更新渲染的数据
const updateVisibleData = () => {
const visibleData = list.slice(
scrollDataRef.current.startIndex,
scrollDataRef.current.endIndex,
);
changeState({
startOffset: anchorItemRef.current.top,
endOffset: (list.length - scrollDataRef.current.endIndex) * defaultHeight,
visibleData,
});
};
useEffect(() => {
const containerRect = containerRef.current.getBoundingClientRect();
// 计算可渲染的元素个数
visibleCountRef.current =
Math.ceil(containerRect.height / defaultHeight) + bufferSize;
scrollDataRef.current.endIndex =
scrollDataRef.current.startIndex + visibleCountRef.current;
updateVisibleData();
list.map((item, index) => {
cacheRef.current[index] = {
index: index,
top: index * defaultHeight,
bottom: index * defaultHeight + defaultHeight,
};
});
containerRef.current.addEventListener('scroll', handleScroll, false);
return () => {
containerRef.current?.removeEventListener('scroll', handleScroll, false);
};
}, []);
const { startOffset, endOffset, visibleData } = state;
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
ref={containerRef}>
<div className='wrapper' ref={wrapperRef}>
<div
style={{
paddingTop: `${startOffset}px`,
paddingBottom: `${endOffset}px`,
}}>
{visibleData.map((item, index) => {
return (
<Item
cachePosition={cachePosition}
key={scrollDataRef.current.startIndex + index}
item={item}
index={scrollDataRef.current.startIndex + index}>
{children}
</Item>
);
})}
</div>
</div>
</div>
);
};
export default VirtualScroll;
使用方式如下:
import React, { useState, useEffect } from 'react';
import { getData } from '@/helper';
import VirtualScroll from '@/components/VirtualScroll';
import './index.less';
export default function NotLimitVirtualList() {
const [list, setList] = useState([]);
const init = () => {
setList(getData());
};
useEffect(() => {
init();
}, []);
return (
<>
{list.length > 0 && (
<VirtualScroll list={list} containerHeight={'100%'}>
{(item) => (
<div className='list-item'>
<p>{item.words}</p>
<p>{item.content}</p>
<div style={{ height: item.height }}></div>
</div>
)}
</VirtualScroll>
)}
</>
);
}
效果如下:
小结
本文主要以一个常见的面试场景,引入并介绍了虚拟滚动
的概念
、应用场景
以及如何优化虚拟滚动中的白屏问题
,然后使用知乎上的代码实现了定高的虚拟滚动
demo,接着介绍了如何使用cache
实现一个不定高的虚拟滚动
。希望可以在小伙伴们日常的开发
和面试
中,可以提供到一些帮助
。
如果看这篇文章后,感觉有收获的小伙伴们可以点赞
+收藏
哦~
如果想和小羽
交流技术可以加下
暂无评论内容