字节笔记本

2026年2月22日

手把手实现 React 无限滚动组件

本文介绍 my-react-infinite-scroller,一个手把手教你实现 React 无限滚动组件的开源教学项目。该项目不仅提供了完整的无限滚动实现代码,还详细讲解了 scrollHeight、scrollTop、clientHeight 等核心概念,帮助开发者深入理解无限滚动的底层原理。

项目简介

my-react-infinite-scroller 是由 haixiangyan 开发的一个开源教学项目,旨在帮助开发者从零开始理解并实现 React 无限滚动组件。与直接使用现成的 npm 包不同,这个项目通过循序渐进的代码示例,让你彻底搞懂无限滚动的核心原理。

截至目前,该项目在 GitHub 上已获得 13 stars,包含 21 次提交,采用 TypeScript 编写,并配有详细的图文教程。

核心特性

  • 循序渐进的学习路径:从最小实现开始,逐步添加功能
  • 完整的 TypeScript 支持:类型安全的代码实现
  • 双向无限滚动:支持向下和向上(反向)无限滚动
  • 全局/局部滚动容器:支持 window 和自定义容器两种滚动模式
  • 详细的原理解析:图解 scrollHeight、scrollTop、clientHeight 等关键概念
  • 实际可运行的 Demo:提供在线预览和本地运行示例

技术栈

  • React - 核心 UI 框架
  • TypeScript - 类型安全的 JavaScript 超集
  • CSS - 样式处理

核心原理:offset 公式

无限滚动的核心原理很简单:当「剩余可滚动距离」小于设定的阈值时,触发加载更多数据的操作。

计算公式

typescript
const offset = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight

if (offset < this.props.threshold) {
  this.props.loadMore()
}

关键概念解释

属性说明
scrollHeight元素的内容高度,包括溢出导致的不可见内容
scrollTop元素内容垂直滚动的像素数
clientHeight元素的可视高度(包含 padding)

安装与使用

克隆项目

bash
git clone https://github.com/haixiangyan/my-react-infinite-scroller.git
cd my-react-infinite-scroller

安装依赖

bash
npm install
# 或
yarn install

启动项目

bash
npm start
# 或
yarn start

访问 http://localhost:3000 查看效果。

快速开始

基础用法

typescript
import React, { useState, useEffect } from 'react'
import InfiniteScroll from './InfiniteScroll'

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
          threshold={50}
          loadMore={fetchMore}
          loader={<div className="loader" key={0}>Loading ...</div>}
        >
          {items.map((item) => (
            <div key={item}>{item}</div>
          ))}
        </InfiniteScroll>
      </div>
    </div>
  )
}

export default App

核心实现解析

最小实现版本

typescript
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() {
    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
    }
  }

  componentDidMount() {
    this.pageLoaded = this.props.pageStart || 0
    this.attachScrollListener()
  }

  componentDidUpdate() {
    this.attachScrollListener()
  }

  componentWillUnmount() {
    this.detachScrollListener()
  }

  attachScrollListener() {
    const parentElement = this.getParentElement(this.scrollComponent)
    if (!parentElement) return
    parentElement.addEventListener('scroll', this.scrollListener)
    parentElement.addEventListener('resize', this.scrollListener)
  }

  detachScrollListener() {
    const parentElement = this.getParentElement(this.scrollComponent)
    if (!parentElement) return
    parentElement.removeEventListener('scroll', this.scrollListener)
    parentElement.removeEventListener('resize', this.scrollListener)
  }

  getParentElement(el: HTMLElement | null): HTMLElement | null {
    return el && el.parentElement
  }

  render() {
    const { children, loader } = this.props
    return (
      <div ref={(node) => (this.scrollComponent = node)}>
        {children}
        {loader}
      </div>
    )
  }
}

支持 Window 全局滚动

当需要在全局(window)实现无限滚动时,需要使用另一套计算公式:

typescript
// 计算元素顶部到页面顶部的距离
calculateTopPosition(el: HTMLElement | null): number {
  if (!el) return 0
  return el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement)
}

// 计算 offset
calculateOffset(el: HTMLElement | null, scrollTop: number) {
  if (!el) return 0
  return this.calculateTopPosition(el) + el.offsetHeight - scrollTop - window.innerHeight
}

支持反向无限滚动

反向无限滚动(向上滚动加载更多)常用于聊天记录等场景:

typescript
// 在 scrollListener 中处理反向滚动
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
}

API 参考

Props

属性类型必填说明
loadMoreFunction加载更多数据的回调函数
loaderReactNode加载状态显示的组件
thresholdnumber触发加载的阈值(像素)
hasMoreboolean是否还有更多数据可加载
pageStartnumber初始页码,默认为 0
useWindowboolean是否使用 window 作为滚动容器
isReverseboolean是否为反向无限滚动
getScrollParent() => HTMLElement自定义获取滚动容器的函数

项目链接

总结

my-react-infinite-scroller 是一个非常优秀的开源教学项目,它不仅提供了可运行的代码,更重要的是通过详细的图文教程帮助开发者理解无限滚动的底层原理。如果你想深入理解 React 组件开发或者需要自定义无限滚动功能,这个项目绝对值得学习。

分享: