最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 造一个 react-infinite-scroller 轮子

    正文概述 掘金(海怪)   2021-04-05   964

    造一个 react-infinite-scroller 轮子

    无限滚动是一个开发时经常遇到的问题,比如 ant-design 的 List 组件里就推荐使用 react-infinite-scroller 配合 List 组件一起使用。

    造一个 react-infinite-scroller 轮子

    假如我们想自己实现无限滚动,难免要去查 scroll 事件,还要搞清 offsetHeight, scrollHeight, pageX 这些奇奇怪怪变量之间的关系,真让人脑袋大。今天就带大家造一个 reac-infinite-scroller 的轮子吧。

    offset 公式

    无限滚动的原理很简单:只要 很长元素总高度 - 窗口距离顶部高度 - 窗口高度 < 阈值 就加载更多,前面那一堆下称为 offset,表示还剩多少 px 到达底部

    然后就懵逼了:scrollY, pageY, scrollTop, offsetTop, clientHeight 这一堆的变量到底用哪个来计算呢?对于大部分人来说,这些变量简直是噩梦一般的存在,总是会傻傻搞不清。

    这里直接给出计算 offset 的公式,免得大家去查了:

    const offset = 很长元素总高度 - 窗口距离顶部高度 - 窗口高度 = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight
    
    if (offset < this.props.threshold) {
      this.props.loadMore()
    }
    

    简单说一下这些变量都是个啥:

    造一个 react-infinite-scroller 轮子

    • scrollHeight: 这个只读属性是一个元素的内容高度,包括由于溢出导致的视图中不可见内容。相当于上面的 “很长元素总高度”
    • scrollTop: 可以获取或设置一个元素的内容垂直滚动的像素数。相当于上面的 “窗口距离顶部的高度”

    造一个 react-infinite-scroller 轮子

    • clientHeight: 仅仅包括 padding 的元素高度。相当于上面的 “窗口高度”

    总结一下,上面公式里的 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>
        )
      }
    }
    

    上面就是一个最小实现,有以下注意点:

    • scrollListener 用到了 this,所以要 bind this,不然 this 为 undefined
    • parentElement 上添加/移除监听器
    • 组件 mount 的时候添加监听器,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 的动作抽象出来,再把 attachScrollListenerdetachScrollListener 抽象出来。同时,上面还对 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()
        }
      }
    }
    

    useWindow

    上面对 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
    

    造一个 react-infinite-scroller 轮子

    • offsetHeight: 是一个只读属性,返回一个元素的布局高度
    • window.pageYOffset: 其实就是 scrollY 的别名,返回文档在垂直方向已滚动的像素值
    • window.innerHeight: 为浏览器窗口的视口的高度

    造一个 react-infinite-scroller 轮子

    上面公式里“当前窗口顶部与很长元素顶部的距离 + offsetHeigh”在页面里是定死的,而 window.pageYOffset - window.innerHeight 会随着滚动而改变,两者相减则为 offset。图示:

    造一个 react-infinite-scroller 轮子

    不过,这里的 “当前窗口顶部与很长元素顶部的距离” 这一步并不能通过变量来获得,只能用 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)
      }
    
      ...
    }
    

    上面改动的点有:

    1. 添加和移除监听器时,如果 useWindow === true,以 windowscrollEl
    2. 添加计算 topPosition 和 offset 的函数: calculateTopPositioncalculateOffset
    3. 监听器里判断是否 useWindow,如果 true,使用上面的 calculateOffset 计算 offset

    至此,无限滚动最核心的滚动已经实现了。

    isReverse

    除了向下无限滚动,我们还要考虑无限向上滚动的情况。有人就会问了:一般都是无限向下的呀,哪来的无限向上?很简单,翻找微信的聊天记录不就是无限向上滚动的嘛。

    首先,在 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
    

    为了达到这个效果,我们要记录上一次的 scrollTopscrollHeight,然后在组件更新的时候更新 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 这个位置。

    实现时,首先声明 beforeScrollHeightbeforeScrollTop,并在 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()
      }
    

    至此,向上滚动也被我们实现了。

    mousewheel 事件

    在 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 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 包含了 passiveuseCapture,前者为是否开启 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 函数

    最后,render 函数还可以再进一步优化。首先,在 props 里添加 elementref,前者为容器的 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 的计算规则如下:

    1. 向下滚动:el.scrollHeight - parentElement.scrollTop - parentElement.clientHeight
    2. 下上滚动:parentElement.scrollTop
    3. window 向下滚动: calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight)
    4. 其中 calculateTopPosition 为递归地计算元素顶部到浏览器窗口顶部的距离
    5. window 向上滚动:window.pageYOffset || doc.scrollTop
    6. 其中 doc 为 doc = document.documentElement || document.body.parentElement || document.body

    当然,这个轮子还有很多细节值得我们注意:

    1. 除了 scroll 事件,resize 事件也应该触发加载更多
    2. 在 mount 和 update 的时候添加 listener,在 unmounte 和 offset < threshold 时移除 listener。还有一点,在添加 listener 的时候可以触发一次 listener 作为 initialLoad
    3. 向上滚动的时候,在 componentDidUpdate 里要把滚动条设置为上一次停留的地方,否则滚动条会一直在顶部,一直触发“加载更多”
    4. 在 mousewheel 里 e.preventDefault 解决“加载更多”时间超长的问题
    5. 添加被动监听器,提高页面滚动性能

    起源地下载网 » 造一个 react-infinite-scroller 轮子

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元