耿健的个人博客

一个即将放飞理想的咸鱼博主

0%

21.自定义Hook分页加载的实现

痛点分析

在实际业务开发的过程中,
有很多关于长列表分页加载的场景。
比如 feed 信息流,数据统计,成员设置等等。
那么如果每个页面都单独写一套长列表分页加载的逻辑,
代码重复臃肿,且每个页面实现方式不一样。
很难统一管理。

而 Hook 的概念,可以使你在无需修改组件结构的情况下复用状态逻辑。
所以才有了封装分页加载 useQueryPageList 的想法。

功能实现

那么封装一个功能,入参返回值都是必要的。
入参就是:
需要调用的接口函数、以及对应的传入参数。
返回值就是:
用以展示的列表数据,以及总条数。

所以在 useQueryPageList 里面同时需要使用生命周期:
useEffect监听 funFetchApi、param 其中之一发生变化:重新请求一次第一分页(第一次进入页面通过此处获取数据)
useEffect监听 isUpdateList 变化:用于主动触发刷新的需求
useDidShowonShow 生命周期:重新获取数据(注:第一次进入页面虽触发 onShow 不过不去执行获取数据操作)
useReachBottom触底生命周期:加载下一分页数据
usePullDownRefresh下拉刷新生命周期:重新加载第一分页数据

同时我们需要绑定变量保存当前加载情况:
nPageNum当前加载的分页编号
nPageSize每组分页加载多少条数据
arrPageList目前已经加载的数据

isInitComplate是否初始化完毕
funFetchApiTmp接口 API 的备份,用以判断是否有变化
paramTmp接口入参的备份,用以判断是否有变化

最后在之前的三个声明周期分别触发回调函数,将获取到的分页数据返回即可。

代码实现

useQueryPageList.ts

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import Taro, {
useDidShow,
useReachBottom,
usePullDownRefresh,
} from "@tarojs/taro";
import { useEffect, useRef } from "react";
import useDebounce from "@/hooks/useDebounce";

const PAGE_NUM_LOCK = 2;
const PAGE_SIZE = 20;

/**
* @param callback 获取到的列表数据回调函数
* @param funFetchApi 请求数据的接口函数
* @param param 请求数据的必要参数
* @param isUpdateList 修改则主动触发onShow刷新
*/
const useQueryPageList = (
callback: any,
funFetchApi: any = null,
param: any = {},
isUpdateList: boolean = false
) => {
const nPageNum = useRef<number>(0);
const nTotalCount = useRef<number>(0);
const arrPageList = useRef<Array<any>>([]);

const isInitComplate = useRef<boolean>(false);
const funFetchApiTmp = useRef(undefined);
const paramTmp = useRef(undefined);

const nPageSize = param.nPageSize ? param.nPageSize : PAGE_SIZE;

/**
* 统一处理接口返回数据
* @param res
*/
const dealFetchResult = (res: any) => {
const list = res?.data ? res.data : [];
const totalCount =
res?.totalCount === undefined
? 9999
: res?.totalCount
? res?.totalCount
: 0;
nTotalCount.current = totalCount;
return {
list,
totalCount,
};
};

/**
* 统一返回结果数据
*/
const returnCallBack = () => {
callback &&
callback({
state: "RESULT",
list: arrPageList.current,
totalCount: nTotalCount.current,
});
};

/**
* 监听funFetchApi、param其中之一发生变化:重新请求一次第一分页
* 新增防抖操作,只取短时间内最后一次的请求结果
*/
useEffect(
useDebounce(() => {
const isNotUndefined =
!(funFetchApi === undefined) && !(param === undefined);
const isDiff =
funFetchApiTmp.current !== funFetchApi ||
JSON.stringify(paramTmp.current) !== JSON.stringify(param);
if (isNotUndefined && isDiff) {
funFetchApiTmp.current = funFetchApi;
paramTmp.current = param;
nPageNum.current = 0;
callback && callback({ state: "LOADING" });
const paramReal = {
...param,
nPageNum: nPageNum.current,
nPageSize: nPageSize,
};
funFetchApi &&
funFetchApi(paramReal).then((res) => {
console.log("useQueryPageList useUpdateApiOrParam", res);
const { list } = dealFetchResult(res);
arrPageList.current = list;
isInitComplate.current = true;
returnCallBack();
});
}
}, 500),
[funFetchApi, param]
);

/**
* 监听isUpdateList变化:用于主动触发刷新
*/
useEffect(() => {
if (!callback || !funFetchApi) {
return;
}
if (isInitComplate.current) {
callback({ state: "LOADING" });
// 一次最多加载(PAGE_NUM_LOCK + 1) * PAGE_SIZE条数据
nPageNum.current =
nPageNum.current >= PAGE_NUM_LOCK ? PAGE_NUM_LOCK : nPageNum.current;
const paramReal = {
...param,
nPageNum: 0,
nPageSize: (nPageNum.current + 1) * nPageSize,
};
funFetchApi(paramReal).then((res) => {
console.log("useQueryPageList useUpdateList", res);
const { list } = dealFetchResult(res);
arrPageList.current = list;
returnCallBack();
});
}
}, [isUpdateList]);

/**
* onShow声明周期:重新获取数据(注:第一次进入页面虽触发onShow不过不执行获取数据操作)
*/
useDidShow(() => {
if (!callback || !funFetchApi) {
return;
}
if (isInitComplate.current) {
callback({ state: "LOADING" });
// 一次最多加载PAGE_NUM_LOCK * PAGE_SIZE条数据
nPageNum.current =
nPageNum.current >= PAGE_NUM_LOCK ? PAGE_NUM_LOCK : nPageNum.current;
const paramReal = {
...param,
nPageNum: 0,
nPageSize: (nPageNum.current + 1) * nPageSize,
};
funFetchApi(paramReal).then((res) => {
console.log("useQueryPageList useDidShow", res);
const { list } = dealFetchResult(res);
arrPageList.current = list;
returnCallBack();
});
}
});

/**
* 触底生命周期:加载下一分页数据
*/
useReachBottom(() => {
if (!callback || !funFetchApi) {
return;
}
if (nPageNum.current * nPageSize > nTotalCount.current) {
return;
}
callback({ state: "REACH_BOTTOM" });
nPageNum.current++;
const paramReal = {
...param,
nPageNum: nPageNum.current,
nPageSize: nPageSize,
};
funFetchApi(paramReal).then((res) => {
console.log("useQueryPageList useReachBottom", res);
const { list } = dealFetchResult(res);
arrPageList.current = arrPageList.current.concat(list);
returnCallBack();
});
});

/**
* 下拉刷新生命周期:重新加载第一分页数据
*/
usePullDownRefresh(() => {
if (!callback || !funFetchApi) {
return;
}
console.log("useQueryPageList usePullDownRefresh");
callback({ state: "LOADING" });
nPageNum.current = 0;
const paramReal = {
...param,
nPageNum: nPageNum.current,
nPageSize: nPageSize,
};
funFetchApi(paramReal).then((res) => {
console.log("useQueryPageList usePullDownRefresh", res);
const { list } = dealFetchResult(res);
arrPageList.current = list;
returnCallBack();
});
});
};

