h5页面加载更多的实践

上拉加载更多是用UI库,还是自己封装?

Posted by My on April 10, 2024

前言

在 h5 页面中,列表页是需要有分页效果的,最常见的就是使用上拉加载功能,那就近正好遇到了,那基于这个需求,就来分析一下如何实现这个功能。

那目前,整个页面含有几个组件,细的就不说,主要是整个页面框架和「上拉加载」组件。

基本的内容准备

UI 效果是这样的: image.png 基于此,要实现上拉加载,得先有这个页面的容器和数据的请求接口,下面就针对这些进行分析。

页面框架

页面框架是基于 React 的,所以我们先来看一下页面的结构。

1
2
3
4
5
6
import React from "react";
import NavBox from "@/components/NavBox";
const InvitationList: React.FC = () => {
  return <NavBox title="我邀请的人">{/* 列表内容 */}</NavBox>;
};
export default InvitationList;

正如上图所见,「NavBox」组件是整个页面的导航栏,它包含了页面的标题,以及「列表内容」,也就是我们要实现上拉加载的地方。

数据请求接口

「邀请列表」的每一个「card」也已经封装好了,接收一个「info」参数。现在需要一些异步数据,为上拉加载做准备。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//interface.tsx

/**
 * @description: 邀请模块
 */
export namespace Invite {
  export interface reqInvite {
    pageNum: number;
    pageSize: number;
  }
  export interface resInviteItem {
    id: number | string;
    name: string;
    phone: string;
    time: string;
    status: number;
  }
}

其中 resInviteItem 是泛型,数组的每一项。

异步请求接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import fetch from "@/api";
import { ListResponse, Invite } from "@/api/interface";

/**
 * @description: 邀请列表
 * @param {Invite.reqInvite} data 邀请列表参数
 */
export const getList = (
  data: Invite.reqInvite
): Promise<ListResponse<Invite.resInviteItem>> => {
  return fetch<ListResponse<Invite.resInviteItem>>(
    "/invitationList",
    data,
    "POST"
  );
};

页面展示

到这里,页面已经能正产展示页面信息了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { useEffect, useState } from "react";
import NavBox from "@/components/NavBox";
import InviteCard from "@/components/InviteCard";
import { Invite } from "@/api/interface";
import { getList } from "@/api/modules/invite";

const InvitationList: React.FC = () => {
  const [data, setData] = useState<Invite.resInviteItem[]>([]);
  useEffect(() => {
    getList({ pageNum: 1, pageSize: 10 }).then((res) => {
      setData(res.data.records);
    });
  }, []);

  return (
    <NavBox title="我邀请的人">
      {data.map((item) => (
        <InviteCard info={item} key={item.id} />
      ))}
    </NavBox>
  );
};
export default InvitationList;

效果如上图一样,但是只能根据 「pageSize」只能加载 10 条数据。

实现上拉加载

现在,我们需要实现「上拉加载」功能,也就是当用户下拉到底部时,加载更多的数据。

首先我是基于方便的原则去思考,看看 「vant」有没有提供这个功能。

react vant 实现

这是 react vant 的 List 组件。分析一下它的实现,是通过监听「scroll」事件,判断是否到达底部,然后触发「on-load」事件,请求接口,获取更多数据。它有一个 「offset」属性,可以控制「scroll」事件的触发时机,默认是 300,也就是说,当距离底部 300px 时,才会触发「scroll」事件。

仔细想想,这个并不符合我的预期。假设我的列表 6 条,在某个手机机型上,最后一条数据刚好有一部分在「视口」之外,也就意味着我需要滚动才能看到完整的列表(上拉加载的概念出来了)。这时候需要测试并调整「offset」,选取合适的值。

如果在某个机型里,6 条数据已经能完全的在视口内展示,那就无法达到「offset」的值,进而无法实现「上拉加载」功能。 所以 react vant 的原话是:理想情况下每次请求获取的数据条数应能够填满一屏高度

