在 Vue 3 中,我们可以通过自定义 Hook 来将业务逻辑从组件中抽离出来,使得我们的组件更加简洁和易于维护。今天,我们将介绍如何实现一个自定义的 PullToRefresh Hook,该 Hook 可以帮助我们在列表组件中轻松实现下拉刷新和上拉加载更多功能。

背景

在许多移动端应用或 Web 应用中,下拉刷新和上拉加载更多功能是非常常见的交互方式。为了实现这一功能,我们需要管理分页状态、加载状态、数据合并等多个方面的逻辑。通过使用 Vue 3 的组合式 API(Composition API),我们可以将这些逻辑封装成一个通用的 Hook,使得代码更加复用和易于维护。

  1. 需求分析

我们需要实现以下功能:
• 支持下拉刷新和上拉加载更多。
• 支持分页加载,每次请求时,都会返回一页的数据。
• 支持数据的去重处理,防止加载重复的数据。
• 支持刷新时清空旧数据。
• 支持自定义数据格式化。
• 支持刷新完成后的回调,以便处理列表数据。

  1. 代码实现

下面是我们实现的 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 组件源码(仅供参考,相关逻辑根据项目实际业务场景)

![](https://ihopefulchina.github.io/post-images/1739756274673.png)