1. 前言
在移动应用开发里,列表数据的分页加载是一个高频需求。用户在浏览列表时,为了提升体验,通常希望滚动到底部就能自动加载更多数据。 RuiPaging 组件正是基于此需求开发,它封装了分页加载的通用逻辑,借助 Taro 框架,可适配多端应用,帮助开发者快速实现列表的分页加载功能。
2. 实现分析
- 组件定义与类型声明:借助泛型 T 提升组件的灵活性,使组件能处理不同类型的列表数据。利用 forwardRef 让组件可以接收 ref ,方便父组件调用组件内部的方法。同时定义了 RuiPagingProps 和 RuiPagingRef 接口,明确组件的属性和可调用方法的类型。
- 状态管理,使用 useState 管理四个状态: 2.1 page :记录当前页码,初始值为 1。 2.2 isEnd :标记是否已加载完所有数据,初始值为 false 。 2.3 isLoading :标记是否正在加载数据,避免重复请求,初始值为 false 。 2.4 list :存储列表数据,初始值为 [] 。
- 自动加载逻辑:在 useEffect 钩子中,若 auto 属性为 true 且当前没有在加载数据,就触发数据加载操作。
- 滚动加载逻辑:通过 ScrollView 组件的 onScrollToLower 事件监听滚动到底部的操作,若未加载完所有数据且当前没有在加载数据,就触发新数据的加载。
- 数据处理逻辑 5.1 complete 方法:处理数据加载完成后的逻辑,更新列表数据和页码,判断是否加载完所有数据。 5.2 reload 方法:重置状态并重新加载第一页数据。
- 方法暴露:使用 useImperativeHandle 暴露 complete 和 reload 方法,方便父组件调用。
3. 类型定义
- 定义一个空的ListItem接口,作为列表项的基本类型;
- 导出RuiPagingProps接口,用于定义分页组件的属性,使用泛型T继承ListItem;
- 可选的value属性,类型为T数组,用于传递初始数据;
- 可选的auto属性,类型为boolean,用于控制是否自动加载第一页数据;
- 必需的onChange回调函数,当列表数据变化时调用,接收T数组作为参数;
- 可选的size属性,类型为number,用于设置每页数据条数;
- 必需的onQuery回调函数,用于触发数据查询,接收页码和页面大小作为参数;
- 必需的children属性,类型为React.ReactNode,用于接收子组件。
- 导出RuiPagingRef接口,用于定义分页组件的引用方法,使用泛型T继承ListItem;
- complete方法定义,用于完成数据加载,接收T数组或布尔值作为参数;
- reload方法定义,用于重新加载数据,无参数。
typescript
interface ListItem {}
export interface RuiPagingProps<T extends ListItem> {
value?: T[];
auto?: boolean;
onChange: (e: T[]) => void;
size?: number;
onQuery: (page: number, size: number) => void;
children: React.ReactNode;
}
export interface RuiPagingRef<T extends ListItem> {
complete: (value: T[] | boolean) => void;
reload: () => void;
}
4. 传入变量和状态
- 使用解构赋值语法从props中提取组件需要的属性;
- 设置auto属性默认值为true,表示组件挂载后自动加载第一页数据;
- 设置onChange回调函数默认为空函数,当数据发生变化时触发;
- 设置size属性默认值为10,表示每次查询的数据条数;
- 设置onQuery回调函数默认为空函数,用于执行实际的数据查询逻辑;
- 提取children属性,用于渲染子组件。
- 初始化page状态为1,表示当前页码;
- 初始化isEnd状态为false,表示是否已加载完所有数据;
- 初始化isLoading状态为false,表示是否正在加载数据;
- 初始化list状态为空数组,用于存储加载到的数据列表。
ini
const {
auto = true,
onChange = () => {},
size = 10,
onQuery = () => {},
children,
} = props;
const [page, setPage] = useState(1);
const [isEnd, setIsEnd] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [list, setList] = useState<T[]>([]);
5. 初始化是否自动加载
- 使用useEffect钩子,用于在组件挂载时执行副作用操作;
- 检查auto属性是否为true且当前未在加载数据;
- 如果条件满足,设置加载状态为true,表示开始加载数据;
- 调用onQuery函数,传入当前页码和每页大小来获取数据。
scss
useEffect(() => {
if (auto && !isLoading) {
setIsLoading(true);
onQuery(page, size);
}
}, []);
6. 滚动加载实现
- 定义一个名为onScrollToLower的函数,当用户滚动到列表底部时触发;
- 检查是否未达到数据末尾且当前未在加载数据;
- 如果条件满足,设置加载状态为true,表示开始加载数据;
- 调用onQuery函数,传入当前页码和每页大小来获取更多数据。
scss
const onScrollToLower = () => {
if (!isEnd && !isLoading) {
setIsLoading(true);
onQuery(page, size);
}
};
7. 更新数据和重新加载实现
- 定义一个名为complete的函数,接收一个泛型数组或布尔值作为参数,用于处理分页加载完成后的逻辑;
- 设置加载状态为false,表示数据加载已完成;
- 判断传入的参数是否为数组类型;
- 如果参数是数组,则执行以下操作;
- 使用concat方法将新数据连接到现有列表末尾,创建一个新的数组;
- 调用onChange回调函数,将更新后的完整列表传递给父组件;
- 更新组件内部的状态,保存新的列表数据;
- 判断新获取的数据长度是否小于请求的页面大小;
- 如果数据长度小于页面大小,说明已加载完所有数据,设置结束状态为true;
- 如果数据长度等于页面大小,说明可能还有更多数据,将页码增加1;
- 如果传入的参数不是数组(通常表示加载失败),直接设置结束状态为true。
- 定义reload函数,用于重新加载数据;
- 将页码重置为1,从第一页开始加载;
- 清空当前列表数据;
- 将结束状态设置为false,允许重新加载数据;
- 调用onQuery函数,使用初始页码和页面大小开始加载数据。
scss
const complete = (value: T[] | boolean) => {
setIsLoading(false);
if (Array.isArray(value)) {
const newList = list.concat(value);
onChange(newList);
setList(newList);
if (value.length < size) {
setIsEnd(true);
} else {
setPage(page + 1);
}
} else {
setIsEnd(true);
}
};
const reload = () => {
setPage(1);
setList([]);
setIsEnd(false);
onQuery(1, size);
};
8. DOM 结构
ini
<ScrollView
className="rui-paging-content"
scrollY
onScrollToLower={onScrollToLower}
>
{children}
{list.length === 0 && isEnd && (
<View className="rui-flex-cc h-[60px] text-[24px] text-[#999]">
暂无数据
</View>
)}
{isEnd && list.length > 0 && (
<View className="rui-flex-cc h-[60px] text-[24px] text-[#999]">
没有更多数据了
</View>
)}
{isLoading && (
<View className="rui-flex-cc h-[60px] text-[24px] text-[#999]">
加载中...
</View>
)}
</ScrollView>
9. 总结
到这一步基本功能就实现完成,当然如果需要添加下拉刷新、数据量大的虚拟列表等功能,就需要单独开发补充,到这一步已经满足基本的开发需求。
10. 完整代码
ini
import { useEffect, useState, forwardRef, useImperativeHandle } from "react";
import { ScrollView, View } from "@tarojs/components";
import "./paging.scss";
function RuiPaging<T extends ListItem>(
props: RuiPagingProps<T>,
ref: React.ForwardedRef<RuiPagingRef<T>>
) {
const {
auto = true,
onChange = () => {},
size = 10,
onQuery = () => {},
children,
} = props;
const [page, setPage] = useState(1);
const [isEnd, setIsEnd] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [list, setList] = useState<T[]>([]);
useEffect(() => {
if (auto && !isLoading) {
setIsLoading(true);
onQuery(page, size);
}
}, []);
const onScrollToLower = () => {
if (!isEnd && !isLoading) {
setIsLoading(true);
onQuery(page, size);
}
};
const complete = (value: T[] | boolean) => {
setIsLoading(false);
if (Array.isArray(value)) {
const newList = list.concat(value);
onChange(newList);
setList(newList);
if (value.length < size) {
setIsEnd(true);
} else {
setPage(page + 1);
}
} else {
setIsEnd(true);
}
};
const reload = () => {
setPage(1);
setList([]);
setIsEnd(false);
onQuery(1, size);
};
useImperativeHandle(ref, () => ({
complete,
reload,
}));
return (
<ScrollView
className="rui-paging-content"
scrollY
onScrollToLower={onScrollToLower}
>
{children}
{list.length === 0 && isEnd && (
<View className="rui-flex-cc h-[60px] text-[24px] text-[#999]">
暂无数据
</View>
)}
{isEnd && list.length > 0 && (
<View className="rui-flex-cc h-[60px] text-[24px] text-[#999]">
没有更多数据了
</View>
)}
{isLoading && (
<View className="rui-flex-cc h-[60px] text-[24px] text-[#999]">
加载中...
</View>
)}
</ScrollView>
);
}
export default forwardRef(RuiPaging);
interface ListItem {}
export interface RuiPagingProps<T extends ListItem> {
value?: T[];
auto?: boolean;
onChange: (e: T[]) => void;
size?: number;
onQuery: (page: number, size: number) => void;
children: React.ReactNode;
}
export interface RuiPagingRef<T extends ListItem> {
complete: (value: T[] | boolean) => void;
reload: () => void;
}
11. 组件使用
注意:可以配合【taro react】 ---- useModel 数据双向绑定 hook 实现进行开发,更加方便快捷,同时减少代码量。
javascript
const paging = useRef<RuiPagingRef<Course>>(null);
const [keyword, keywordModel] = useModel("");
const [videos, model] = useModel<Course[]>([]);
// 教程列表
const getCourseList = (pageNum, pageSize) => {
https
.getCourseList({ keyword, classify_id: "", pageNum, pageSize })
.then((res) => {
const data = res?.data?.data;
paging?.current?.complete(data?.records);
})
.catch((_) => {
paging?.current?.complete(false);
});
};
return (
<View className="bg-[#eee]">
<RuiPaging {...model()} ref={paging} onQuery={getCourseList}>
<View>
{/* 搜索框 */}
<TutorialSearch {...keywordModel()} onSearch={init} />
{/* 视频列表 */}
<View className="mt-[30px] ml-[30px] mr-[30px] pb-[30px]">
<TutorialList videos={videos} />
</View>
</View>
</RuiPaging>
</View>
);
12. 总结
- 代码复用性高 :将通用的分页逻辑封装在组件中,不同页面的列表只需引入该组件就能实现分页加载,减少了重复代码。
- 维护成本低 :分页逻辑集中管理,若需要修改或优化,只需在组件内部进行修改,无需在多个地方改动代码。
- 交互体验统一 :组件内部统一处理加载中和没有更多数据的提示,保证了不同列表的交互体验一致。
- 轻量级 :不依赖其他第三方库,减少了项目的依赖体积。
- 可定制性强 :可以根据项目的具体需求对组件进行修改和扩展。