前端常见的需求场景是以列表的形式干净高效的承载文字、列表、图片、段落等,如果列表长度过长,将会引起首屏打开时间长 、滑动卡顿等现象
针对此情况我决定采用懒加载数据方案(也称分页加载)去解决列表过长时出现的问题,以下是我的解决思路、代码、实测数据结果
监听滚动条即将触底
通过监听【可滚动区域的 DOM】的 scrollTop + clientHeight 的值与 scrollHeight 的值接近来判断滚动条即将触底
获取可滚动区域的 DOM:overflowBoxDom(以下所有代码经过精简,详细参考代码仓库)
html
import { useRef } from 'react';
const overflowBoxRef = useRef(null);
const [overflowBoxDom, setOverflowBoxDom] = useState<HTMLDivElement | null>(null);
useEffect(() => {
setOverflowBoxDom(overflowBoxRef.current);
}, []);
<div className={'overflow-box'} ref={overflowBoxRef}>
{heroInfoList.map((item, index) => (
<HeroInfo key={index} info={item} />
))}
<div className={styles['loading-more-box']}>
{
hasMore ? (
<img className={styles['loading-more-icon']} src={loadingMoreIcon}></img>
) : (
<div>------ 已经到底啦 ------</div>
)
}
</div>
</div>
使用 onscroll
事件监听即将触底事件,其中 throttle
是防抖函数,实现方法参考 JavaScript系列 -- 防抖、节流,作用是防止过于频繁的触发加载更多数据
其中设定 scrollThreshold 为 300 是为了提前预知触底事件,提前加载更多数据,使得用户对列表更新无明显感知,真正做到用户无感知且顺滑的懒加载
js
useEffect(() => {
if (overflowBoxDom) {
overflowBoxDom.onscroll = throttle(() => {
// 为了避免连续触发 scroll 事件导致的性能问题
// 我们可以设定一个 "scrollThreshold" 值,单位像素
const scrollThreshold = 300;
const scrollHeight = overflowBoxDom.scrollHeight;
const scrollTop = overflowBoxDom.scrollTop;
const clientHeight = overflowBoxDom.clientHeight;
const isNeerBottom = scrollHeight - scrollTop - clientHeight <= scrollThreshold;
if (isNeerBottom && hasMore && !isMoreLoading) {
// console.log('触底啦');
getMoreHeroInfo(); // 加载更多内容
}
}, 500);
}
}, [overflowBoxDom]);
效果
控制变量:统一网络状况
- 打开网络,点击【自定义-添加】
- 设置上传速度、下载速度、延迟时间
- 点击添加后,修改网络配置为 4G
对比实测
数据指标意义
(Chrome 谷歌浏览器)
- finish time 完成用时:页面最后一个请求截止的时间,如果页面加载完成后,触发新的网络请求,那么该时间会变更
- DOMContentLoaded:dom内容加载并解析完成的时间,即页面白屏时间
- load:页面所有的资源(图片、音频、视频等)加载完成的时间(其实不准确,原因如下)
注意:【load
事件在整个页面及所有依赖资源如样式表和图片都已完成加载时触发(来自MDN)】是有误的,图片资源的加载属于同步请求还是异步请求是由浏览器决定的,主流浏览器是设置异步解码图片以减少其他内容的渲染延迟,所以图片资源的加载可以看做是异步请求 ,不属于 load 事件监听范围内,也就是说【load 事件无法监测页面的所有图片资源加载完成】
所以真正要监测到页面所有资源加载完成应该是看这个时间(finish time):
实验结果
- 一次性加载所有王者荣耀英雄信息 115 个,页面所有资源加载完成的时间是
3.63
秒
- 分页加载王者荣耀英雄信息,每页 10 个,下拉加载更多,页面所有资源加载完成的时间是
1.05
秒
可以观察到资源加载时间大大减少,本质上是将首屏加载的资源数量减少,这就是懒加载(分页加载)的核心
最终懒加载方案也会加载相同的英雄信息网络资源,但是用户对此几乎无感知
懒加载列表组件封装
几乎所有的列表模块都需要这样的能力,所以我将其封装成一个具备懒加载能力的列表组件 List,大大提高开发效率
我设想把具备这个能力的列表组件封装成 List,然后外部组件引入这个组件,并定义一个数组,通过传入这个数组和一个列表项组件完成列表渲染,这样这个列表就具备懒加载能力了
list.tsx 导出 List 组件,并设置 List.Item 组件
tsx
import React, { useEffect, useRef, useState } from 'react';
import { mergeOptions, throttle } from '@/utils/util';
import './list.less'
import loadingMoreIcon from "@/assets/loading.gif";
type ListPropsT = {
hasMore?: boolean; // 是否还有更多数据
children?: React.ReactNode; // 放 List.Item
onHeaderReleased?: () => void; // 滚动条触顶事件
onNearBottom?: () => void; // 滚动条即将触底事件
};
type ListItemPropsT = {
children?: React.ReactNode; // 列表内容组件
};
/** 默认配置 */
const defaultProps = {
hasMore: true,
}
const List: React.FC<ListPropsT> & {
Item: typeof ListItem;
} = (props) => {
const overflowBoxRef = useRef(null);
const [overflowBoxDom, setOverflowBoxDom] = useState<HTMLDivElement | null>(null);
const options: ListPropsT = mergeOptions(defaultProps, configProps, props); // 合并配置项
useEffect(() => {
if (overflowBoxRef) {
setOverflowBoxDom(overflowBoxRef.current);
}
}, [overflowBoxRef]);
useEffect(() => {
if (overflowBoxDom) {
overflowBoxDom.onscroll = throttle(() => {
const scrollThreshold = 300;
const scrollHeight = overflowBoxDom.scrollHeight;
const scrollTop = overflowBoxDom.scrollTop;
const clientHeight = overflowBoxDom.clientHeight;
const isNeerBottom = scrollHeight - scrollTop - clientHeight <= scrollThreshold;
if (scrollTop <= -50) {
console.log('触顶啦');
if (options.onHeaderReleased) options.onHeaderReleased();
}
if (isNeerBottom) {
console.log('触底啦');
if (options.onNearBottom) options.onNearBottom();
}
}, 500);
}
}, [overflowBoxDom]);
return (
<div
className={'list'}
onClick={() => { }}
ref={overflowBoxRef}
>
{props.children}
<div className={'loading-more-box'}>
{
options.hasMore ?
(<img className={'loading-more-icon'} src={loadingMoreIcon}></img>) :
(<div className={'no-more-text'}>------ 已经到底啦 ------</div>)
}
</div>
</div>
)
};
const ListItem: React.FC<ListItemPropsT> = (props) => {
return (
<div className={'list-item'}>
{props.children}
</div>
)
};
List.Item = ListItem;
export default List;
page.tsx 引入使用 List 组件
tsx
import List from '@/components/list/list';
const ListGuide: React.FC<{}> = () => {
const [heroInfoList, setHeroInfoList] = useState<HeroInfoT[]>([]); // 英雄信息列表
const [hasMore, setHasMore] = useState(true); // 是否还有更多
let startIndex = 0; // 当前请求列表的起始索引
useEffect(() => {
addDataToHeroInfoList();
}, []);
/** 初始化或重新刷新列表 */
const initHeroInfoList = function () {
setHasMore(true);
setHeroInfoList([]);
startIndex = 0;
addDataToHeroInfoList();
}
/** 加载更多数据 */
const addDataToHeroInfoList = async function () {
const list = await getHeroInfoList(startIndex, 10);
if (list.length === 0) {
setHasMore(false); // 不再有新数据
return;
}
setHeroInfoList(e => e.concat(list));
startIndex += 10;
}
return (
<>
<List
hasMore={hasMore}
onNearBottom={() => { addDataToHeroInfoList() }}
onHeaderReleased={() => { initHeroInfoList() }}
>
{heroInfoList.map((item, index) => (
<List.Item key={index}>
<HeroInfo info={item} />
</List.Item>
))}
</List>
</>
)
}
const HeroInfo: React.FC<{
info: HeroInfoT
}> = ({ info }) => {
return (
<div className={styles['hero-info-box']}>
<div className={styles['top-box']}>
<img
className={styles['hero-avatar']}
src={`....../${info.heroid}/${info.heroid}.jpg`}
/>
<div className={styles['middle-box']}>
<div>{info.heroName}</div>
<div>职业:{info.heroJob}</div>
</div>
</div>
</div>
);
};
效果:
此封装的 List 组件具备:
- 列表渲染能力
- 懒加载更多数据能力
- 触顶事件回调能力
优点:
- 防抖减少频繁触发
- 提前判断触底,使得用户对列表加载更多数据几乎无感知