在 Vue 3 中,我们可以通过自定义 Hook 来将业务逻辑从组件中抽离出来,使得我们的组件更加简洁和易于维护。今天,我们将介绍如何实现一个自定义的 PullToRefresh Hook,该 Hook 可以帮助我们在列表组件中轻松实现下拉刷新和上拉加载更多功能。
背景
在许多移动端应用或 Web 应用中,下拉刷新和上拉加载更多功能是非常常见的交互方式。为了实现这一功能,我们需要管理分页状态、加载状态、数据合并等多个方面的逻辑。通过使用 Vue 3 的组合式 API(Composition API),我们可以将这些逻辑封装成一个通用的 Hook,使得代码更加复用和易于维护。
- 需求分析
我们需要实现以下功能:
• 支持下拉刷新和上拉加载更多。
• 支持分页加载,每次请求时,都会返回一页的数据。
• 支持数据的去重处理,防止加载重复的数据。
• 支持刷新时清空旧数据。
• 支持自定义数据格式化。
• 支持刷新完成后的回调,以便处理列表数据。
- 代码实现
下面是我们实现的 usePullToRefresh Hook 代码:
import { computed, onMounted, ref } from 'vue';
import { useLockFn } from '~/hooks';
import { removeRepetition } from '~/utils';
export enum PullToRefreshState {
/**
* 普通状态
*/
none,
/**
* 刷新中
*/
refreshing,
/**
* 添加中
*/
pushing,
}
export interface IPullRefreshHookRefreshParams {
/**
* 刷新时是否清空旧数组
*
* @default false
*/
clearList?: boolean;
}
interface IUsePullToRefreshOption<T> {
/** 默认数据 */
defaultData?: T[];
/** 默认初始插入数据 */
defaultInsertData?: T[];
dataKey?: string;
/** 初始是否发出请求 */
initRequest?: boolean;
/** 获取数据 */
getData: (data: Record<string, any> & { pageNum: number }) => Promise<{ data?: { list?: T[]; isLastPage?: boolean; total?: number } }>;
/** 数据格式化 */
dataFormat?(data: T[]): any[];
/** 获取数据完成回调,返回本次数据 */
onAfterLoad?(data: T[], currentParams: Record<string, any> & { pageNum: number }, total: number, isLastPage: boolean): void;
/** 获取数据完成回调,返回当前list、currentParams */
customSpliceList?(list: T[], currentParams: Record<string, any> & { pageNum: number }): void;
}
export default function usePullToRefresh<T>(config: IUsePullToRefreshOption<T>) {
const { defaultData = [], dataKey = 'id' } = config;
const list = ref<any[]>(defaultData);
const total = ref<number>(0);
const noMore = ref<boolean>(false);
const pullToRefreshState = ref<PullToRefreshState>(PullToRefreshState.refreshing);
const requestId = ref<number>(0);
const pageNum = ref<number>(0);
const getListData = useLockFn(async (num?: number) => {
if (num !== undefined) {
pageNum.value = num;
} else {
pageNum.value++;
}
if (pageNum.value === 1) {
pullToRefreshState.value = PullToRefreshState.refreshing;
// setData([]);
} else {
pullToRefreshState.value = PullToRefreshState.pushing;
}
try {
const currentRequestId = ++requestId.value;
const currentParams = { pageNum: pageNum.value, pageSize: 10 };
const { data = {} } = await config.getData(currentParams);
if (currentRequestId !== requestId.value) {
return;
}
// eslint-disable-next-line prefer-const
let { list: dataList = [], isLastPage } = data;
dataList = config.dataFormat ? config.dataFormat(dataList) : dataList;
if (config.onAfterLoad) {
config.onAfterLoad(dataList, currentParams, data?.total || -1, !!isLastPage);
}
if (pageNum.value === 1) {
list.value = (config.defaultInsertData ? [...config.defaultInsertData, ...dataList] : dataList) || [];
} else {
dataList = removeRepetition((list.value || [])?.concat(dataList), dataKey);
list.value = dataList;
}
if (config.customSpliceList) {
config.customSpliceList(dataList, currentParams);
}
total.value = data?.total || -1;
noMore.value = isLastPage !== undefined ? !!isLastPage : data?.total !== -1 ? dataList?.length >= (data?.total || -1) : true;
} catch (error) {
if (pageNum.value === 1) {
list.value = [];
}
}
pullToRefreshState.value = PullToRefreshState.none;
});
/**
* 更新某条数据的值
*
* @param updatedata
*/
function updateById(updatedata: T, key = 'id') {
const newList = [...list.value]?.map((value) => {
if ((value as any)[key] === (updatedata as any)[key]) {
/** 改成替换更新 */
return { ...updatedata };
}
return { ...value };
});
list.value = [...newList];
}
/**
* 删除某条数据的值
*
* @param id
*/
function deleteById(id: number | string, key = 'id') {
list.value = list.value?.filter((item) => `${item[key]}` !== `${id}`);
}
/**
* 更新列表中的数据
*
* @param {T} data 需要更新的数据
* @param {(string | ((item: T, index: number) => boolean))} compare 比对逻辑
*/
function updateLisItem(data: T, compare: string | ((item: T, index: number) => boolean)) {
const isString = typeof compare === 'string';
list.value = list.value?.map((value, index) => {
if (isString) {
return (value as any)[compare] === (data as any)[compare] ? { ...value, ...data } : value;
}
return compare(value, index) ? { ...value, ...data } : value;
});
}
onMounted(() => {
if (config?.initRequest !== false) {
getListData();
}
});
function onRefresh(refreshParams?: IPullRefreshHookRefreshParams) {
if (refreshParams?.clearList) {
list.value = [];
}
getListData(1);
}
const isEmpty = computed(() => list?.value.length === 0 && pullToRefreshState?.value === PullToRefreshState.none);
return {
list,
setList: list.value,
total,
updateById,
pageNum,
updateLisItem,
deleteByIndex: (index: number) => (list.value = list.value?.filter((__, idx) => idx !== index)),
/** 根据传入的标识删除对应的数据,传入标识值和标识字段,默认id */
deleteById,
state: pullToRefreshState,
noMore,
onRefresh,
isEmpty,
onReachBottom: () => getListData(),
};
}
使用实例
// 列表数据
const pullToProps = useMMPullToRefresh<UpWorkHistoryPageVO>({
initRequest: false,
getData: (params) => {
if (type.value === EStaffBillPermission.REPAIR) {
return api['/wechat/api/repairWorkOrderSignHistory/page_GET']({ ...params, id: pageQuery?.value?.id });
}
return api['/wechat/api/upWorkOrder/historyPage_GET']({ ...params, id: pageQuery?.value?.id, type: ESignInType.SIGN_IN });
},
});
const { list, isEmpty } = toRefs(pullToProps);
<PullToRefresh
:noMore="pullToProps?.noMore"
:state="pullToProps?.state"
:isEmpty="pullToProps?.isEmpty"
@onRefresh="pullToProps?.onRefresh"
@onReachBottom="pullToProps?.onReachBottom"
refresherBackground="#fff"
>
<view class="list" v-if="list && list?.length > 0">
<view class="item" v-for="item in list" :key="item?.id">
<Item :item="item" :type="type" />
</view>
</view>
<view v-else-if="isEmpty">
<Empty marginTop="440" :emptyStr="t('common.no_data')" />
</view>
</PullToRefresh>
```
PullToRefresh 组件源码(仅供参考,相关逻辑根据项目实际业务场景)
<view class="noMore" v-if="!isEmpty && (status !== 'nomore' || (status === 'nomore' && !props.hiddeNoMoreText))">
<TnLoadmore :status="status" v-if="status" color="#999" loadingIconMode="flower" />
</view>
<Gap :height="props?.footerSpace" v-if="props?.footerSpace" :styles="{ width: '100%' }" />
</scroll-view>
</view>
