博客
React|带你实现一个实用的 scrollTop hook 组件

React | 带你实现一个实用的 scrollTop hook 组件

本文也在掘金发布 (opens in a new tab)

前言

在很多应用场景,当我们滚动时我们可能需要获取页面滚动高度 scrollTop ,但是每次使用,都需要写一堆代码,比如监听滚动事件、注销滚动事件、节流逻辑、滚动结束等等。

为了预防每次都写同样的逻辑、简化业务代码,本篇文章将一步一步实现一个完善的 hook 组件。

实现

为了让逻辑更清晰,分成几步(大家可以看着步骤试着自己去实现看看):

  • 简单实现 scrollTop hook —— useScrollTop
  • 加入节流控制频率
  • 添加滚动状态
  • 添加滚动区域限制

每个实现附上了 demo 链接(国内)和效果图,如果 demo 链接打不开,可以去 clone 案例项目 (opens in a new tab).

初步实现 useScrollTop

主要思路:didMount 时监听 scroll 事件,unMount 时注销 scroll 事件。

useScrollTop 初步代码;

import { useEffect, useState } from "react";
 
export function useScrollTop() {
  const [scrollTop, setScrollTop] = useState(0);
  useEffect(() => {
    function scrollFn(): void {
      // 获取 scrollTop
      const newScrollTop =
        document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
      // 更新 state
      setScrollTop(newScrollTop);
    }
    // 监听 scroll 事件
    window.addEventListener("scroll", scrollFn);
    return (): void => {
      // 注销 scroll 事件
      window.removeEventListener("scroll", scrollFn);
    };
  }, []);
  return scrollTop;
}

效果如下(也可以点击去查看demo (opens in a new tab)):

5ic1i-n3p8x.gif

主要逻辑:

  • scrollFn:获取 scrollTop -> 更新 state
  • 监听 scroll 事件
  • 组件销毁时注销 scroll 事件

节流

使用 lodash/throttle 来完成节流,并使用 scrollThreshold 参数来控制节流时间。

下面来看具体实现代码:

import throttle from "lodash/throttle";
 
type Options = {
  /**
   * 滚动节流时间设置,默认 10ms
   */
  scrollThreshold?: number;
}
 
export function useScrollTop({ scrollThreshold = 10 }: Options = {}) {
  const [scrollTop, setScrollTop] = useState(0);
  useEffect(() => {
    // scrollThreshold 大于 0 才有使用 throttle 的必要
    const handleScroll = scrollThreshold > 0 ? throttle((newScrollTop: number) => {
      setScrollTop(newScrollTop);
    }, scrollThreshold, { leading: true, trailing: false }) : setScrollTop;
 
    function scrollFn(): void {
      // 获取 scrollTop
      const newScrollTop =
        document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
      // 更新 state
      handleScroll(newScrollTop);
    }
    // ...重复代码省略
  }, []);
  return scrollTop;
}

效果如下(也可以点击去查看demo (opens in a new tab)):

ub3v8-wyfk3.gif

主要改动逻辑:

  • 新增 handleScroll 函数,函数需要被 throttle 包裹,把 setScrollTop 放入 handleScroll内部触发。
  • scrollFn 更新 state 位置,改成 handleScroll(newScrollTop)

添加滚动状态

记录滚动状态,那么需要滚动结束事件,浏览器默认没有提供滚动事件,因此我们需要进行模拟实现。

使用 lodash/debounce 来完成结束事件的防抖,一般来说不连续触发滚动事件后,则代表滚动结束。

下面来看具体实现代码:

import debounce from "lodash/debounce";
 
type Options = {
  /**
   * 滚动节流时间设置,默认 10ms
   */
  scrollThreshold?: number;
  /**
   * 滚动完成防抖时间设置,默认 500ms
   */
  scrollFinshedThreshold?: number;
}
 
export function useScrollTop({ scrollThreshold = 10, scrollFinshedThreshold = 500 }: Options = {}) {
  const [state, setState] = useState({
    scrollTop: 0,
    scrolling: false,
  });
  useEffect(() => {
    // 滚动状态
    let scrolling = false;
 
    // 触发 setScrollTop 统一到此处
    function handleChangeScrollTop(newScrollTop: number): void {
      // 更新外部状态
      setState({
        scrollTop: newScrollTop,
        scrolling,
      })
    }
 
    // scrollThreshold 大于 0 才有使用 throttle 的必要
    const handleScroll = scrollThreshold > 0 ? throttle((newScrollTop: number) => {
      handleChangeScrollTop(newScrollTop);
    }, scrollThreshold, { leading: true, trailing: false }) : handleChangeScrollTop;
 
    // 滚动结束事件
    const scrollEnd = debounce(
      (newScrollTop) => {
        scrolling = false;
        handleChangeScrollTop(newScrollTop);
      },
      scrollFinshedThreshold,
      { leading: false, trailing: true }
    );
 
    function scrollFn(): void {
      // ...
      // 使用被 debounce 的函数来模拟滚动结束
      scrollEnd(newScrollTop)
    }
    // ...
  }, []);
  return state;
}

效果如下(也可以点击去查看demo (opens in a new tab)):

ub3v8-wyfk3.gif

主要改动逻辑:

  • 使用 state 字段存储状态,新增 scrolling 状态,用于记录滚动状态。
  • 新增 handleChangeScrollTop 函数,触发 scrollTop 改变和 scrolling 状态统一到此处。
  • 新增了 useEffect 内部状态 scrolling ,最终的值会同步到外部 state
  • 新增 scrollEnd 事件,结束事件在滚动结束完成才触发,debounce 可以用于模拟没有滚动事件结束的时候才执行,但是需要注意设置的 wait 时间,太小可能会执行多次,这里默认值为 500ms

