大家好,我是张三岁🤣,一只法系前端⚖️。爱分享🖋️、爱冰冰🧊🧊。
欢迎小伙伴们加我微信:maomaoibingbing,拉你进群,一起讨论,期待与大家共同成长🥂。
前言
我们经常在页面开发中遇到 渲染列表
的情况,一般情有 切换分页
和 无限追加
两种模式,无限追加的情况一般需要借助触底的钩子(回调函数)来完成。如果是小程序,会有特殊的触底钩子(生命周期)。但是如果是非移动端,就需要我们自己实现判断是否触底的功能。今天给各位小伙伴带来 React
中触底加载的一种实现方式(注:以下将使用函数式组件),希望能对各位有所帮助,蟹蟹٩('ω')و。
一、业务场景
有一个列表页,需要在页面触底时,追加下一页数据到页面中。
二、实现思路
- 封装一个函数用于监听页面是否触底。
- 触底时请求下一页数据,并追加到要渲染的数组中。
- 进一步优化代码。
三、进行编码
1. 请求数据
首先我们需要把请求数据的方法写好,此处使用 ahooks
的 useRequest
。(因为脚手架 UmiJS
内置了 ahooks
的这个函数,所以下面从 umi
引入该函数。)
全局工具函数 utils/index.js
// utils/index.js
/**
* 希望获得数组
* 如果传入的是数组则直接返回,否则返回一个空数组
* @param data 必填 传入的待处理数据
* @returns Array
*/
export const wantArray = (data) => (Array.isArray(data) ? data : []);
封装请求的 service.js
// service.js
import { request } from 'umi';
// 公告列表
export function getNoticeList(params) {
// xxx 为请求地址
return request('xxx', { params });
};
公告列表的 jsx
文件的关键代码,已加注释,可放心食用。
// 公告列表 jsx
// 以下为关键代码
import { useEffect, useState } from 'react';
import { useRequest } from 'umi';
import { Card, Spin } from 'antd';
import ArticleItem from '@/components/ArticleItem';// 文章条目组件
import { wantArray } from '@/utils';
import { getNoticeList } from './service';
export default () => {
/* ======================== 公告列表 ======================== */
const pageSize = 10;// 每一页条数,因需求不需要更改此项故用 const 定义
const [current, setCurrent] = useState(1);// 当前页码
const [list, setList] = useState([]);// 列表数组
// todo 请求数据
const { run: runGetNoticeList, loading: noticeListLoading } = useRequest(getNoticeList, {
manual: true,// 开启手动请求
formatResult: res => {// 格式化数据
setCurrent(res?.current);// 设置 当前页码
setList([...list, ...wantArray(res?.data)]);// 追加数组
}
});
// 进入页面后默认请求一次数据
useEffect(() => { runGetNoticeList({ current, pageSize }) }, []);
// 省略其他代码...
return (
<>
{/* 省略其他代码... */}
<Card tabList={[{ key: '', tab: '公告' }]}>
<Spin size="large" spinning={noticeListLoading} tip="加载中...">
{
list.map(value => {
return (
<ArticleItem
key={value?.id}
item={value}
/>
);
})
}
</Spin>
</Card>
</>
);
};
2. 是否触底
我们需要实现判断是否触底的函数,并对其进行节流处理。最后用 addEventListener
进行侦听(需要在组件销毁时销毁该侦听器)。
// 公告列表 jsx
// 以下为关键代码
import { useEffect } from "react";
import { message } from "antd";
import { throttle } from "lodash";
export default () => {
/**
* 加载更多
* 此函数内进行接口请求等操作
*/
const handleLoadMore = () => {
// 为测试效果临时使用 message
message.info("触底了~");
};
/**
* 判断是否触底
* 此函数进行判断是否触底
* @param handler 必填 判断后执行的回调函数
* @returns null
*/
const isTouchBottom = (handler) => {
// 文档显示区域高度
const showHeight = window.innerHeight;
// 网页卷曲高度
const scrollTopHeight =
document.body.scrollTop || document.documentElement.scrollTop;
// 所有内容高度
const allHeight = document.body.scrollHeight;
// (所有内容高度 = 文档显示区域高度 + 网页卷曲高度) 时即为触底
if (allHeight <= showHeight + scrollTopHeight) {
handler();
};
};
/**
* 节流 判断是否触底
* 将是否触底函数进行 节流 处理
* @returns function
*/
const useFn = throttle(() => {
// 此处调用 加载更多函数
isTouchBottom(handleLoadMore);
}, 500);
useEffect(() => {
// 开启侦听器,监听页面滚动
window.addEventListener("scroll", useFn);
// 组件销毁时移除侦听器
return () => { window.removeEventListener("scroll", useFn) };
}, []);
// 省略其他代码...
};
让我们来看下效果先:
效果还行,那我们接着进行下一步。
3. 触底加载
我们只需在触底时进行数据请求即可。在此处有一个问题,即函数式组件中侦听器无法拿到实时更新的变量。需要借助 useRef
来进行辅助。这也是在开发过程中遇到的问题之一,当时是阅读了这篇文章 《React监听事件执行的方法中如何获取最新的state》 才得以解决。
// 公告列表 jsx
// 以下为关键代码
import { useEffect, useState, useRef } from 'react';
import { useRequest } from 'umi';
import { Card, Spin, message } from 'antd';
import ArticleItem from '@/components/ArticleItem';// 文章条目组件
import { throttle } from 'lodash';
import { wantArray } from '@/utils';
import { getNoticeList } from './service';
export default () => {
/* ======================== 公告列表 ======================== */
const pageSize = 10;
const [current, setCurrent] = useState(1);
const [list, setList] = useState([]);
// 此处增加了一个变量用于保存 是否还有更多数据
const [isMore, setIsMore] = useState(true);
// todo 请求数据
const { run: runGetNoticeList, loading: noticeListLoading } = useRequest(getNoticeList, {
manual: true,
formatResult: res => {
setCurrent(res?.current);// 设置 当前页码
setList([...list, ...wantArray(res?.data)]);// 追加数组
// 如果当前页码大于等于总页数则设置 是否还有更多数据 为 false
if (current >= Math.round(res.total / pageSize)) { setIsMore(false) };
}
});
// 进入页面后默认请求一次数据
useEffect(() => { runGetNoticeList({ current, pageSize }) }, []);
// 为解决监听函数无法获取到最新 state 值的问题,使用 useRef 代替 state
const currentRef = useRef(null);
useEffect(() => { currentRef.current = current }, [current]);
const loadingRef = useRef(null);
useEffect(() => { loadingRef.current = noticeListLoading }, [noticeListLoading]);
const isMoreRef = useRef(null);
useEffect(() => { isMoreRef.current = isMore }, [isMore]);
// todo 加载更多
const handleLoadMore = () => {
if (!loadingRef.current && isMoreRef.current) {
message.info('加载下一页~');
// 防止 (current + 1) 更新不及时,创建一个临时变量
const temp = currentRef.current + 1;
setCurrent(temp);
runGetNoticeList({ current: temp, pageSize });
};
};
// todo 判断是否触底
const isTouchBottom = (handler) => {
// 文档显示区域高度
const showHeight = window.innerHeight;
// 网页卷曲高度
const scrollTopHeight =
document.body.scrollTop || document.documentElement.scrollTop;
// 所有内容高度
const allHeight = document.body.scrollHeight;
// (所有内容高度 = 文档显示区域高度 + 网页卷曲高度) 时即为触底
if (allHeight <= showHeight + scrollTopHeight) {
handler();
};
};
const useFn = throttle(() => {
// 此处调用 加载更多函数
isTouchBottom(handleLoadMore);
}, 500);
useEffect(() => {
// 开启侦听器,监听页面滚动
window.addEventListener("scroll", useFn);
// 组件销毁时移除侦听器
return () => { window.removeEventListener("scroll", useFn) };
}, []);
return (
<>
{/* 省略其他代码... */}
<Card tabList={[{ key: '', tab: '公告' }]}>
<Spin size="large" spinning={noticeListLoading} tip="加载中...">
{
list.map(value => {
return (
<ArticleItem
key={value?.id}
item={value}
/>
);
})
}
</Spin>
</Card>
</>
);
};
让我们来看下效果先:
功能虽然实现了,但是还需要继续优化。
4. 封装「触底加载hook」
如果多个页面都用到了这个触底加载的功能,就需要进行封装,因为这是一段代码,且不含 UI
部分,所以封装成一个 hook
。在 src
目录下新建文件夹并命名为 hooks
,然后新建文件夹 useTouchBottom
并在其之中新建 index.js
。
// src/hooks/useTouchBottom/index.js
// 触底加载 hook
import { useEffect } from 'react';
import { throttle } from 'lodash';
const isTouchBottom = (handler) => {
// 文档显示区域高度
const showHeight = window.innerHeight;
// 网页卷曲高度
const scrollTopHeight = document.body.scrollTop || document.documentElement.scrollTop;
// 所有内容高度
const allHeight = document.body.scrollHeight;
// (所有内容高度 = 文档显示区域高度 + 网页卷曲高度) 时即为触底
if (allHeight <= showHeight + scrollTopHeight) {
handler();
}
};
const useTouchBottom = (fn) => {
const useFn = throttle(() => {
if (typeof fn === 'function') {
isTouchBottom(fn);
};
}, 500);
useEffect(() => {
window.addEventListener('scroll', useFn);
return () => {
window.removeEventListener('scroll', useFn);
};
}, []);
};
export default useTouchBottom;
// 公告列表 jsx
// 以下为关键代码
import { useEffect, useState, useRef } from 'react';
import { useRequest } from 'umi';
import { Card, Spin, message } from 'antd';
import ArticleItem from '@/components/ArticleItem';// 文章条目组件
import useTouchBottom from '@/hooks/useTouchBottom';// 触底加载 hook
import { wantArray } from '@/utils';
import { getNoticeList } from './service';
export default () => {
/* ======================== 公告列表 ======================== */
// ...
// 为解决监听函数无法获取到最新 state 值的问题,使用 useRef 代替 state
// ...
// todo 加载更多
const handleLoadMore = () => {
if (!loadingRef.current && isMoreRef.current) {
message.info('加载下一页~');
const temp = currentRef.current + 1;
setCurrent(temp);
runGetNoticeList({ current: temp, pageSize });
};
};
// 使用 触底加载 hook
useTouchBottom(handleLoadMore);
// 省略其他代码...
};
5. 封装「加载更多组件」
我们可以发现在加载的时候有些生硬,需要一个 加载更多
组件来救场,此处是一个最简易的版本。
// src/components/LoadMore/index.jsx
// 加载更多组件
import styles from './index.less';
/**
* @param status 状态 loadmore | loading | nomore
* @param hidden 是否隐藏
*/
const LoadMore = ({ status = 'loadmore', hidden = false }) => {
return (
<div className={styles.loadmore} hidden={hidden}>
{status === 'loadmore' && <div>下拉加载</div>}
{status === 'loading' && <div>加载中...</div>}
{status === 'nomore' && <div>已加载全部内容</div>}
</div>
);
};
export default LoadMore;
// src/components/LoadMore/index.less
// 加载更多组件
.loadmore {
padding: 12px 0;
width: 100%;
color: rgba(0, 0, 0, 0.6);
font-size: 14px;
text-align: center;
}
// 公告列表 jsx
// 以下为关键代码
import { useEffect, useState, useRef } from 'react';
import { useRequest } from 'umi';
import { Card, Spin, message } from 'antd';
import ArticleItem from '@/components/ArticleItem';// 文章条目组件
import LoadMore from '@/components/LoadMore'; // 加载更多组件
import useTouchBottom from '@/hooks/useTouchBottom';// 触底加载 hook
import { wantArray } from '@/utils';
import { getNoticeList } from './service';
export default () => {
/* ======================== 公告列表 ======================== */
// 新建一个变量用来保存 加载更多组件 状态,初始值为 loadmore
const [loadMoreStatus, setLoadMoreStatus] = useState('loadmore');
// ...
// 为解决监听函数无法获取到最新 state 值的问题,使用 useRef 代替 state
const currentRef = useRef(null);
useEffect(() => { currentRef.current = current }, [current]);
// loading 和 isMore 变化时需要修改 loadMoreStatus 的状态
const loadingRef = useRef(null);
useEffect(() => {
loadingRef.current = noticeListLoading;
if (noticeListLoading) { setLoadMoreStatus('loading') };
}, [noticeListLoading]);
const isMoreRef = useRef(null);
useEffect(() => {
if (!isMore) { setLoadMoreStatus('nomore') };
isMoreRef.current = isMore;
}, [isMore]);
// 省略其他代码...
return (
<>
{/* 省略其他代码... */}
<Card tabList={[{ key: '', tab: '公告' }]}>
<Spin size="large" spinning={noticeListLoading} tip="加载中...">
{
list.map(value => {
return (
<ArticleItem
key={value?.id}
item={value}
/>
);
})
}
{/* 加载更多组件 */}
<LoadMore status={loadMoreStatus} hidden={list.length === 0} />
</Spin>
</Card>
</>
);
};
6. 封装「空状态组件」
对于列表,我们一般需要自定义一个 空状态
组件来缺省占位。
// src/components/Empty/index.jsx
// 空状态组件
import styles from './index.less';
import emptyList from '@/assets/images/common/empty-list.svg';
const Empty = (() => {
return (
<div className={styles.empty}>
<img src={emptyList} alt="暂无数据" />
<div>暂无数据</div>
</div>
);
});
export default Empty;
// src/components/Empty/index.less
// 空状态组件
.empty {
padding: 50px 0;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
text-align: center;
img {
margin-bottom: 16px;
}
}
// 公告列表 jsx
// 以下为关键代码
import Empty from '@/components/Empty';// ? 空状态组件
export default () => {
// 省略其他代码...
return (
<>
{/* 省略其他代码... */}
<Card tabList={[{ key: '', tab: '公告' }]}>
<Spin size="large" spinning={noticeListLoading} tip="加载中...">
{/* 空状态组件 */}
{list.length === 0 && <Empty />}
{
list.map(value => {
return (
<ArticleItem
key={value?.id}
item={value}
/>
);
})
}
<LoadMore status={loadMoreStatus} hidden={list.length === 0} />
</Spin>
</Card>
</>
);
};
当列表为空时的效果如下图:
7. 问题:页面缩放
经过测试,上述代码存在一个问题:当页面缩放时,判断是否触底的函数失效。经过排查,发现页面缩放时 网页卷曲高度
和 所有内容高度
会发生改变且等式 网页卷曲高度 + 网页卷曲高度 = 所有内容高度
不再成立。目前的一种解决方案是将判断改为 所有内容高度 <= 文档显示区域高度 + 网页卷曲高度 + 100
,即:
// src/hooks/useTouchBottom/index.js
// 触底加载 hook
const isTouchBottom = (handler) => {
// 文档显示区域高度
const showHeight = window.innerHeight;
// 网页卷曲高度
const scrollTopHeight = document.body.scrollTop || document.documentElement.scrollTop;
// 所有内容高度
const allHeight = document.body.scrollHeight;
// (所有内容高度 = 文档显示区域高度 + 网页卷曲高度) 时即为触底
// 判断 所有内容高度 <= 文档显示区域高度 + 网页卷曲高度 + 100
if (allHeight <= showHeight + scrollTopHeight + 100) {
handler();
}
};
8. 完整代码
// utils/index.js
/**
* 希望获得数组
* 如果传入的是数组则直接返回,否则返回一个空数组
* @param data 必填 传入的待处理数据
* @returns Array
*/
export const wantArray = (data) => (Array.isArray(data) ? data : []);
// service.js
import { request } from 'umi';
// 公告列表
export function getNoticeList(params) {
// xxx 为请求地址
return request('xxx', { params });
};
// src/components/LoadMore/index.jsx
// 加载更多组件
import styles from './index.less';
/**
* @param status 状态 loadmore | loading | nomore
* @param hidden 是否隐藏
*/
const LoadMore = ({ status = 'loadmore', hidden = false }) => {
return (
<div className={styles.loadmore} hidden={hidden}>
{status === 'loadmore' && <div>下拉加载</div>}
{status === 'loading' && <div>加载中...</div>}
{status === 'nomore' && <div>已加载全部内容</div>}
</div>
);
};
export default LoadMore;
// src/components/LoadMore/index.less
// 加载更多组件
.loadmore {
padding: 12px 0;
width: 100%;
color: rgba(0, 0, 0, 0.6);
font-size: 14px;
text-align: center;
}
// src/components/Empty/index.jsx
// 空状态组件
import styles from './index.less';
import emptyList from '@/assets/images/common/empty-list.svg';
const Empty = (() => {
return (
<div className={styles.empty}>
<img src={emptyList} alt="暂无数据" />
<div>暂无数据</div>
</div>
);
});
export default Empty;
// src/components/Empty/index.less
// 空状态组件
.empty {
padding: 50px 0;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
text-align: center;
img {
margin-bottom: 16px;
}
}
// 公告列表 jsx
import { useEffect, useState, useRef } from 'react';
import { useRequest } from 'umi';
import { Card, Spin } from 'antd';
import ArticleItem from '@/components/ArticleItem';// 文章条目组件
import Empty from '@/components/Empty';// ? 空状态组件
import LoadMore from '@/components/LoadMore'; // 加载更多组件
import useTouchBottom from '@/hooks/useTouchBottom';// 触底加载 hook
import { wantArray } from '@/utils';
import { getNoticeList } from './service';
const Notice = () => {
/* ======================== 公告列表 ======================== */
const pageSize = 10;
const [current, setCurrent] = useState(1);
const [list, setList] = useState([]);
const [isMore, setIsMore] = useState(true);
const [loadMoreStatus, setLoadMoreStatus] = useState('loadmore');
// todo 请求数据
const { run: runGetNoticeList, loading: noticeListLoading } = useRequest(getNoticeList, {
manual: true,
formatResult: res => {
setCurrent(res?.current);
setList([...list, ...wantArray(res?.data)]);
if (current >= Math.round(res.total / pageSize)) { setIsMore(false) };
}
});
useEffect(() => { runGetNoticeList({ current, pageSize }) }, []);
// 为解决监听函数无法获取到最新 state 值的问题,使用 useRef 代替 state
const currentRef = useRef(null);
useEffect(() => { currentRef.current = current }, [current]);
const loadingRef = useRef(null);
useEffect(() => {
loadingRef.current = noticeListLoading;
if (noticeListLoading) { setLoadMoreStatus('loading') };
}, [noticeListLoading]);
const isMoreRef = useRef(null);
useEffect(() => {
if (!isMore) { setLoadMoreStatus('nomore') };
isMoreRef.current = isMore;
}, [isMore]);
// todo 加载更多
const handleLoadMore = () => {
if (!loadingRef.current && isMoreRef.current) {
const temp = currentRef.current + 1;
setCurrent(temp);
runGetNoticeList({ current: temp, pageSize });
};
};
useTouchBottom(handleLoadMore);
return (
<>
{/* 省略其他代码... */}
<Card tabList={[{ key: '', tab: '公告' }]}>
<Spin size="large" spinning={noticeListLoading} tip="加载中...">
{list.length === 0 && <Empty />}
{
list.map(value => {
return (
<ArticleItem
key={value?.id}
item={value}
/>
);
})
}
<LoadMore status={loadMoreStatus} hidden={list.length === 0} />
</Spin>
</Card>
</>
);
};
export default Notice;
小结
上述代码是 React
中触底加载的一种实现方式,可能并非最优解决方案。不过我们在此案例中使用了自定义 hook
,封装了 加载更多组件
和 空状态组件
,也算是有一些其他的收获。我们只有不断地积累各种各样的功能实现方案,才能真正具备独立开发大型项目的能力。只有不断积累,才能不断成长!