Load More in H5 Pages
Best practices for implementing load-more pagination in H5 pages with React components.
H5 列表页做分页,移动端不适合翻页按钮,上拉加载是标准做法。需求来了,先看看 vant 有没有现成的。
页面基础
页面框架是 React,整体结构用 NavBox 组件包着,列表内容塞进去:
1
2
3
4
5
6
7
import React from "react";
import NavBox from "@/components/NavBox";
const InvitationList: React.FC = () => {
return <NavBox title="我邀请的人">{/* 列表内容 */}</NavBox>;
};
export default InvitationList;
数据接口的类型定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// interface.tsx
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
import fetch from "@/api";
import { ListResponse, Invite } from "@/api/interface";
export const getList = (
data: Invite.reqInvite
): Promise<ListResponse<Invite.resInviteItem>> => {
return fetch<ListResponse<Invite.resInviteItem>>("/invitationList", data, "POST");
};
先让页面正常展示,pageSize 固定 10 条:
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;
页面有了,接下来实现上拉加载。
先试 vant,发现不对劲
react-vant 有个 List 组件,监听 scroll 事件,快到底部时触发 onLoad。有个 offset 属性,默认 300,意思是距底部 300px 时就触发。
听起来挺合理。但仔细想了一下——假设列表 6 条,在某个机型上最后一条刚好有一部分在视口外,那没问题,滚一点就能触发。但如果换个更高的屏幕,6 条数据全部在视口内,根本到不了 offset,上拉加载就永远触发不了。官方文档原话是:「理想情况下每次请求获取的数据条数应能够填满一屏高度」——这就是在说这个问题。
还有一个问题:开发环境下 onLoad 会触发两次,第二次调接口就直接拿到第二页数据了,出现重复请求。
放弃 vant,自己实现。
自定义 LoadList 组件
核心思路:不用 scroll 事件,改用 IntersectionObserver——监听一个 loading 指示器元素是否进入视口,进了就触发加载。
LoadList 组件的结构是两部分:父组件传入的 children(列表内容),以及紧跟其后的 LoadingBox 组件。LoadingBox 进入视口 → 加载下一页。
有个顺带的好处:页面初始时 children 为空,LoadingBox 直接在视口里,所以第一次数据请求不需要单独处理——observer 一挂上就会触发,自动发第一页请求。
LoadingBox
LoadingBox 接受一个 finished 参数,控制显示「加载中…」还是「没有更多数据了」。
LoadList 需要拿到这个组件的 DOM 节点来 observe 它,所以用 forwardRef 把 ref 暴露出来:
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>
);
});
注意 forwardRef 的泛型顺序:第一个是 DOM 类型(HTMLDivElement),第二个是 props 类型(LoadingBoxProps)。函数形参里正好相反,第一个是 props,第二个是 ref。
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
const [finished, setFinished] = useState(false);
const loadingRef = useRef<HTMLDivElement>(null);
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);
}
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, []);
这里遇到了一个 React 的坑
现在要实现 handlerLoading:每次触发时 pageNum +1,调接口,把数据回调给父组件。
很直觉地想用 setState 来跟踪 pageNum:
1
2
3
4
5
6
const handlerLoading = async () => {
setInitParams((prev) => ({ ...prev, pageNum: prev.pageNum + 1 }));
const { data } = await api(initParams); // 拿不到更新后的值
callback(data);
};
跑起来发现不对——setState 之后立刻读 initParams,拿到的还是旧值。
这跟 React 的渲染机制有关:state 是一张快照,setState 不会立即更新,要等下一次渲染才生效。所以你在同一次执行里刚 setState 完就读值,读到的是这次渲染开始时的快照,不是你刚写进去的值。
另外,组件每次重新渲染,函数体内的普通变量(let num = 0 这种)都会重新初始化——useState 解决了持久化,但解决不了”立即读到更新值”的问题。
解法是 useRef。useRef 声明的变量不触发重渲染,组件渲染时也不会重置它,读 .current 永远是最新值:
1
2
3
4
5
6
7
8
const initParams = useRef({ pageNum: 0, pageSize: 10 });
const handlerLoading = async () => {
initParams.current.pageNum += 1;
const { data } = await api(initParams.current);
callback(data);
};
边界条件:没有更多数据了
两种判断方式:多请求一次看是否返回空,或者用 total 字段对比。后端接口有返回 total,用第二种:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
};
total 和 listNum 也用 useRef 保存,原因一样——需要跨渲染持久化,且不需要触发重渲染。
空数据状态
接口返回 total 为 0 时,不该显示 loading,应该显示 Empty:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
};
根据 isEmpty 决定渲染哪个:
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
33
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 和 pageSize 写死在组件里了。如果某个接口的分页字段不叫 pageNum 而叫 num,或者需要额外传其他参数,组件就改不了。
workaround 是在外面包一层函数,把参数在这里处理好,再传给 api:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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>
);
能用,但不是最优解——LoadList 把分页参数的结构假设写死了,调用方每次都要做这层适配。如果要改,应该在组件设计上就把参数构造的控制权还给外部。算是当时没想到的地方,先这样用着。