export default useQueryPageList;

问题解决

基本功能是已经实现了。
不过实际还有一些隐藏问题。

  1. 不能在组件内使用。
    因为如果该组件涉及到多次注册和销毁的逻辑。会导致组件内声明的useReachBottom等 Hook 不会销毁。就会多次注册这些 hook。
    意味着组件销毁、创建 N 次,那么在下拉刷新或者触底的时候就会触发 N 次这些 Hook。

  2. 关于页面刷新的交互
    比如有以下业务场景:列表中有点赞数,当通过列表进入详情,点赞后返回,列表中点赞数要+1。

    方案 1:列表页面 onShow 生命周期再次加载一次。
    优点:onShow 操作简单,再次调取接口即可。
    缺点:比如刚刚点赞的是第 1000 条。页面只会再次加载一次第一组分页数据,页面就会被强制拉上去。要是页面再次加载是将刚刚加载的数据再次请求一次,那么一次请求数据过于庞大也会有问题。且接口请求需要时间,点赞数更新需要等待。
    方案 2:本地数据管理,精准刷新。
    列表页面 onShow 声明周期的时候,不做任何处理。
    点赞评论等操作的时候,除了接口调用,同时也要将本地数据同步进行修改。
    优点:减少接口调用,提升性能,优化体验。
    缺点:需要跨组件甚至跨页面,精确管理本地数据,给点赞、评论等操作增加副作用冗余操作,逻辑复杂。后期维护增加困难。且如果是帖子取消置顶等交互操作无法判定帖子原来的位置,同样需要接口支持。
    方案 3:即为方案 1 的优化版本。
    onShow 生命周期再次加载一次。不过设定一次加载最多条数。以防一次性加载数量过于庞大。

    根据项目来决定使用哪种方案。相比之下,个人比较倾向于方案 3 的使用。

后记

Hook 是一个非常好用的功能。
他类似于一种纬度,横向切片式的操作。
可以封装一些无需依赖状态的公共方法。

同样 Hook 也要时刻注意他的触发时机,
很容易就造成莫名其妙的多触发了很多次。
埋下了性能的深坑。