Post

Load More in H5 Pages

Best practices for implementing load-more pagination in H5 pages with React components.

Load More in H5 Pages

H5 列表页做分页,移动端不适合翻页按钮,上拉加载是标准做法。需求来了,先看看 vant 有没有现成的。

image.png image-dark

页面基础

页面框架是 React,整体结构用 NavBox 组件包着,列表内容塞进去:

1
2
3
4
5
6
7
import React from "react";
import NavBox from "@/components/NavBox";

const InvitationList: React.FC = () => {
  return <NavBox title="我邀请的人">{/* 列表内容 */}</NavBox>;
};
export default InvitationList;

数据接口的类型定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// interface.tsx
export namespace Invite {
  export interface reqInvite {
    pageNum: number;
    pageSize: number;
  }
  export interface resInviteItem {
    id: number | string;
    name: string;
    phone: string;
    time: string;
    status: number;
  }
}

请求接口封装好了,resInviteItem 是每条记录的类型:

1
2
3
4
5
6
7
8
import fetch from "@/api";
import { ListResponse, Invite } from "@/api/interface";

export const getList = (
  data: Invite.reqInvite
): Promise<ListResponse<Invite.resInviteItem>> => {
  return fetch<ListResponse<Invite.resInviteItem>>("/invitationList", data, "POST");
};

先让页面正常展示,pageSize 固定 10 条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { useEffect, useState } from "react";
import NavBox from "@/components/NavBox";
import InviteCard from "@/components/InviteCard";
import { Invite } from "@/api/interface";
import { getList } from "@/api/modules/invite";

const InvitationList: React.FC = () => {
  const [data, setData] = useState<Invite.resInviteItem[]>([]);
  useEffect(() => {
    getList({ pageNum: 1, pageSize: 10 }).then((res) => {
      setData(res.data.records);
    });
  }, []);

  return (
    <NavBox title="我邀请的人">
      {data.map((item) => (
        <InviteCard info={item} key={item.id} />
      ))}
    </NavBox>
  );
};
export default InvitationList;

页面有了,接下来实现上拉加载。

先试 vant,发现不对劲

react-vant 有个 List 组件,监听 scroll 事件,快到底部时触发 onLoad。有个 offset 属性,默认 300,意思是距底部 300px 时就触发。

听起来挺合理。但仔细想了一下——假设列表 6 条,在某个机型上最后一条刚好有一部分在视口外,那没问题,滚一点就能触发。但如果换个更高的屏幕,6 条数据全部在视口内,根本到不了 offset,上拉加载就永远触发不了。官方文档原话是:「理想情况下每次请求获取的数据条数应能够填满一屏高度」——这就是在说这个问题。

还有一个问题:开发环境下 onLoad 会触发两次,第二次调接口就直接拿到第二页数据了,出现重复请求。

放弃 vant,自己实现。

自定义 LoadList 组件

核心思路:不用 scroll 事件,改用 IntersectionObserver——监听一个 loading 指示器元素是否进入视口,进了就触发加载。

LoadList 组件的结构是两部分:父组件传入的 children(列表内容),以及紧跟其后的 LoadingBox 组件。LoadingBox 进入视口 → 加载下一页。

有个顺带的好处:页面初始时 children 为空,LoadingBox 直接在视口里,所以第一次数据请求不需要单独处理——observer 一挂上就会触发,自动发第一页请求。

LoadingBox

LoadingBox 接受一个 finished 参数,控制显示「加载中…」还是「没有更多数据了」。

LoadList 需要拿到这个组件的 DOM 节点来 observe 它,所以用 forwardRef 把 ref 暴露出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface LoadingBoxProps {
  finished: boolean;
  ref: RefObject<HTMLDivElement>;
}
const LoadingBox = forwardRef<HTMLDivElement, LoadingBoxProps>((props, ref) => {
  const { finished } = props;
  const [desc, setDesc] = useState("加载中...");
  useEffect(() => {
    if (finished) {
      setDesc("没有更多数据了");
    }
  }, [finished]);
  return (
    <div className={styles.loading} ref={ref}>
      {finished ? (
        <span>{desc}</span>
      ) : (
        <Loading size="1.6rem" type="spinner">
          {desc}
        </Loading>
      )}
    </div>
  );
});

