写在最前
看官们好,我是JetTsang,之前都是在掘金潜水来着,现在偶尔做一些内容输出吧。
前言
无限滚动通常应用于移动端的交互上,比如我们最常见的短视频软件,看视频的时候可以一直滑,那就是一种无限滚动的典型案例。
其核心点就在于怎么去触发加载数据,在传统网页开发当中,通常是利用scroll事件,配合上scrollHeight - scrollTop <= clientHeight + threshold
,来判断。
但今天可以换个思路,使用交叉观察器IntersectionObserver
来判断是否触发滚动。
IntersectionObserver介绍
ps:知道这个API用法的可以直接跳过
简单来说,这是检测一个元素是否与祖先元素或者视口有交集。
它有如下优势:
- 异步,这点能一定程度保证页面流畅性
- 配置灵活,可以配置祖先元素或者视口、threshold交叉比例阈值,rootMargin偏移量
- 相对于传统的scroll、resize事件,性能更有保障
更多细节可以看 MDN Intersection_Observer_API
简单用法
// 创建一个 IntersectionObserver 实例
let observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// entry 是一个 IntersectionObserverEntry 对象
if (entry.isIntersecting) {
// 当目标元素进入视窗时执行的操作
entry.target.classList.add('visible');
} else {
// 当目标元素离开视窗时执行的操作
entry.target.classList.remove('visible');
}
});
}, {
root: null, // 默认为视窗
rootMargin: '0px',
threshold: 0.5 // 50% 的交集时触发回调
});
// 观察某个元素
let target = document.querySelector('.target');
observer.observe(target);
实现
假设是向上滚动,那么就需要判断滚动列表的最后1个元素是否进入视窗/祖先元素即可。
示例搭建
首先构建基础框架
请求封装,使用# JSONPlaceholder来mock数据
export const fetchPosts = async (page, pageSize = 10) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${pageSize}`
);
const data = await response.json();
return data;
};
组件骨架
const PostsList = () => {
const [posts, setPosts] = useState([]); // 渲染的数据
const [page, setPage] = useState(1); // 当前页码
const [loading, setLoading] = useState(false); // 是否加载中
return (
<div>
<h1>Your Feed</h1>
<ul>
{posts.map((post, index) => (
<li
key={post.id}
>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
{loading && <p>Loading...</p>}
</div>
);
};
然后添加effect
const loadMorePosts = ()=>{
setLoading(true)
const newPosts = await fetchPosts(page)
setPosts(prePosts=>[...prePosts,...newPosts]
setLoading(false)
}
useEffect(() => {
loadMorePosts();
}, [page]); // 当页码变动时,重新请求
到此,已经实现了初次渲染加载10个posts了,接下来需要根据Intersection Observer这个API来检测何时开始续传。
观察逻辑
根据Intersection Observer的使用规则,那就用一个ref来存储
const observer = useRef();
observer.current = new IntersectionObserver(callback)
回调参数应该怎么写呢?
这里只需要增加page,之后对应的副作用会执行loadMorePosts,从而加载到更多的Posts
const callback = (entries) => {
// 这里只观察最后1个,因此entries只有1个
if (entries[0].isIntersecting) {
setPage((prevPage) => prevPage + 1); // 增加页码
}
}
那么什么时候开始观察呢?
每次列表渲染到视图之后,动态观察最后1个li,那如何能巧妙保证能在最后渲染之后呢?useEffect?
获取到最后的li
如何获取到渲染的最后1个li元素呢? 答案当然是ref啊,但最后一个li是会不断更新,更新li之后需要重新observer。
使用ref的回调形式,获得最后的li的ref,详细可看ref-callback - react
于是乎观察的逻辑就放在ref的回调里,同时用useCallback包裹,避免每一次渲染的时候重新创建。
const lastPostElementRef = useCallback(
(node) => {
if (loading) return;
// 让当前observer停止监听
if (observer.current) observer.current.disconnect();
// observer.current 来指向最新的observer对象
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setPage((prevPage) => prevPage + 1);
}
});
if (node) observer.current.observe(node);
},
[loading]
);
然后改一下jsx
{posts.map((post, index) => (
<li
key={post.id}
// 给最后的li添加上ref
ref={posts.length === index + 1 ? lastPostElementRef : null}
>
...
</li>))
}
最后看看效果
附
全文代码
import React, { useCallback, useEffect, useRef, useState } from "react";
import "../styles.css";
import { fetchPosts } from "../services";
const PostsList = () => {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observer = useRef();
const loadMorePosts = useCallback(async () => {
setLoading(true);
const newPosts = await fetchPosts(page, 10);
if (newPosts.length === 0) {
setHasMore(false); // Set hasMore to false if no more posts are returned
} else {
setPosts((prevPosts) => [...prevPosts, ...newPosts]);
}
setLoading(false);
}, [page]);
useEffect(() => {
if (hasMore) {
loadMorePosts();
}
}, [loadMorePosts, hasMore]);
const lastPostElementRef = useCallback(
(node) => {
if (loading || !hasMore) return; // 没有更多数据或者正在加载中时,页面渲染会
if (observer.current) observer.current.disconnect(); // 停止观察,使得observer对象失效
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setPage((prevPage) => prevPage + 1); // 通过设置page,来更新useCallback从而触发请求新的数据
}
});
if (node) observer.current.observe(node);
},
[loading, hasMore]
);
return (
<div>
<h1>Your Feed</h1>
<ul>
{posts.map((post, index) => (
<li
key={post.id} // Use post.id directly
ref={posts.length === index + 1 ? lastPostElementRef : null}
>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
{loading && <p>Loading...</p>}
{/* Message indicating no more posts */}
</div>
);
};
export default PostsList;