另外在实践中,开发环境下,「onlaod」事件是触发两次的,那就意味着在「分页」时,第二次调接口就已经获取了第二页的数据了,这就造成了重复请求。

自定义实现

首先要摒弃监听「scroll」事件,改用「IntersectionObserver」,它可以更精准的监听元素是否出现在视口中。

实现「LoadList」组件,渲染函数里有两部分,一是父组件传入的「children」,而是紧跟其后的「LoadingBox」组件。当「loaidngBox」组件出现在视口中,就进行上拉加载,请求接口,获取更多数据。

1. 定义 LoadList 组件

1
2
3
4
5
6
7
8
interface LoadListProps {
  children?: React.ReactNode; // 列表内容
  api: (params: any) => Promise<any>; // 请求接口
  callback: (params: any) => void; // 回调函数
}
const LoadList: React.FC<LoadListProps> = ({ children, api, callback }) => {
  return <div className={styles.loadlList}>{children}</div>;
};

2. 定义 LoadingBox 组件

接受一个参数 finnished, 用于控制「加载中…」和「没有更多数据了」两种状态。

这时候,「loaidngBox」是一个「FC」,「LoadList」组件需要拿到这个组件,并进行监听,即 observer.observe(currentRef) 。因此,需要将 「LoadingBox」组件暴露出来。这里用到 react 的 hooks forwardRef 来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface LoadingBoxProps {
  finished: boolean;
  ref: RefObject<HTMLDivElement>;
}
const LoadingBox = forwardRef<HTMLDivElement, LoadingBoxProps>((props, ref) => {
  const { finished } = props;
  const [desc, setDesc] = useState("加载中...");
  useEffect(() => {
    if (finished) {
      setDesc("没有更多数据了");
    }
  }, [finished]);
  return (
    <div className={styles.loading} ref={ref}>
      {finished ? (
        <span>{desc}</span>
      ) : (
        <Loading size="1.6rem" type="spinner">
          {desc}
        </Loading>
      )}
    </div>
  );
});

tips: 主要 fowardRef 的泛型里的一个类型是 dom ,即 HTMLDivElement; 第二个类型是 props 的类型,即 LoadingBoxProps 。 而里面的函数的形参中,正好相反,第一个参数是 props, 第二个参数是 ref 。

3. 实现 Observer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const [finished, setFinished] = useState(false); // 是否加载完成
const loadingRef = useRef<HTMLDivElement>(null); // loading 组件的 ref

// 处理loading进入视口的回调函数
const handlerLoading = async () => {
  console.log("loading enter viewport");
};

useEffect(() => {
  const options = {
    rootMargin: "0px",
    threshold: 0.9, // 交叉阈值
  };
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        handlerLoading();
      }
    });
  }, options);

  const currentRef = loadingRef.current;
  if (currentRef) {
    observer.observe(currentRef); // 开始观察loading组件
  }
  // 清理 observer
  return () => {
    if (currentRef) {
      observer.unobserve(currentRef);
    }
  };
}, []);

到此,「LoadingBox」组件的监听就完成了,当其进入视口时,就会触发 handlerLoading 函数。在组件「return」里,「LoadingBox」组件是在「children」后面,也就意味着「children」没有被渲染时,「LoadingBox」是出现在视口中的。

因此,可以利用这个特性,不需要特地地处理第一次接口请求,直接在 handlerLoading 函数中进行就可以:初始时,「LoadingBox」在视口中,第一次请求数据,获取到数据之后,「LoadingBox」被挤到视口外。上拉页面时,「LoadingBox」再次出现在视口中,触发 handlerLoading 函数,再次请求数据,获取更多数据。

4. 实现 handlerLoading 函数

思路是这样的,调用接口,然后将 pageNum +1,调用 callback 函数,将数据传给父组件。最后判断如果没有更多数据了,则停止调用 handlerLoading 函数。

但是,如果按照普通的思路下来,通过 setState 修改变量,执行函数,你会发现走不下去。这里面一个重要的点就是 「状态」

