前言
在 h5 页面中,列表页是需要有分页效果的,最常见的就是使用上拉加载功能,那就近正好遇到了,那基于这个需求,就来分析一下如何实现这个功能。
那目前,整个页面含有几个组件,细的就不说,主要是整个页面框架和「上拉加载」组件。
基本的内容准备
UI 效果是这样的:
基于此,要实现上拉加载,得先有这个页面的容器和数据的请求接口,下面就针对这些进行分析。
页面框架
页面框架是基于 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
组要有两个特性,一是声明dom
:const 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>
);