文章源码: Haixiang6123/my-react-infinite-scroller
预览链接: React App
参考轮子: react-infinite-scroller
无限滚动是一个开发时经常遇到的问题,比如 ant-design 的 List 组件里就推荐使用 react-infinite-scroller 配合 List 组件一起使用。
假如我们想自己实现无限滚动,难免要去查 scroll
事件,还要搞清 offsetHeight
, scrollHeight
, pageX
这些奇奇怪怪变量之间的关系,真让人脑袋大。今天就带大家造一个 reac-infinite-scroller 的轮子吧。
无限滚动的原理很简单:只要 很长元素总高度 \- 窗口距离顶部高度 \- 窗口高度 < 阈值
就加载更多,前面那一堆下称为 offset
,表示还剩多少 px 到达底部。
然后就懵逼了:scrollY
, pageY
, scrollTop
, offsetTop
, clientHeight
这一堆的变量到底用哪个来计算呢?对于大部分人来说,这些变量简直是噩梦一般的存在,总是会傻傻搞不清。
这里直接给出计算 offset 的公式,免得大家去查了:
const offset = 很长元素总高度 - 窗口距离顶部高度 - 窗口高度 = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight if (offset < this.props.threshold) { this.props.loadMore() }
简单说一下这些变量都是个啥:
总结一下,上面公式里的 offset
表示距离底部的 px 值,只要 offset < threshold
说明滚动到了底部,开始 loadMore()
。
下面为使用用例,定义 delay 函数用于 mock 延时效果,fetchMore
为获取更多数据的函数。
let counter = 0 const delay = (asyncFn: () => Promise<void>) => new Promise<void>(resolve => { setTimeout(() => { asyncFn().then(() => resolve) }, 1500) }) const App = () => { 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> <div style={{height: 250, overflow: 'auto', border: '1px solid red'}}> <InfiniteScroll throttle={50} loadMore={fetchMore} loader={<div className="loader" key={0}>Loading ...</div>} > {items.map(item => <div key={item}>{item}</div>)} </InfiniteScroll> </div> </div> ) }
轮子最简单的实现如下:
interface Props { loadMore: Function // 加载更多的回调 loader: ReactNode // “加载更多”的组件 threshold: number // 到达底部的阈值 hasMore?: boolean // 是否还有更多可以加载 pageStart?: number // 页面初始页 } 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 一下 } // 滚动监听顺 scrollListener() { const node = this.scrollComponent if (!node) return const parentNode = node.parentElement if (!parentNode) return // 核心计算公式 const offset = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight if (offset < this.props.threshold) { parentNode.removeEventListener('scroll', this.scrollListener) // 加载的时候去掉监听器 this.props.loadMore(this.pageLoaded += 1) // 加载更多 this.loadingMore = true // 正在加载更多 } } componentDidMount() { this.pageLoaded = this.props.pageStart || 0 // Mount 的时候就添加监听器 if (this.scrollComponent && this.scrollComponent.parentElement) { this.scrollComponent.parentElement.addEventListener('scroll', this.scrollListener) } } componentDidUpdate() { // 到达底部时会把监听器临时移除,组件更新的时候,这里再加回来 if (this.scrollComponent && this.scrollComponent.parentElement) { this.scrollComponent.parentElement.addEventListener('scroll', this.scrollListener) } } componentWilUnmount() { // Mount 的时候就添加监听器 if (this.scrollComponent && this.scrollComponent.parentElement) { this.scrollComponent.parentElement.addEventListener('scroll', this.scrollListener) } } render() { const {children, loader} = this.props // 获取滚动元素的核心代码 return ( <div ref={node => this.scrollComponent = node}> {children} 很长很长很长的东西 {loader} “加载更多” </div> ) } }
上面就是一个最小实现,有以下注意点:
bind
this,不然 this 为 undefined
offset < threshold
的时候移除监听器,组件更新后再次添加监听器,unmount 前移除监听器上面添加/移除监听器的代码有点冗余,封装一下:
class InfiniteScroll extends Component<Props, any> { ... // 滚动监听顺 scrollListener() { const node = this.scrollComponent if (!node) return const parentNode = node.parentElement 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 // 正在加载更多 } } getParentElement(el: HTMLElement | null): HTMLElement | null { return el && el.parentElement } 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) } 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> ) } }
上面首先将获取 parentElement
的动作抽象出来,再把 attachScrollListener
和 detachScrollListener
抽象出来。同时,上面还对 resize 事件绑定了监听器,因为当用户 resize 的时候也会出现 offset < threshold
的可能,这个时候也需要 loadMore
。
还有一个问题:刚进页面的时候,高度为 0,假如此时 offset < threshold
理应触发“加载更多”,然而这个时候用户并没有做任何滚动,滚动事件不会被触发,“加载更多”也不会被触发,这其实并不符合我们的预期。
因此,这里可以加一个 initialLoad
的 props 指定添加监听器的时候就自动触发一次监听器的代码。
interface Props { ... initialLoad?: boolean // 是否第一次就加载 } class InfiniteScroll extends Component<Props, any> { ... attachListeners() { const parentElement = this.getParentElement(this.scrollComponent) if (!parentElement) return parentElement .addEventListener('scroll', this.scrollListener, this.eventOptions) parentElement .addEventListener('resize', this.scrollListener, this.eventOptions) if (this.props.initialLoad) { this.scrollListener() } } }
上面对 parentElement
的限制是比较死的,可以添加 getParentElement
这个 props 让开发者自己指定 parentElement
,这样轮子就会更灵活些。
interface Props { loadMore: Function loader: ReactNode threshold: number getScrollParent?: () => HTMLElement } class InfiniteScroll extends Component<Props, any> { ... getParentElement(el: HTMLElement | null): HTMLElement | null { const scrollParent = this.props.getScrollParent && this.props.getScrollParent() if (scrollParent) { return scrollParent } return el && el.parentElement } ... }
此时们不禁想到,要是开发者想传 document.body
作为 parentElement
,上面的代码还能继续使用么?当然是不行的。document.body
和很长很长的元素往往存在很多层嵌套,这些复杂的嵌套关系有时候并不会是我们希望的那样。
而在全局 (window) 做无限滚动的例子又比较常见,为了实现全局滚动的功能,这里加一个 useWindow
props 来表示是否用 window
作为滚动的容器。
interface Props { ... getScrollParent?: () => HTMLElement // 获取 parentElement 的回调 useWindow?: boolean // 是否以 window 作为 scrollEl }
如果用全局作为滚动容器,我们需要另一套算计方法来算 offset
了,下面给出新的计算公式:
offset = 很长元素总高度 - 窗口距离顶部高度 - 窗口高度 = (当前窗口顶部与很长元素顶部的距离 + offsetHeight) - window.pageYOffset - window.innerHeight
上面公式里“当前窗口顶部与很长元素顶部的距离 + offsetHeigh”在页面里是定死的,而 window.pageYOffset \- window.innerHeight
会随着滚动而改变,两者相减则为 offset
。图示:
不过,这里的 “当前窗口顶部与很长元素顶部的距离” 这一步并不能通过变量来获得,只能用 JS 来获取:
// 元素顶部到页面顶部的距离 calculateTopPosition(el: HTMLElement | null): number { if (!el) return 0 return el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement) }
利用 calculateTopPosition
函数,计算 offset
的函数为:
// 计算 offset calculateOffset(el: HTMLElement | null, scrollTop: number) { if (!el) return 0 return this.calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight) }
整理上面函数到轮子里:
class InfiniteScroll extends Component<Props, any> { ... scrollListener() { const node = this.scrollComponent if (!node) return const parentNode = this.getParentElement(node) 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.throttle) { node.removeEventListener('scroll', this.scrollListener) 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) } ... }
上面改动的点有:
useWindow === true
,以 window
为 scrollEl
calculateTopPosition
和 calculateOffset
useWindow
,如果 true
,使用上面的 calculateOffset
计算 offset至此,无限滚动最核心的滚动已经实现了。
除了向下无限滚动,我们还要考虑无限向上滚动的情况。有人就会问了:一般都是无限向下的呀,哪来的无限向上?很简单,翻找微信的聊天记录不就是无限向上滚动的嘛。
首先,在 props 加一个 isReverse
用于指定向下还是向上无限滚动。
interface Props { ... isReverse?: boolean // 是否为相反的无限滚动 }
那 isReverse
会影响哪个部分呢?第一反应肯定是 loader
的位置变了:
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> ) }
然后 offset
的计算也要变了。对于向上无限滚动,offset
的计算反而变简单了,直接 offset = scrollTop
。在 scrollListener
里修改 offset
的计算:
scrollListener() { const el = this.scrollComponent if (!el) return const parentElement = this.getParentElement(el) if (!parentElement) return let offset; if (this.props.useWindow) { const doc = document.documentElement || document.body.parentElement || document.body const scrollTop = window.pageYOffset || doc.scrollTop offset = this.props.isReverse ? scrollTop : this.calculateOffset(el, scrollTop) } else { offset = this.props.isReverse ? parentElement.scrollTop : el.scrollHeight - parentElement.scrollTop - parentElement.clientHeight } // 是否到达阈值,是否可见 if (offset < (this.props.threshold || 300) && (el && el.offsetParent !== null)) { this.detachListeners() this.beforeScrollHeight = parentElement.scrollHeight this.beforeScrollTop = parentElement.scrollTop if (this.props.loadMore) { this.props.loadMore(this.pageLoaded += 1) this.loadingMore = true } } }
我们还要考虑一个问题:向上滚动加载更多内容后,滚动条的位置不应该还停留在 scrollY = 0 的位置,不然会一直加载更多,比如此时滚动到了顶部:
3 <- 到顶部了,开始加载 2 1 0
加载更多后
6 <- 不应该停留在这个位置,因为会再次触发无限滚动,用户体验不友好 5 4 3 <- 应该停留在原始的位置,用户再向上滚动才再次加载更多 2 1 0
为了达到这个效果,我们要记录上一次的 scrollTop
和 scrollHeight
,然后在组件更新的时候更新 parentElemnt.scrollTop
:
// 当前 scrollTop = 当前 scrollHeight - 上一次的 scrollHeight + 上一交的 scrollTop parentElement.scrollTop = parentElement.scrollHeight - this.beforeScrollHeight + this.beforeScrollTop
以上面的例子举例: parentElement.scrollHeight: 6 - 0 的高度 beforeScrollHeight: 3 - 0 的高度 * beforeScrollTop: 高度为 0
最后更新 parentElement.scrollTop
为 3 - 0 的高度,滚动条会停留在 3 这个位置。
实现时,首先声明 beforeScrollHeight
和 beforeScrollTop
,并在 scrollListener
里进行赋值:
class InfiniteScroll extends Component<Props, any> { ... // isReverse 后专用参数 private beforeScrollTop = 0 // 上次滚动时 parentNode 的 scrollTop private beforeScrollHeight = 0 // 上次滚动时 parentNode 的 scrollHeight ... scrollListener() { const el = this.scrollComponent if (!el) return const parentNode = this.getParentElement(el) if (!parentNode) return let offset; if (this.props.useWindow) { const doc = document.documentElement || document.body.parentNode || document.body const scrollTop = window.pageYOffset || doc.scrollTop offset = this.props.isReverse ? scrollTop : this.calculateOffset(el, scrollTop) } else if (this.props.isReverse) { offset = parentNode.scrollTop } else { offset = el.scrollHeight - parentNode.scrollTop - parentNode.clientHeight } if (offset < (this.props.throttle || 300)) { this.detachScrollListener() this.beforeScrollTop = parentNode.scrollTop // 记录上一次的 scrollTop this.beforeScrollHeight = parentNode.scrollHeight // 记录上一次的 scrollHeight if (this.props.loadMore) { this.props.loadMore(this.pageLoaded += 1) this.loadingMore = true } } } ... }
然后在 componentDidUpdate
里计算并更新滚动条的位置:
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() }
至此,向上滚动也被我们实现了。
在 Stackoverflow 这个帖子 中说到:Chrome 下做无限滚动时可能存在加载时间变得超长的问题。
目前猜测因为 passive listener 的特性所引发的,帖子里也给出了解决方法:在 mousewheel 里 e.preventDefault
就好。
class InfiniteScroll extends Component<Props, any> { ... mousewheelListener(e: Event) { // 详见: https://stackoverflow.com/questions/47524205/random-high-content-download-time-in-chrome/47684257#47684257 // @ts-ignore mousewheel 事件里存在 deltaY if (e.deltaY === 1) { e.preventDefault() } } attachListeners() { const parentElement = this.getParentElement(this.scrollComponent) if (!parentElement || !this.props.hasMore) return const scrollEl = this.props.useWindow ? window : parentElement scrollEl.addEventListener('scroll', this.scrollListener) scrollEl.addEventListener('resize', this.scrollListener) scrollEl.addEventListener('mousewheel', this.mousewheelListener) } detachMousewheelListener() { const scrollEl = this.props.useWindow ? window : this.scrollComponent?.parentElement if (!scrollEl) return scrollEl.removeEventListener('mousewheel', this.mousewheelListener) } detachListeners() { const scrollEl = this.props.useWindow ? window : this.getParentElement(this.scrollComponent) if (!scrollEl) return scrollEl.removeEventListener('scroll', this.scrollListener) scrollEl.removeEventListener('resize', this.scrollListener) } componentWillUnmount() { this.detachListeners() this.detachMousewheelListener() } render() { ... } }
上面同时把 attachScrollListener
改为 attachListeners
,并在里面添加 mousewheel 的监听器,在 componentWillUnmount
里移除 mousewheel 的监听器。
上面提到了 passive listener,当监听器添加了 passive 属性 后,它就是 passive listener(被动监听器)。对 touch 和 mouse 的事件监听不会阻塞页面的滚动,可提高页面滚动性能。详情可见这篇文章。
这里的两个监听器都可以设置 passive: true 来提高滚动性能,不过我们第一步是要检测当前浏览器是否支持被动监听器。
isPassiveSupported() { let passive = false const testOptions = { get passive() { passive = true return true } } try { const testListener = () => { } document.addEventListener('test', testListener, testOptions) // @ts-ignore 仅用于测试是否可以使用 passive listener document.removeEventListener('test', testListener, testOptions) } catch (e) { } return passive }
上面给一个“假的”事件添加了一个“假的”被动监听器,并带个 testOptions
作为第三个参数。testOptions
利用 ES6 Proxy 的特性判断当前浏览器是否会读取 passive
属性,读取了说明支持 passive listener,返回 true
。
再造一个函数获取监听器的 options
,这个 options
包含了 passive
和 useCapture
,前者为是否开启 passive 特性,后者为是否捕获。
interface EventListenerOptions { useCapture: boolean // 是否捕获 passive: boolean // 是否 passive } class InfiniteScroll extends Component<Props, any> { ... private eventOptions = {} // 注册事件的选项 ... isPassiveSupported() { // 当前是否支持 passive ... } mousewheelListener(e: Event) { // 详见: https://stackoverflow.com/questions/47524205/random-high-content-download-time-in-chrome/47684257#47684257 // @ts-ignore mousewheel 事件里存在 deltaY if (e.deltaY === 1 && !this.isPassiveSupported()) { e.preventDefault() } } getEventListenerOptions() { // 获取监听器的 options const options: EventListenerOptions = {useCapture: this.props.useCapture || false, passive: false} if (this.isPassiveSupported()) { options.passive = true } return options } attachListeners() { const parentElement = this.getParentElement(this.scrollComponent) if (!parentElement || !this.props.hasMore) return const scrollEl = this.props.useWindow ? window : parentElement scrollEl.addEventListener('mousewheel', this.mousewheelListener, this.eventOptions) // 使用 eventOptions scrollEl.addEventListener('scroll', this.scrollListener, this.eventOptions) // 使用 eventOptions scrollEl.addEventListener('resize', this.scrollListener, this.eventOptions) // 使用 eventOptions if (this.props.initialLoad) { this.scrollListener() } } detachMousewheelListener() { const scrollEl = this.props.useWindow ? window : this.scrollComponent?.parentElement if (!scrollEl) return scrollEl.removeEventListener('mousewheel', this.mousewheelListener, this.eventOptions) // 使用 eventOptions } detachListeners() { const scrollEl = this.props.useWindow ? window : this.getParentElement(this.scrollComponent) // 使用 eventOptions if (!scrollEl) return scrollEl.removeEventListener('scroll', this.scrollListener, this.eventOptions) // 使用 eventOptions scrollEl.removeEventListener('resize', this.scrollListener, this.eventOptions) // 使用 eventOptions } ... }
注意:被动监听器里是不能有 e.preventDefault
的,因此在 mousewheelListener
里要做 isPassiveSupported
的判断,如果支持了 passive,就不执行 e.preventDefault
。
最后,render
函数还可以再进一步优化。首先,在 props 里添加 element
和 ref
,前者为容器的 tagName,后者为获取滚动元素的回调:
interface Props { ... element?: string // 元素 tag 名 ref?: (node: HTMLElement | null) => void // 获取要滚动的元素 }
然后改写 render
render() { const { // 内部 props children, element, hasMore, isReverse, loader, loadMore, initialLoad, pageStart, ref, threshold, useCapture, useWindow, getScrollParent, // 需要 pass 的 props ...props } = this.props const childrenArray = [children] if (hasMore && loader) { isReverse ? childrenArray.unshift(loader) : childrenArray.push(loader) } const passProps = { ...props, ref: (node: HTMLElement | null) => { this.scrollComponent = node if (ref) { ref(node) } } } return createElement(element || 'div', passProps, childrenArray) }
这一步主要优化了 3 个点: 1. 将 tagName (element
props) 也作为 props 暴露出来 2. 将剩下的 props 透传给滚动元素 3. 在 passProps
里添加 ref
,开发者可以通过 ref
获取滚动元素
这篇文章主要带大家过了一遍 react-infinite-scroller 的源码,从 0 到 1 地实现了一遍源码。
核心部分为 offset < threshold
则加载更多,offset
的计算规则如下:
el.scrollHeight - parentElement.scrollTop - parentElement.clientHeight
parentElement.scrollTop
calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight)
window.pageYOffset || doc.scrollTop
doc = document.documentElement || document.body.parentElement || document.body
当然,这个轮子还有很多细节值得我们注意:
offset < threshold
时移除 listener。还有一点,在添加 listener 的时候可以触发一次 listener 作为 initialLoad
componentDidUpdate
里要把滚动条设置为上一次停留的地方,否则滚动条会一直在顶部,一直触发“加载更多”e.preventDefault
解决“加载更多”时间超长的问题饥人谷一直致力于培养有灵魂的编程者,打造专业有爱的国内前端技术圈子。如造梦师一般帮助近千名不甘寂寞的追梦人把编程梦变为现实,他们以饥人谷为起点,足迹遍布包括facebook、阿里巴巴、百度、网易、京东、今日头条、大众美团、饿了么、ofo在内的国内外大小企业。 了解培训课程:加微信 xiedaimala03,官网:https://jirengu.com