注意 forwardRef 的泛型顺序:第一个是 DOM 类型(HTMLDivElement),第二个是 props 类型(LoadingBoxProps)。函数形参里正好相反,第一个是 props,第二个是 ref。

Observer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const [finished, setFinished] = useState(false);
const loadingRef = useRef<HTMLDivElement>(null);

const handlerLoading = async () => {
  console.log("loading enter viewport");
};

useEffect(() => {
  const options = {
    rootMargin: "0px",
    threshold: 0.9,
  };
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        handlerLoading();
      }
    });
  }, options);

  const currentRef = loadingRef.current;
  if (currentRef) {
    observer.observe(currentRef);
  }
  return () => {
    if (currentRef) {
      observer.unobserve(currentRef);
    }
  };
}, []);

这里遇到了一个 React 的坑

现在要实现 handlerLoading:每次触发时 pageNum +1,调接口,把数据回调给父组件。

很直觉地想用 setState 来跟踪 pageNum:

1
2
3
4
5
6
const handlerLoading = async () => {
  setInitParams((prev) => ({ ...prev, pageNum: prev.pageNum + 1 }));

  const { data } = await api(initParams); // 拿不到更新后的值
  callback(data);
};

跑起来发现不对——setState 之后立刻读 initParams,拿到的还是旧值。

这跟 React 的渲染机制有关:state 是一张快照setState 不会立即更新,要等下一次渲染才生效。所以你在同一次执行里刚 setState 完就读值,读到的是这次渲染开始时的快照,不是你刚写进去的值。

另外,组件每次重新渲染,函数体内的普通变量(let num = 0 这种)都会重新初始化——useState 解决了持久化,但解决不了”立即读到更新值”的问题。

解法是 useRefuseRef 声明的变量不触发重渲染,组件渲染时也不会重置它,读 .current 永远是最新值:

1
2
3
4
5
6
7
8
const initParams = useRef({ pageNum: 0, pageSize: 10 });

const handlerLoading = async () => {
  initParams.current.pageNum += 1;

  const { data } = await api(initParams.current);
  callback(data);
};

边界条件:没有更多数据了

两种判断方式:多请求一次看是否返回空,或者用 total 字段对比。后端接口有返回 total,用第二种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const listNum = useRef(0);
const total = useRef(0);

const handlerLoading = async () => {
  if (listNum.current === total.current && total.current) {
    setFinished(true);
    return;
  }

  initParams.current.pageNum += 1;
  const { data } = await api(initParams.current);
  total.current = data.total;
  listNum.current += data.records.length;
  callback(data);
};

totallistNum 也用 useRef 保存,原因一样——需要跨渲染持久化,且不需要触发重渲染。

空数据状态

接口返回 total 为 0 时,不该显示 loading,应该显示 Empty:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const [isEmpty, setIsEmpty] = useState(false);

const handleEmptyState = (total: number) => {
  if (total === 0) {
    setIsEmpty(true);
  } else if (listNum.current === total) {
    setFinished(true);
  }
};

const handlerLoading = async () => {
  if (listNum.current === total.current && total.current) {
    setFinished(true);
    return;
  }

  initParams.current.pageNum += 1;
  const { data } = await api(initParams.current);
  total.current = data.total;
  listNum.current += data.records.length;
  callback(data);
  handleEmptyState(data.total);
};

根据 isEmpty 决定渲染哪个:

1
2
3
4
5
6
7
8
9
10
11
12
return (
  <>
    {!isEmpty ? (
      <div className={styles.loadlList}>
        {children}
        <LoadingBox ref={loadingRef} finished={finished} />
      </div>
    ) : (
      <Empty description="暂无数据" {...emptyProps} />
    )}
  </>
);

LoadList 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import React, {
  useEffect,
  useRef,
  useState,
  forwardRef,
  RefObject,
} from "react";
import styles from "./loadList.module.scss";
import { Loading, Empty } from "react-vant";

