前端性能优化系列 - 长列表分页加载篇

201 阅读6分钟

前端常见的需求场景是以列表的形式干净高效的承载文字、列表、图片、段落等,如果列表长度过长,将会引起首屏打开时间长滑动卡顿等现象

针对此情况我决定采用分页加载方案去解决列表过长时出现的问题,以下是我的解决思路、代码、实测数据结果

监听滚动条即将触底

第一步,要监听滚动条即将触底。通过监听【可滚动区域的 DOM】的 scrollTop + clientHeight 的值与 scrollHeight 的值接近来判断滚动条是否即将触底

要先获取可滚动区域的 DOM:overflowBoxDom,需要通过 useRef 钩子挂载 DOM 节点,然后读取 overflowBoxRef.current 获取 overflowBoxDom 节点

其中 hasMore 变量代表是否还有更多数据,如果还有更多显示 loading.gif,否则显示“已经到底啦”(以下所有代码经过精简,详细参考代码仓库

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 是为了提前预知触底事件,提前加载更多数据,使得用户对列表更新无明显感知,真正做到用户无感知且顺滑的懒加载

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]);

效果:

result.gif

观察发现:一开始显示第 1 到 11 条数据,当滚动到即将触底时加载下 10 条数据,可以观察到滚动条高度突然变短,说明列表长度变长,如果隐藏滚动条,用户将对此无感知

控制变量:统一网络状况

为了使得实测数据更具有说服力,我们需要在浏览器控制台将网络状况进行统一控制

  1. 打开网络,点击【自定义-添加】

image.png

  1. 设置上传速度、下载速度、延迟时间

image.png

  1. 点击添加后,修改网络配置为 4G

对比实测

数据指标意义

(Chrome 谷歌浏览器)

  • finish time 完成用时:页面最后一个请求截止的时间,如果页面加载完成后,触发新的网络请求,那么该时间会变更
  • DOMContentLoaded:dom内容加载并解析完成的时间,即页面白屏时间
  • load:页面所有的资源(图片、音频、视频等)加载完成的时间(其实不准确,原因如下

注意:【load 事件在整个页面及所有依赖资源如样式表和图片都已完成加载时触发(来自MDN)】是有误的,图片资源的加载属于同步请求还是异步请求是由浏览器决定的,主流浏览器是设置异步解码图片以减少其他内容的渲染延迟,所以图片资源的加载可以看做是异步请求,不属于 load 事件监听范围内,也就是说【load 事件无法监测页面的所有图片资源加载完成】

image.png

所以真正要监测到页面所有资源加载完成应该是看这个时间(finish time):

image.png

实验结果

  • 一次性加载所有王者荣耀英雄信息 115 个,页面所有资源加载完成的时间是 3.63

image.png

  • 分页加载王者荣耀英雄信息,每页 10 个,下拉加载更多,页面所有资源加载完成的时间是 1.86

image.png

可以观察到资源加载时间大大减少,本质上是将首屏加载的资源数量减少,这就是分页加载的核心

最终分页加载方案也会加载相同的英雄信息网络资源,但是用户对此几乎无感知

封装分页加载列表组件

几乎所有的列表模块都需要这样的能力,所以我将其封装成一个具备分页加载能力的列表组件 List,大大提高开发效率

我设想把具备这个能力的列表组件封装成 List,然后外部组件引入这个组件,并定义一个数组,通过传入这个数组和一个列表项组件完成列表渲染,这样这个列表就具备分页加载能力了

list.tsx 声明定义 List 组件以及 ListItem 组件,并将其设为 List 组件的 Item 属性

ListPropsTListItemPropsT 分别代表 List 组件、 ListItem 组件传入参数的类型,外部组件只需要传入 hasMore(是否还有更多)、children(列表项组件),通过 onHeaderReleased(滚动条触顶事件)、onNearBottom(滚动条即将触底事件)回调做相应的刷新数据操作

其中 defaultProps 表示默认参数,mergeOptions 将参数进行合并

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 组件,声明定义 HeroInfo 列表项组件,将其作为 <List.Item> 组件的 children,维护一个数组 heroInfoList 存放英雄信息列表数据,通过 onHeaderReleasedonNearBottom 回调更新数组 heroInfoList

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>
  );
};

效果:

预览地址:alkaoua720.github.io/common-ui-r…

image.png

此封装的 List 组件具备:

  1. 列表渲染能力
  2. 分页加载更多数据能力
  3. 触顶事件回调能力

优点:

  1. 防抖减少频繁触发
  2. 提前判断触底,使得用户对列表加载更多数据几乎无感知