手搓react无限滚动组件
上拉无限滚动
核心:判断滚动条是否触底了,触底了就重新加载数据
判断触底:scrollHeight-scrollTop-clientHeight<阈值
容器底部与列表底部的距离(表示还剩多少px到达底部)=列表高度-容器顶部到列表顶部的距离-容器高度
说一下几个概念
scrollHeight:只读属性。表示当前元素的内容总高度,包括由于溢出导致在视图中不可见的内容。这里获取的是列表数据的总高度
scrollTop:可以获取或设置一个元素的内容垂直滚动的像素数。这里获取的是容器顶部到列表顶部的距离,也就是列表卷去的高度
clientHeight:元素content+padding的高度。这里获取的是容器的高度
代码实现:
import * as React from 'react';
import { Component, createElement, ReactNode } from 'react';
interface Props {
loadMore: Function; // 加载数据的回调函数
loader: ReactNode; // “加载更多”的组件
threshold: number; // 到达底部的阈值
hasMore?: boolean; // 是否还有更多可以加载
pageStart?: number; // 页面初始页
initialLoad?: boolean; // 是否第一次就加载
getScrollParent?: () => HTMLElement; //自定义滚动容器
}
class InfiniteScroll extends Component<Props, any> {
private scrollComponent: HTMLDivElement | null = null; // 列表数据
private loadingMore = false; // 是否正在加载更多
private pageLoaded = 0; // 当前加载页数
constructor(props: Props) {
super(props);
this.scrollListener = this.scrollListener.bind(this); // scrollListener 用到了 this,所以要 bind 一下
}
//获取滚动容器
getParentElement(el: HTMLElement | null): HTMLElement | null {
const scrollParent =
this.props.getScrollParent && this.props.getScrollParent();
if (scrollParent) {
return scrollParent;
}
//默认将当前组件的外层元素作为滚动容器
return el && el.parentElement;
}
// 滚动监听顺
scrollListener() {
//列表数据组件
const node = this.scrollComponent;
if (!node) return;
//滚动容器
const parentNode = this.getParentElement(this.scrollComponent);
if (!parentNode) return;
// 核心计算公式
const offset =
node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight;
if (offset < this.props.threshold) {
this.detachScrollListener(); // 加载的时候去掉监听器
this.props.loadMore((this.pageLoaded += 1)); // 加载更多
this.loadingMore = true; // 正在加载更多
}
}
attachScrollListener() {
const parentElement = this.getParentElement(this.scrollComponent);
if (!parentElement) return;
const scrollEl = this.props.useWindow ? window : parentElement;
scrollEl.addEventListener('scroll', this.scrollListener);
scrollEl.addEventListener('resize', this.scrollListener);
//设置滚动条即时不动也会自动触发第一次渲染列表数据
if (this.props.initialLoad) {
this.scrollListener();
}
}
detachScrollListener() {
const parentElement = this.getParentElement(this.scrollComponent);
if (!parentElement) return;
parentElement.removeEventListener('scroll', this.scrollListener);
parentElement.removeEventListener('resize', this.scrollListener);
}
componentDidMount() {
this.attachScrollListener();
}
componentDidUpdate() {
this.attachScrollListener();
}
componentWillUnmount() {
this.detachScrollListener();
}
render() {
const { children, loader } = this.props;
// 获取滚动元素的核心代码
return (
<div ref={(node) => (this.scrollComponent = node)}>
{children} 很长很长很长的东西
{loader} “加载更多”
</div>
);
}
}
export default InfiniteScroll;
测试demo
import React, { useEffect, useState } from 'react';
import InfiniteScroll from './InfiniteScroll';
type AsyncFn = () => Promise<void>;
export const delay = (asyncFn: AsyncFn) =>
new Promise<void>((resolve) => {
setTimeout(() => {
asyncFn().then(() => resolve);
}, 1500);
});
let counter = 0;
const DivScroller = () => {
const [items, setItems] = useState<string[]>([]);
const fetchMore = async () => {
await delay(async () => {
const newItems = [];
for (let i = counter; i < counter + 50; i++) {
newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`);
}
setItems([...items, ...newItems]);
counter += 50;
});
};
useEffect(() => {
fetchMore().then();
}, []);
return (
<div style={{ height: 250, overflow: 'auto', border: '1px solid red' }}>
<InfiniteScroll
useWindow={false}
threshold={50}
loadMore={fetchMore}
loader={
<div className="loader" key={0}>
Loading ...
</div>
}
>
{items.map((item) => (
<div key={item}>{item}</div>
))}
</InfiniteScroll>
</div>
);
};
export default DivScroller;
运行结果:
window作容器的无限滚动
window作为滚动组件的话,判断触底的公式不变,获取数据的方法变化了:
offset = 列表数据高度 - 容器顶部到列表顶部的距离 - 容器高度
offset = (当前窗口顶部到列表顶部的距离+offsetHeight) - window.pageOffsetY - window.innerHeight
(当前窗口顶部到列表顶部的距离+offsetHeight)是固定的值,变化的是window.pageOffsetY,也就是说往上拉会window.pageOffsetY变大,offset变小,也就是距离底部越来越近
代码实现
import * as React from 'react';
import { Component, createElement, ReactNode } from 'react';
interface Props {
loadMore: Function; // 加载数据的回调函数
loader: ReactNode; // “加载更多”的组件
threshold: number; // 到达底部的阈值
hasMore?: boolean; // 是否还有更多可以加载
pageStart?: number; // 页面初始页
initialLoad?: boolean; // 是否第一次就加载
getScrollParent?: () => HTMLElement; //自定义滚动容器
useWindow?: boolean; // 是否以 window 作为 scrollEl
}
class InfiniteScroll extends Component<Props, any> {
private scrollComponent: HTMLDivElement | null = null; // 列表数据
private loadingMore = false; // 是否正在加载更多
private pageLoaded = 0; // 当前加载页数
constructor(props: Props) {
super(props);
this.scrollListener = this.scrollListener.bind(this); // scrollListener 用到了 this,所以要 bind 一下
}
//获取滚动容器
getParentElement(el: HTMLElement | null): HTMLElement | null {
const scrollParent =
this.props.getScrollParent && this.props.getScrollParent();
if (scrollParent) {
return scrollParent;
}
//默认将当前组件的外层元素作为滚动容器
return el && el.parentElement;
}
// 滚动监听顺
scrollListener() {
//列表数据组件
const node = this.scrollComponent;
if (!node) return;
//滚动容器
const parentNode = this.getParentElement(this.scrollComponent);
if (!parentNode) return;
let offset;
if (this.props.useWindow) {
const doc =
document.documentElement ||
document.body.parentElement ||
document.body; // 全局滚动容器
const scrollTop = window.pageYOffset || doc.scrollTop; // 全局的 "scrollTop"
offset = this.calculateOffset(node, scrollTop);
} else {
offset =
node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight;
}
if (offset < this.props.threshold) {
this.detachScrollListener(); // 加载的时候去掉监听器
this.props.loadMore((this.pageLoaded += 1)); // 加载更多
this.loadingMore = true; // 正在加载更多
}
}
calculateOffset(el: HTMLElement | null, scrollTop: number) {
if (!el) return 0;
return (
this.calculateTopPosition(el) +
el.offsetHeight -
scrollTop -
window.innerHeight
);
}
calculateTopPosition(el: HTMLElement | null): number {
if (!el) return 0;
return (
el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement)
);
}
attachScrollListener() {
const parentElement = this.getParentElement(this.scrollComponent);
if (!parentElement) return;
const scrollEl = this.props.useWindow ? window : parentElement;
scrollEl.addEventListener('scroll', this.scrollListener);
}
detachScrollListener() {
const parentElement = this.getParentElement(this.scrollComponent);
if (!parentElement) return;
const scrollEl = this.props.useWindow ? window : parentElement;
scrollEl.removeEventListener('scroll', this.scrollListener);
}
componentDidMount() {
this.attachScrollListener();
}
componentDidUpdate() {
this.attachScrollListener();
}
componentWillUnmount() {
this.detachScrollListener();
}
render() {
const { children, loader } = this.props;
// 获取滚动元素的核心代码
return (
<div ref={(node) => (this.scrollComponent = node)}>
{children} 很长很长很长的东西
{loader} “加载更多”
</div>
);
}
}
export default InfiniteScroll;
测试demo:
import React, { useEffect, useState } from 'react';
import InfiniteScroll from './InfiniteScroll';
type AsyncFn = () => Promise<void>;
export const delay = (asyncFn: AsyncFn) =>
new Promise<void>((resolve) => {
setTimeout(() => {
asyncFn().then(() => resolve);
}, 1500);
});
let counter = 0;
const DivScroller = () => {
const [items, setItems] = useState<string[]>([]);
const fetchMore = async () => {
await delay(async () => {
const newItems = [];
for (let i = counter; i < counter + 150; i++) {
newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`);
}
setItems([...items, ...newItems]);
counter += 150;
});
};
useEffect(() => {
fetchMore().then();
}, []);
return (
<div style={{ border: '1px solid blue' }}>
<InfiniteScroll
useWindow
threshold={300}
loadMore={fetchMore}
loader={
<div className="loader" key={0}>
Loading ...
</div>
}
>
{items.map((item) => (
<div key={item}>{item}</div>
))}
</InfiniteScroll>
</div>
);
};
export default DivScroller;
运行结果:
下滑无限滚动
改变loader的位置
offset计算方法发生改变:offset = scrollTop
考虑一个问题:当下拉加载新数据后滚动条的位置不应该在scrollY = 0 的位置,不然会一直加载新数据
解决办法:
当前 scrollTop = 当前 scrollHeight - 上一次的 scrollHeight + 上一交的 scrollTop parentElement.scrollTop = parentElement.scrollHeight - this.beforeScrollHeight + this.beforeScrollTop
代码实现:
import * as React from 'react';
import { Component, createElement, ReactNode } from 'react';
interface Props {
loadMore: Function; // 加载数据的回调函数
loader: ReactNode; // “加载更多”的组件
threshold: number; // 到达底部的阈值
hasMore?: boolean; // 是否还有更多可以加载
pageStart?: number; // 页面初始页
initialLoad?: boolean; // 是否第一次就加载
getScrollParent?: () => HTMLElement; //自定义滚动容器
useWindow?: boolean; // 是否以 window 作为 scrollEl
isReverse?: boolean; // 是否为相反的无限滚动
}
class InfiniteScroll extends Component<Props, any> {
private scrollComponent: HTMLDivElement | null = null; // 列表数据
private loadingMore = false; // 是否正在加载更多
private pageLoaded = 0; // 当前加载页数
// isReverse 后专用参数
private beforeScrollTop = 0; // 上次滚动时 parentNode 的 scrollTop
private beforeScrollHeight = 0; // 上次滚动时 parentNode 的 scrollHeight
constructor(props: Props) {
super(props);
this.scrollListener = this.scrollListener.bind(this); // scrollListener 用到了 this,所以要 bind 一下
}
//获取滚动容器
getParentElement(el: HTMLElement | null): HTMLElement | null {
const scrollParent =
this.props.getScrollParent && this.props.getScrollParent();
if (scrollParent) {
return scrollParent;
}
//默认将当前组件的外层元素作为滚动容器
return el && el.parentElement;
}
// 滚动监听顺
scrollListener() {
//列表数据组件
const node = this.scrollComponent;
if (!node) return;
//滚动容器
const parentNode = this.getParentElement(this.scrollComponent);
if (!parentNode) return;
let offset;
if (this.props.useWindow) {
const doc =
document.documentElement ||
document.body.parentElement ||
document.body; // 全局滚动容器
const scrollTop = window.pageYOffset || doc.scrollTop; // 全局的 "scrollTop"
offset = this.props.isReverse
? scrollTop
: this.calculateOffset(node, scrollTop);
} else {
offset = this.props.isReverse
? parentNode.scrollTop
: node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight;
}
// 是否到达阈值,是否可见
if (
offset < (this.props.threshold || 300) &&
node &&
node.offsetParent !== null
) {
this.detachScrollListener();
this.beforeScrollHeight = parentNode.scrollHeight;
this.beforeScrollTop = parentNode.scrollTop;
if (this.props.loadMore) {
this.props.loadMore((this.pageLoaded += 1));
this.loadingMore = true;
}
}
}
calculateOffset(el: HTMLElement | null, scrollTop: number) {
if (!el) return 0;
return (
this.calculateTopPosition(el) +
el.offsetHeight -
scrollTop -
window.innerHeight
);
}
calculateTopPosition(el: HTMLElement | null): number {
if (!el) return 0;
return (
el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement)
);
}
attachScrollListener() {
const parentElement = this.getParentElement(this.scrollComponent);
if (!parentElement) return;
const scrollEl = this.props.useWindow ? window : parentElement;
scrollEl.addEventListener('scroll', this.scrollListener);
}
detachScrollListener() {
const parentElement = this.getParentElement(this.scrollComponent);
if (!parentElement) return;
const scrollEl = this.props.useWindow ? window : parentElement;
scrollEl.removeEventListener('scroll', this.scrollListener);
}
componentDidMount() {
this.attachScrollListener();
}
componentDidUpdate() {
if (this.props.isReverse && this.props.loadMore) {
const parentElement = this.getParentElement(this.scrollComponent);
if (parentElement) {
// 更新滚动条的位置
parentElement.scrollTop =
parentElement.scrollHeight -
this.beforeScrollHeight +
this.beforeScrollTop;
this.loadingMore = false;
}
}
this.attachScrollListener();
}
componentWillUnmount() {
this.detachScrollListener();
}
render() {
const { children, loader, isReverse } = this.props;
const childrenArray = [children];
if (loader) {
// 根据 isReverse 改变 loader 的插入方式
isReverse ? childrenArray.unshift(loader) : childrenArray.push(loader);
}
return (
<div ref={(node) => (this.scrollComponent = node)}>{childrenArray}</div>
);
}
}
export default InfiniteScroll;
测试demo:
import React, { useEffect, useState } from 'react';
import InfiniteScroll from './InfiniteScroll';
type AsyncFn = () => Promise<void>;
export const delay = (asyncFn: AsyncFn) =>
new Promise<void>((resolve) => {
setTimeout(() => {
asyncFn().then(() => resolve);
}, 1500);
});
let counter = 0;
const DivReverseScroller = () => {
const [items, setItems] = useState<string[]>([]);
const fetchMore = async () => {
await delay(async () => {
const newItems = [];
for (let i = counter; i < counter + 50; i++) {
newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`);
}
setItems([...items, ...newItems]);
counter += 50;
});
};
useEffect(() => {
fetchMore().then();
}, []);
return (
<div style={{ height: 250, overflow: 'auto', border: '1px solid red' }}>
<InfiniteScroll
isReverse
useWindow={false}
threshold={50}
loadMore={fetchMore}
loader={
<div className="loader" key={0}>
Loading ...
</div>
}
>
{items
.slice()
.reverse()
.map((item) => (
<div key={item}>{item}</div>
))}
</InfiniteScroll>
</div>
);
};
export default DivReverseScroller;
运行结果
优化
1、在mousewheel里通过e.preventDefault解决"加载更多"时间超长的问题
2、添加被动监听器,提高页面滚动性能
3、优化render函数
最终优化版源码,感谢海怪大佬~
总结
无限滚动原理的核心就是维护当前的offset值
1、向下无限滚动:offset = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight
2、向上无限滚动:offset = parentNode.scrollTop
3、window为滚动容器向下无限滚动:offset = calculateTopPosition(node) + node.offsetHeight - window.pageYoffset - window.innerHeight
其中calculateTopPosition函数通过递归计算当前窗口顶部距离浏览器窗口顶部的距离
4、window为滚动容器向上无限滚动:offset = window.pageYoffset || doc.scrollTop
其中doc = document.documentElement || document.body.parentElement || document.body
转载自:https://juejin.cn/post/7237425805511934008