造一個 react-infinite-scroller 輪子

文章源碼: https://github.com/Haixiang6123/my-react-infinite-scroller
預覽鏈接: http://yanhaixiang.com/my-react-infinite-scroller/
參考輪子: https://www.npmjs.com/package/react-infinite-scroller

無限滾動是一個開發時經常遇到的問題,比如 ant-design 的 List 組件裏就推薦使用 react-infinite-scroller 配合 List 組件一起使用。

假如我們想自己實現無限滾動,難免要去查 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()
}

簡單說一下這些變量都是個啥:

  • scrollHeight: 這個只讀屬性是一個元素的內容高度,包括由於溢出導致的視圖中不可見內容。相當於上面的 “很長元素總高度”
  • scrollTop: 可以獲取或設置一個元素的內容垂直滾動的像素數。相當於上面的 “窗口距離頂部的高度”
  • 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

上面公式裏“當前窗口頂部與很長元素頂部的距離 + 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)
  }

  ...
}

上面改動的點有:

  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. 添加被動監聽器,提高頁面滾動性能
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章