这个和 react 的渲染机制有关,当 setState 之后,会渲染整个组件,即如果你在 「FC」 里声明了一个变量,如 let num = 0,然后你对它进行 num++,那么每次渲染都会重新声明一个新的变量,即 num 变量每次都会被初始化为 0。 另外,state 的特性就是一张快照,你在此时修改 state ,并不会立即生效,而是等到下一次渲染时才会生效。

基于上面的这种特性,对于 「payload」,并不能用 setState 来更新,你获取不到更新后的值。

1
2
3
4
5
6
const handlerLoading = async () => {
  setInitParams((prev) => ({ ...prev, pageNum: prev.pageNum + 1 })); // ❌ 修改后拿不到值

  const { data } = await api(initParams);
  callback(data);
};

useRef 组要有两个特性,一是声明 domconst ref = useRef<HTMLDivElement>(null) ,二是保存变量:const num = useRef(0)。用 useRef 声明的变量不具有响应式,即不会触发组件的重新渲染。组件渲染时,并不会重置变量,达到了缓存变量的效果。

我们可以用 useRef 来保存 initParams 变量。

1
2
3
4
5
6
7
const initParams = useRef({ pageNum: 0, pageSize: 10 });
const handlerLoading = async () => {
  initParams.current.pageNum += 1; // ✅ 修改后拿到值

  const { data } = await api(initParams.current);
  callback(data); // 回调函数处理数据
};

到此,「上拉加载」功能就实现了,每次触发 handlerLoading 函数,都会将 「页数」自增 1,并请求接口,获取更多数据。

接着我们处理边界条件,即「没有更多数据」的时候。这里有两个思路:

  • 多一次请求,如果返回空数组,则认为没有更多数据。
  • 判断总条数与当前获取的条数,如果相等,则认为没有更多数据。

因为后端的接口有返回 total 字段,所以我们采用第二种方法。同样的,数据需要保存起来,用作判断,所以使用 useRef 保存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const listNum = useRef(0); // 已获取的条数
const total = useRef(0); // 总条数

const handlerLoading = async () => {
  //第二次进入,如果已经加载完毕,则不再加载
  if (listNum.current === total.current && total.current) {
    setFinished(true);
    return;
  }

  initParams.current.pageNum += 1;
  const { data } = await api(initParams);
  total.current = data.total;
  listNum.current += data.records.length;
  callback(data);
};

到此,「上拉加载」功能就实现了,并且处理了边界条件。刚开始进入页面时,显示「加载中」,然后加载数据。如果数据已经加载完全,哪怕「LoadingBox」组件还在视口中,也不会再次触发 handlerLoading 函数。

5. 处理空数据的情况

如果接口没有数据,应该是展示一个「空」状态。因此,需要有个变量来控制,条件渲染「List」组件与「Empty」组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const [isEmpty, setIsEmpty] = useState(false);
const handleEmptyState = (total: number) => {
  if (total === 0) {
    setIsEmpty(true);
  } else if (listNum.current === total) {
    setFinished(true);
  }
};

const handlerLoading = async () => {
  if (listNum.current === total.current && total.current) {
    setFinished(true);
    return;
  }

  initParams.current.pageNum += 1;
  const { data } = await api(initParams.current);
  total.current = data.total;
  listNum.current += data.records.length;
  callback(data);
  handleEmptyState(data.total); // 处理空数据情况
};

条件判断,渲染「List」组件与「Empty」组件。

1
2
3
4
5
6
7
8
9
10
11
12
return (
  <>
    {!isEmpty ? (
      <div className={styles.loadlList}>
        {children}
        <LoadingBox ref={loadingRef} finished={finished} />
      </div>
    ) : (
      <Empty description="暂无数据" {...emptyProps} />
    )}
  </>
);

LoadList 组件的完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import React, {
  useEffect,
  useRef,
  useState,
  forwardRef,
  RefObject,
} from "react";
import styles from "./loadList.module.scss";
import { Loading, Empty } from "react-vant";

