分析
为了提升性能,不能一次性把大量数据直接渲染到页面上,所以就需要一个机制来实现一个虚拟的列表,只渲染用户可视区域看到内容
- 下拉到底,继续加载数据并拼接
- 渲染的数据只是用户看到的内容
虚拟列表
虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
如下图所示
核心变量
- 通过容器高度和每一条的高度计算视口应该渲染的可以看到的条数(visibleCount)
- 计算当前可视区域起始索引位置(start)
- 计算当前可视区域结束索引位置(end)
- 计算当前可视区域的数据,并渲染到页面中 (visibleData)
- 计算 startIndex 对应的数据在整个列表中的偏移位置 startOffset 并设置到列表上 (offset)
1const visibleCount = Math.ceil(containerHeight / itemHeight);23const start = Math.floor(scrollTop / itemHeight);45const end = start + visibleCount;67const visibleData = data.slice(start, Math.min(end, data.length));89const offset = scrollTop - (scrollTop % itemHeight);
无限滚动
滚动时要设置数据的视口,即通过设置 star 的方式间接地设置数据的滑动窗口 当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量 offset,通过样式控制将渲染区域偏移至可视区域中。
实现
1import "./styles.css";2import Faker from "faker";3import { useState, useRef, useEffect, useMemo, useCallback } from "react";45const itemHeight = 100;6const total = 1000000;78export default function App() {9 const ref = useRef();1011 // 可视区域高度12 const containerHeight = document.body.clientHeight;13 // 可显示的列表项数14 const visibleCount = Math.ceil(containerHeight / itemHeight);1516 const [listData, setListData] = useState([]);17 // 偏移量18 const [startOffset, setStartOffset] = useState(0);19 // 起始索引20 const [start, setStart] = useState(0);21 // 结束索引22 const end = start + visibleCount;2324 // 列表总高度25 const listHeight = useMemo(() => {26 return listData.length * itemHeight;27 }, [listData]);2829 // 获取真实显示列表数据30 const visibleData = useMemo(() => {31 return listData.slice(start, Math.min(end, listData.length));32 }, [listData, start, end]);3334 //加载随机数据35 const getTenListData = useCallback(() => {36 if (listData.length >= total) {37 return [];38 }39 return new Array(10).fill({}).map((item) => ({40 id: Faker.random.uuid(),41 avatar: Faker.image.avatar(),42 title: Faker.name.firstName(),43 content: Faker.company.companyName(),44 }));45 }, [listData]);4647 useEffect(() => {48 const data = getTenListData();49 setListData(data);50 }, []);5152 const scrollToTop = () => {53 ref.current.scrollTo({54 top: 0,55 left: 0,56 behavior: "smooth",57 });58 };5960 const scrollEvent = useCallback(61 (e) => {62 // 当前滚动位置63 const scrollTop = ref.current.scrollTop;64 // 此时的开始索引65 const start = Math.floor(scrollTop / itemHeight);66 const end = start + visibleCount;67 setStart(start);68 if (end >= listData.length) {69 const data = listData.concat(getTenListData());70 setListData(data);71 }72 // 此时的偏移量73 const offset = scrollTop;74 setStartOffset(offset);75 },76 [listData, getTenListData, visibleCount]77 );7879 useEffect(() => {80 let dom = ref.current;81 scrollEvent();82 if (dom) {83 dom.addEventListener("scroll", scrollEvent);84 }85 return () => {86 if (dom) {87 dom.removeEventListener("scroll", scrollEvent);88 }89 };90 }, [scrollEvent]);9192 return (93 <div className="infinite-list-container" ref={ref}>94 <div className="scrollTopBtn" onClick={scrollToTop}>95 ∧96 </div>9798 <div99 className="infinite-list-phantom"100 style={{ height: Math.max(listHeight, containerHeight + 1) }}101 />102 <div103 className="infinite-list"104 style={{ transform: `translate3d(0,${startOffset}px,0)` }}105 >106 {visibleData.map((item) => (107 <div108 className="infinite-list-item"109 key={item.id}110 style={{ height: itemHeight }}111 >112 <div113 className="left-section"114 style={{ backgroundImage: `url(${item.avatar})` }}115 ></div>116 <div className="right-section">117 <div className="title">{item.title}</div>118 <div className="desc">{item.content}</div>119 </div>120 </div>121 ))}122 </div>123 </div>124 );125}