Taro ScrollView分页请求

385 阅读4分钟

设计思路

  • 监听ScrollView滑动,当滑动到底部时触发加载数据的动作。正好可以使用lowerThresholdonScrollToLower属性,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.gif

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);
}