interface LoadingBoxProps {
  finished: boolean;
  ref: RefObject<HTMLDivElement>;
}
const LoadingBox = forwardRef<HTMLDivElement, LoadingBoxProps>((props, ref) => {
  const { finished } = props;
  const [desc, setDesc] = useState("加载中...");
  useEffect(() => {
    if (finished) {
      setDesc("没有更多数据了");
    }
  }, [finished]);
  return (
    <div className={styles.loading} ref={ref}>
      {finished ? (
        <span>{desc}</span>
      ) : (
        <Loading size="1.6rem" type="spinner">
          {desc}
        </Loading>
      )}
    </div>
  );
});

interface LoadListProps {
  children?: React.ReactNode;
  api: (params: any) => Promise<any>;
  callback: (params: any) => void;
  emptyProps?: React.ComponentProps<typeof Empty>;
}
const LoadList: React.FC<LoadListProps> = ({
  children,
  api,
  callback,
  emptyProps,
}) => {
  const [isEmpty, setIsEmpty] = useState(false);
  const [finished, setFinished] = useState(false);
  const loadingRef = useRef<HTMLDivElement>(null);
  const initParams = useRef({ pageNum: 0, pageSize: 10 });
  const listNum = useRef(0);
  const total = useRef(0);

  const handlerLoading = async () => {
    if (listNum.current === total.current && total.current) {
      setFinished(true);
      return;
    }

    initParams.current.pageNum += 1;
    const { data } = await api(initParams.current);
    total.current = data.total;
    listNum.current += data.records.length;
    callback(data);
    handleEmptyState(data.total);
  };

  const handleEmptyState = (total: number) => {
    if (total === 0) {
      setIsEmpty(true);
    } else if (listNum.current === total) {
      setFinished(true);
    }
  };

  useEffect(() => {
    const options = {
      rootMargin: "0px",
      threshold: 0.9,
    };
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          handlerLoading();
        }
      });
    }, options);

    const currentRef = loadingRef.current;
    if (currentRef) {
      observer.observe(currentRef);
    }
    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, []);

  return (
    <>
      {!isEmpty ? (
        <div className={styles.loadlList}>
          {children}
          <LoadingBox ref={loadingRef} finished={finished} />
        </div>
      ) : (
        <Empty description="暂无数据" {...emptyProps} />
      )}
    </>
  );
};

export default LoadList;

页面使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React, { useState } from "react";
import NavBox from "@/components/NavBox";
import InviteCard from "@/components/InviteCard";
import { Invite } from "@/api/interface";
import { getList } from "@/api/modules/invite";
import LoadList from "@/components/LoadList";

const InvitationList: React.FC = () => {
  const [data, setData] = useState<Invite.resInviteItem[]>([]);

  const getInvitationList = ({
    records,
  }: {
    records: Invite.resInviteItem[];
  }) => {
    setData((v) => [...v, ...records]);
  };

  return (
    <NavBox title="我邀请的人">
      <LoadList
        api={getList}
        callback={getInvitationList}
        emptyProps=
      >
        {data.map((item) => (
          <InviteCard info={item} key={item.id} />
        ))}
      </LoadList>
    </NavBox>
  );
};
export default InvitationList;

用完才发现一个问题:LoadList 内部把 pageNumpageSize 写死在组件里了。如果某个接口的分页字段不叫 pageNum 而叫 num,或者需要额外传其他参数,组件就改不了。

workaround 是在外面包一层函数,把参数在这里处理好,再传给 api

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const getListData = (params: Invite.reqInvite) => {
  const form: Invite.reqInvite = {
    ...params,
    type: 1,
  };
  return getList(form);
};

return (
  <NavBox title="我邀请的人">
    <LoadList
      api={getListData}
      callback={getInvitationList}
      emptyProps=
    >
      {data.map((item) => (
        <InviteCard info={item} key={item.id} />
      ))}
    </LoadList>
  </NavBox>
);

能用,但不是最优解——LoadList 把分页参数的结构假设写死了,调用方每次都要做这层适配。如果要改,应该在组件设计上就把参数构造的控制权还给外部。算是当时没想到的地方,先这样用着。

This post is licensed under CC BY 4.0 by the author.