react无限滚动组件的实现示例
作者:ymitc
上拉无限滚动
核心:判断滚动条是否触底了,触底了就重新加载数据
判断触底: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
到此这篇关于react无限滚动组件的实现示例的文章就介绍到这了,更多相关react无限滚动内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!