interface LoadingBoxProps {
  finished: boolean;
  ref: RefObject<HTMLDivElement>;
}
const LoadingBox = forwardRef<HTMLDivElement, LoadingBoxProps>((props, ref) => {
  const { finished } = props;
  const [desc, setDesc] = useState("加载中...");
  useEffect(() => {
    if (finished) {
      setDesc("没有更多数据了");
    }
  }, [finished]);
  return (
    <div className={styles.loading} ref={ref}>
      {finished ? (
        <span>{desc}</span>
      ) : (
        <Loading size="1.6rem" type="spinner">
          {desc}
        </Loading>
      )}
    </div>
  );
});

interface LoadListProps {
  children?: React.ReactNode;
  api: (params: any) => Promise<any>;
  callback: (params: any) => void;
  emptyProps?: React.ComponentProps<typeof Empty>;
}
const LoadList: React.FC<LoadListProps> = ({
  children,
  api,
  callback,
  emptyProps,
}) => {
  const [isEmpty, setIsEmpty] = useState(false);
  const [finished, setFinished] = useState(false);
  const loadingRef = useRef<HTMLDivElement>(null);
  const initParams = useRef({ pageNum: 0, pageSize: 10 });

  const listNum = useRef(0);
  const total = useRef(0);

  const handlerLoading = async () => {
    if (listNum.current === total.current && total.current) {
      setFinished(true);
      return;
    }

    initParams.current.pageNum += 1;
    const { data } = await api(initParams.current);
    total.current = data.total;
    listNum.current += data.records.length;
    callback(data);
    handleEmptyState(data.total);
  };

  const handleEmptyState = (total: number) => {
    if (total === 0) {
      setIsEmpty(true);
    } else if (listNum.current === total) {
      setFinished(true);
    }
  };
  useEffect(() => {
    const options = {
      rootMargin: "0px",
      threshold: 0.9,
    };
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          handlerLoading();
        }
      });
    }, options);

    const currentRef = loadingRef.current;
    if (currentRef) {
      observer.observe(currentRef);
    }
    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, []);

  return (
    <>
      {!isEmpty ? (
        <div className={styles.loadlList}>
          {children}
          <LoadingBox ref={loadingRef} finished={finished} />
        </div>
      ) : (
        <Empty description="暂无数据" {...emptyProps} />
      )}
    </>
  );
};

export default LoadList;

页面使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React, { useState } from "react";
import NavBox from "@/components/NavBox";
import InviteCard from "@/components/InviteCard";
import { Invite } from "@/api/interface";
import { getList } from "@/api/modules/invite";
import LoadList from "@/components/LoadList";
const InvitationList: React.FC = () => {
  const [data, setData] = useState<Invite.resInviteItem[]>([]);

  const getInvitationList = ({
    records,
  }: {
    records: Invite.resInviteItem[];
  }) => {
    setData((v) => [...v, ...records]);
  };

  return (
    <NavBox title="我邀请的人">
      <LoadList
        api={getList} // 请求接口
        callback={getInvitationList}
        emptyProps=
      >
        {data.map((item) => (
          <InviteCard info={item} key={item.id} />
        ))}
      </LoadList>
    </NavBox>
  );
};
export default InvitationList;

通过「LoadList」组件发现,我们是将 请求参数 写在了组件里,这就耦合了,如果说请求参数不是 pageNum 字段,而是 num字段,或是需要更多的请求参数怎么办? 其实可以在页面调用的使用,处理好参数,再给 api 赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const getListData = (params: Invite.reqInvite) => {
  const form: Invite.reqInvite = {
    ...params,
    type: 1,
  };
  return getList(form);
};
return (
  <NavBox title="我邀请的人">
    <LoadList
      api={getListData}
      callback={getInvitationList}
      emptyProps=
    >
      {data.map((item) => (
        <InviteCard info={item} key={item.id} />
      ))}
    </LoadList>
  </NavBox>
);