添加滚动区域限制及其他功能完善

如果我只想滚动到某个区域才进行监听 scrollTop,那该如何实现呢?

实现思路:

  • 我们使用添加 scrollTop 最小临界值(minCriticalvalue)和最大临界值(maxCriticalvalue)的方式来进行实现区域内触发功能
  • 当设置 minCriticalvaluemaxCriticalvalue 相同时,触发一次scrollTop改变,一般可用于判断是否显示某个模块。
  • 代码逻辑优化:useScrollTop 需要用于在子组件获取当前 scrollTop 时,可以在未进行滚动时初始化获取

具体实现代码:

import { useEffect, useState } from "react";
import throttle from "lodash/throttle";
import debounce from "lodash/debounce";

type Options = {
  /**
   * 滚动节流时间设置,默认 10ms
   */
  scrollThreshold?: number;
  /**
   * 滚动完成防抖时间设置,默认 500ms
   */
  scrollFinshedThreshold?: number;
  /**
   * 监听滚动区域最小 scrollTop 值,单位px,默认 0,也就是scrollTop >=0 就触发重新渲染
   */
  minCriticalvalue?: number;
  /**
   * 监听滚动最大 scrollTop 值,单位px,默认 100000 ,也就是scrollTop <=100000 就触发重新渲染
   */
  maxCriticalvalue?: number;
};

export function useScrollTop({
  scrollThreshold = 10,
  scrollFinshedThreshold = 500,
  minCriticalvalue = 0,
  maxCriticalvalue = 100000,
}: Options = {}) {
  const [state, setState] = useState({
    scrollTop: 0,
    scrolling: false,
  });
  useEffect(() => {
    // 滚动状态
    let scrolling = false;
    // 获取初始值
    let scrollTop =
      document.documentElement.scrollTop ||
      window.pageYOffset ||
      document.body.scrollTop;

    // 初始值不正确进行校验
    if (
      scrollTop !== state.scrollTop &&
      scrollTop >= minCriticalvalue &&
      scrollTop < maxCriticalvalue
    ) {
      setState({ scrollTop: scrollTop, scrolling: state.scrolling });
    }

    // 触发 setScrollTop 统一到此处
    function handleChangeScrollTop(newScrollTop: number): void {
      // 更新外部状态
      setState({
        scrollTop: newScrollTop,
        scrolling,
      });
      // 更新内部状态
      scrollTop = newScrollTop;
    }

    // scrollThreshold 大于 0 才有使用 throttle 的必要
    const handleScroll =
      scrollThreshold > 0
        ? throttle(
            (newScrollTop: number) => {
              // 滚动中才触发,预防和结束事件冲突
              if (scrolling) {
                handleChangeScrollTop(newScrollTop);
              }
            },
            scrollThreshold,
            { leading: true, trailing: false }
          )
        : handleChangeScrollTop;

    // 滚动结束事件
    const scrollEnd = debounce(
      (newScrollTop) => {
        // 未结束才触发,不然又可能划出界限直接结束,导致上一次的 scrollEnd 还没结束。
        if (scrolling) {
          scrolling = false;
          handleChangeScrollTop(newScrollTop);
        }
      },
      scrollFinshedThreshold,
      { leading: false, trailing: true }
    );

    function scrollFn(): void {
      // 获取 scrollTop
      const newScrollTop =
        document.documentElement.scrollTop ||
        window.pageYOffset ||
        document.body.scrollTop;

      if (
        (newScrollTop <= minCriticalvalue && scrollTop >= minCriticalvalue) ||
        (newScrollTop >= maxCriticalvalue && scrollTop <= maxCriticalvalue)
      ) {
        // 滚动出界限直接结束
        scrolling = false;
        handleChangeScrollTop(newScrollTop);
        return;
      } else if (
        newScrollTop >= minCriticalvalue &&
        newScrollTop <= maxCriticalvalue
      ) {
        // 在界限内滚动,才触发改变事件
        scrolling = true;
        // 更新 state
        handleScroll(newScrollTop);
        // 使用被 debounce 的函数来模拟滚动结束
        scrollEnd(newScrollTop);
      }
    }
    // 监听 scroll 事件
    window.addEventListener("scroll", scrollFn);
    return (): void => {
      // 注销 scroll 事件
      window.removeEventListener("scroll", scrollFn);
    };
  }, []);
  return state;
}

效果如下(也可以点击去查看demo (opens in a new tab)):

7izmx-qm6kx.gif

这样就实现了只有在设定的滚动区域才会触发 scrollTop 改变。

主要实现逻辑:

  1. 内部记录传递给外部的 scrollTop
  2. 调整了 scrollFn 实现逻辑:
    1. scrolling 状态设置位置调整是重点,不能放在最前面了。
    2. 使用内部记录的 scrollTop,这个在这里相当于滚动前的 scrollTopnewScrollTop 是现在滚动的 scrollTop,这样可以判断是否滚动出设置界限,如果滚动了界限就直接改变 scrolling 状态为 false, 并且通知外部修改 scrollTop
    3. 在设置滚动界限内,则触发 handleScrollscrollEnd 事件。
  3. handleScrollscrollEnd 都需要判断 scrolling 状态,因为这两个函数是异步执行的(抖动和截流),而前面描述 scrollFn 函数中第2点逻辑是会同步修改状态的。

结语

我们一步一步实现了一个相对完整的 useScrollTop hook 组件,其实大部分实现也可以抽离出来,封装成一个JS类,这样在 vue 或者其他前端框架上也可以正常的使用了。

最后留一个思考点:当前都是基于整个页面的滚动的,如果是局部div内部滚动,那么该如何实现这个功能?