设计思路
- 监听ScrollView滑动,当滑动到底部时触发加载数据的动作。正好可以使用
lowerThreshold和onScrollToLower属性,lowerThreshold定义距离底部/右边多远时触发onScrollToLower。 - 组件内需要有变量记录当前页
current、数据条数size、数据总条数total,当滚动到底部/右边时,current+1请求数据,当获取的数据条数等于总条数时,不再请求数据。 - 需要搜索参数
searchParam,发送请求时能够携带参数,方便在使用时根据业务携带不同的参数。 - 把
children参数定义为一个接收数据的方法,并返回节点,用于渲染。因此组件不关心UI实现,只要完成分页加载的功能。 - 声明使用哪个请求接口
useApi,由于项目使用RTK Query,这里以RTK Query为例。
具体实现
推导useMutation钩子参数及返回值类型
type ParamsAndResult<T> = T extends UseMutation<
MutationDefinition<infer Request, any, string, infer Response, string>
>
? {
searchParam: Request extends { input: infer SearchParam }
? SearchParam
: never;
result: Response extends { records: (infer Result)[] } ? Result : never;
}
: never;
数据保存及请求
const totalRef = useRef<number>(0); // 列表总数
const isInitedRef = useRef<boolean>(false); // 是否已初始化
const isLoadingRef = useRef<boolean>(false); // 是否加载中
const currentPageRef = useRef<number>(1); // 当前页数
const [isRefetch, setRefetch] = useState<boolean>(false); // 是否下拉刷新
const [dataList, setDataList] = useState<ParamsAndResult<T>["result"][]>([]); // 数据列表
const [getDataList, { isLoading }] = useApi();
// 加载数据
const fetchData = async (param) => {
if (isLoadingRef.current) return;
try {
isLoadingRef.current = true;
const payload = await getDataList({
current: currentPageRef.current,
size: defaultSize,
input: param,
}).unwrap();
totalRef.current = payload.total;
if (currentPageRef.current === 1) {
setDataList(payload.records);
setScrollTop((pre) => (pre === 0 ? 0.1 : 0));
} else {
setDataList((pre) => pre.concat(payload.records));
}
} catch (error: any) {
Taro.showModal({
content: error.message,
confirmText: "我知道了",
showCancel: false,
});
} finally {
isLoadingRef.current = false;
}
};
// 获取新数据、下拉刷新
const fetchNewData = async () => {
setRefetch(true);
currentPageRef.current = 1;
await fetchData(searchParam);
setRefetch(false);
};
// 获取更多、滚动加载
const fetchMoreData = () => {
if (dataList.length >= totalRef.current) return;
currentPageRef.current++;
fetchData(searchParam);
};
useEffect(() => {
if (!isInitedRef.current || refetchOnParamsChange) {
// 初始进入时或搜索参数变动时,加载新数据
isInitedRef.current = true;
fetchNewData();
}
}, [refetchOnParamsChange, searchParam]);
安全区
部分手机存在导航条,当滑动到结尾时可能存在数据被遮挡的情况。可以使用预定义的环境变量(safe-area-inset-bottom)留出足够的空间。
.safeAreaBottom {
padding-bottom: env(safe-area-inset-bottom);
}
children定义为函数
把children定义成函数,由父组件调用时指定渲染UI,组件不关心具体的UI实现。
// children类型声明
children: (
dataList: ParamsAndResult<T>["result"][],
refetch: () => void
) => React.ReactNode;
使用:
<AutoLoadScrollView
ref={scrollViewRef}
useApi={useGetMocksMutation}
showSafeArea
containerClassName={styles.autoLoadScrollView}
searchParam={{ keyword: "O" }}
>
{(mocks) =>
mocks.map((mock) => (
<View key={mock.id} className={styles.item}>
<Image
className={styles.img}
lazyLoad
mode="aspectFill"
src={mock.url}
/>
<Text className={styles.title}>{mock.title}</Text>
</View>
))
}
</AutoLoadScrollView>
自定义ref暴露的属性及方法
// ref穿透
export default React.forwardRef<AutoLoadScrollViewRef<any>, Props<any>>(
AutoLoadScrollView
)
// 自定义由 ref 暴露给父组件的数据
useImperativeHandle(ref, () => ({
dataTotal: totalRef.current,
scrollTo: setScrollTop,
dataList,
}));
// ref穿透
export default React.forwardRef<AutoLoadScrollViewRef<any>, Props<any>>(
AutoLoadScrollView
)
// 自定义由 ref 暴露给父组件的数据
useImperativeHandle(ref, () => ({
dataTotal: totalRef.current,
scrollTo: setScrollTop,
dataList,
}));
代码
AutoLoadScrollView组件:
import React, {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import Taro from "@tarojs/taro";
import { View, type ScrollViewProps, ScrollView } from "@tarojs/components";
import type { MutationDefinition } from "@reduxjs/toolkit/query";
import type { UseMutation } from "@reduxjs/toolkit/dist/query/react/buildHooks";
import styles from "./index.module.less";
type ParamsAndResult<T> = T extends UseMutation<
MutationDefinition<infer Request, any, string, infer Response, string>
>
? {
searchParam: Request extends { input: infer SearchParam }
? SearchParam
: never;
result: Response extends { records: (infer Result)[] } ? Result : never;
}
: never;
type Props<T> = Omit<ScrollViewProps, "children" | "ref" | "className"> & {
/** 获取数据的api */
useApi: T;
/** 容器className */
containerClassName?: string;
/** ScrollView className */
scrollViewClassName?: string;
/** 单次请求条数 */
defaultSize?: number;
/** 显示安全区域 */
showSafeArea?: boolean;
/** 搜索参数 */
searchParam?: ParamsAndResult<T>["searchParam"];
/** 搜索参数变更时重新请求 */
refetchOnParamsChange?: boolean;
/** 子组件渲染函数 */
children: (
dataList: ParamsAndResult<T>["result"][],
refetch: () => void
) => React.ReactNode;
/** 数据变更回调 */
onDataChange?: (data: ParamsAndResult<T>["result"][]) => void;
};
export type AutoLoadScrollViewRef<T> = {
/** 数据总条数 */
dataTotal: number;
/** 滚动到 */
scrollTo: React.Dispatch<React.SetStateAction<number>>;
/** 获取的数据 */
dataList: ParamsAndResult<T>["result"][];
};
const AutoLoadScrollView = <
T extends UseMutation<MutationDefinition<any, any, string, any, string>>
>(
props: Props<T>,
ref: React.ForwardedRef<AutoLoadScrollViewRef<T>>
) => {
const {
containerClassName,
scrollViewClassName,
showSafeArea = false,
searchParam = {},
defaultSize = 10,
refetchOnParamsChange = false,
useApi,
children,
onScroll,
onDataChange,
...otherProps
} = props;
const totalRef = useRef<number>(0); // 列表总数
const isInitedRef = useRef<boolean>(false); // 是否已初始化
const isLoadingRef = useRef<boolean>(false); // 是否加载中
const currentPageRef = useRef<number>(1); // 当前页数
const [scrollTop, setScrollTop] = useState<number>(0); // 滚动位置
const [isRefetch, setRefetch] = useState<boolean>(false); // 是否下拉刷新
const [dataList, setDataList] = useState<ParamsAndResult<T>["result"][]>([]); // 数据列表
const [getDataList, { isLoading }] = useApi();
useImperativeHandle(ref, () => ({
dataTotal: totalRef.current,
scrollTo: setScrollTop,
dataList,
}));
// 底部提示组件
const bottomRender = useMemo(() => {
if (isLoading || dataList.length > 0)
return (
<View
className={`${styles.bottomTips} ${
showSafeArea ? styles.safeAreaBottom : ""
}`}
>
<View className={styles.divider} />
<View>{isLoading ? "努力加载中" : "已经到底了"}</View>
<View className={styles.divider} />
</View>
);
return <View className={styles.emptyText}>暂无数据</View>;
}, [dataList.length, isLoading, showSafeArea]);
// 加载数据
const fetchData = async (param) => {
if (isLoadingRef.current) return;
try {
isLoadingRef.current = true;
const payload = await getDataList({
current: currentPageRef.current,
size: defaultSize,
input: param,
}).unwrap();
totalRef.current = payload.total;
if (currentPageRef.current === 1) {
setDataList(payload.records);
setScrollTop((pre) => (pre === 0 ? 0.1 : 0));
} else {
setDataList((pre) => pre.concat(payload.records));
}
} catch (error: any) {
Taro.showModal({
content: error.message,
confirmText: "我知道了",
showCancel: false,
});
} finally {
isLoadingRef.current = false;
}
};
// 获取新数据、下拉刷新
const fetchNewData = async () => {
setRefetch(true);
currentPageRef.current = 1;
await fetchData(searchParam);
setRefetch(false);
};
// 获取更多、滚动加载
const fetchMoreData = () => {
if (dataList.length >= totalRef.current) return;
currentPageRef.current++;
fetchData(searchParam);
};
useEffect(() => {
if (!isInitedRef.current || refetchOnParamsChange) {
// 初始进入时或搜索参数变动时,加载新数据
isInitedRef.current = true;
fetchNewData();
}
}, [refetchOnParamsChange, searchParam]);
useEffect(() => {
if (onDataChange) onDataChange(dataList);
}, [dataList, onDataChange]);
return (
<View className={`${styles.container} ${containerClassName}`}>
<ScrollView
id="auto-load-scroll-view"
className={`${styles.scrollView} ${scrollViewClassName}`}
scrollY
refresherBackground="#f7f7f7"
refresherDefaultStyle="black"
lowerThreshold={50}
{...otherProps}
refresherEnabled
onRefresherRefresh={fetchNewData}
onScrollToLower={fetchMoreData}
scrollTop={scrollTop}
refresherTriggered={isRefetch}
>
{dataList.length > 0 && children(dataList, fetchNewData)}
{bottomRender}
</ScrollView>
</View>
);
};
export default React.forwardRef<AutoLoadScrollViewRef<any>, Props<any>>(
AutoLoadScrollView
) as <T>(
props: Props<T> & {
ref?: React.ForwardedRef<AutoLoadScrollViewRef<T> | undefined>;
}
) => React.ReactElement;
AutoLoadScrollView组件样式 less:
@weak-color: #808080;
.container {
overflow: hidden;
height: 100%;
width: 100%;
box-sizing: border-box;
.scrollView {
height: 100%;
position: relative;
font-size: 28px;
}
}
.emptyText {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 50%);
color: @weak-color;
}
.bottomTips {
display: flex;
align-items: center;
text-align: center;
padding: 20px;
gap: 20px;
color: @weak-color;
.divider {
flex: 1;
height: 1px;
background-color: @weak-color;
}
}
.safeAreaBottom {
padding-bottom: env(safe-area-inset-bottom);
}