Skip to content

useScroll ​

一个功能强大的 React Hook,用于检测滚动状态和管理滚动事件监听器。

交互演示 React

亲手试一试,更容易理解。

在当前页面运行 useScroll 的交互示例。

功能特性 ​

  • ✅ 滚动状态检测: 检测滚动位置、边界状态等
  • ✅ 平滑滚动动画: 支持多种缓动函数的平滑滚动
  • ✅ 滚动事件监听: 在特定滚动位置触发回调函数
  • ✅ 灵活的目标支持: 支持窗口滚动和容器滚动
  • ✅ 高性能优化: 使用防抖和 RAF 优化
  • ✅ 自动清理: 组件卸载时自动清理资源

基本用法 ​

typescript
import { useScroll } from "./useScroll";

function MyComponent() {
  // 监听窗口滚动(默认)
  const [scrollState, scrollMethods] = useScroll();

  // 监听特定容器滚动
  const containerRef = useRef<HTMLDivElement>(null);
  const [containerState, containerMethods] = useScroll(containerRef);

  return (
    <div>
      <div>当前滚动位置: {scrollState.y}px</div>
      <button onClick={() => scrollMethods.scrollToTop()}>滚动到顶部</button>
    </div>
  );
}

滚动事件监听器 ​

添加事件监听器 ​

typescript
useEffect(() => {
  // 监听垂直滚动到 500px 时触发
  const unsubscribe = scrollMethods.addEventListener(
    500,
    "y",
    (position, direction) => {
      console.log(`滚动到了 ${position}px`);
      // 执行你的逻辑
    }
  );

  // 组件卸载时取消订阅
  return unsubscribe;
}, [scrollMethods]);

监听水平滚动 ​

typescript
// 监听水平滚动到 300px 时触发
const unsubscribe = scrollMethods.addEventListener(300, "x", (position) => {
  console.log(`水平滚动到了 ${position}px`);
});

移除事件监听器 ​

typescript
// 移除特定监听器
scrollMethods.removeEventListener(500, "y", callback);

// 移除所有匹配位置和方向的监听器
scrollMethods.removeEventListener(500, "y");

// 清除所有事件监听器
scrollMethods.clearAllEventListeners();

API 参考 ​

useScroll(target?, options?) ​

参数 ​

参数类型默认值描述
targetBasicTarget<HTMLElement | Document>document滚动目标
optionsScrollOptions{}配置选项

target 支持的类型:

类型描述示例
RefObject<HTMLElement>React ref 对象useRef<HTMLDivElement>(null)
HTMLElementDOM 元素document.getElementById('container')
Document文档对象(窗口滚动)document
() => HTMLElement | Document返回元素的函数() => document.getElementById('id')
undefined默认为窗口滚动-

options 配置项:

属性类型默认值描述
scrollThresholdnumber1滚动阈值(px),用于判断是否到达边界
debounceDelaynumber100防抖延迟(ms),优化性能

返回值 ​

返回一个数组 [scrollState, scrollMethods]

索引名称类型描述
0scrollStateScrollState滚动状态对象
1scrollMethodsScrollMethods滚动方法对象

ScrollState ​

滚动状态对象,包含当前滚动的各种状态信息。

属性类型描述
xnumber水平滚动位置(px)
ynumber垂直滚动位置(px)
isTopboolean是否在顶部(距离顶部 ≤ scrollThreshold)
isBottomboolean是否在底部(距离底部 ≤ scrollThreshold)
isLeftboolean是否在左侧(距离左侧 ≤ scrollThreshold)
isRightboolean是否在右侧(距离右侧 ≤ scrollThreshold)
hasVerticalScrollboolean是否有垂直滚动条
hasHorizontalScrollboolean是否有水平滚动条

ScrollMethods ​

滚动方法对象,包含各种滚动操作和事件监听器管理方法。

滚动方法 ​

方法参数返回值描述
scrollToTopoptions?: ScrollOptionsvoid滚动到顶部
scrollToBottomoptions?: ScrollOptionsvoid滚动到底部
scrollToLeftoptions?: ScrollOptionsvoid滚动到左侧
scrollToRightoptions?: ScrollOptionsvoid滚动到右侧
scrollTotarget: number | targetPosition, options?: ScrollOptionsvoid滚动到指定位置

事件监听器方法 ​

方法参数返回值描述
addEventListenerposition: number, direction: 'x' | 'y', callback: ScrollEventCallback() => void添加滚动事件监听器,返回取消订阅函数
removeEventListenerposition: number, direction: 'x' | 'y', callback?: ScrollEventCallbackvoid移除滚动事件监听器
clearAllEventListeners-void清除所有事件监听器

ScrollEventCallback ​

事件回调函数类型:(currentPosition: number, direction: 'x' | 'y') => void

参数类型描述
currentPositionnumber当前滚动位置(px)
direction'x' | 'y'滚动方向

ScrollOptions ​

滚动动画配置选项。

属性类型默认值描述
durationnumber300动画持续时间(ms)
animationScrollAnimationType'smooth'缓动函数类型

ScrollAnimationType ​

类型描述示例
'smooth'平滑缓动{ animation: 'smooth' }
'linear'线性缓动{ animation: 'linear' }
'ease'标准缓动{ animation: 'ease' }
'ease-in'缓入{ animation: 'ease-in' }
'ease-out'缓出{ animation: 'ease-out' }
'ease-in-out'缓入缓出{ animation: 'ease-in-out' }
[number, number, number, number]自定义贝塞尔曲线{ animation: [0.25, 0.46, 0.45, 0.94] }

targetPosition ​

滚动目标位置对象,用于 scrollTo 方法。

属性类型描述示例
xnumber | string水平滚动目标位置100 或 '50%'
ynumber | string垂直滚动目标位置200 或 '25%'

支持的值类型:

  • number: 像素值,如 100
  • string: 百分比值,如 '50%'(相对于可滚动区域)

高级用法 ​

多个事件监听器 ​

typescript
useEffect(() => {
  const unsubscribers = [
    scrollMethods.addEventListener(200, "y", () => console.log("到达 200px")),
    scrollMethods.addEventListener(500, "y", () => console.log("到达 500px")),
    scrollMethods.addEventListener(1000, "y", () => console.log("到达 1000px")),
  ];

  return () => unsubscribers.forEach((fn) => fn());
}, [scrollMethods]);

百分比滚动 ​

typescript
// 滚动到 50% 位置
scrollMethods.scrollTo({ y: "50%" });

// 滚动到水平 25% 位置
scrollMethods.scrollTo({ x: "25%" });

自定义缓动函数 ​

typescript
// 使用自定义贝塞尔曲线
scrollMethods.scrollToTop({
  duration: 1000,
  animation: [0.25, 0.46, 0.45, 0.94], // cubic-bezier
});

注意事项 ​

  1. 自动清理: 组件卸载时会自动清理所有事件监听器
  2. 重复触发: 事件监听器支持重复触发,当滚动位置回到目标位置下方再次超过时会重新触发
  3. 性能优化: 内部使用防抖和 requestAnimationFrame 优化性能
  4. 类型安全: 完整的 TypeScript 类型支持

示例场景 ​

  • 滚动到特定位置时显示/隐藏元素
  • 滚动进度指示器
  • 懒加载触发
  • 滚动动画触发
  • 导航栏状态切换
  • 滚动统计和分析

API 速查表 ​

Hook 调用 ​

typescript
const [scrollState, scrollMethods] = useScroll(target?, options?)

快速参考 ​

功能方法/属性示例
获取滚动位置scrollState.x, scrollState.yconsole.log(scrollState.y)
检查边界状态scrollState.isTop, scrollState.isBottomif (scrollState.isTop) { ... }
滚动到顶部scrollMethods.scrollToTop()scrollMethods.scrollToTop({ duration: 500 })
滚动到指定位置scrollMethods.scrollTo()scrollMethods.scrollTo(500)
百分比滚动scrollMethods.scrollTo()scrollMethods.scrollTo({ y: '50%' })
添加事件监听scrollMethods.addEventListener()addEventListener(500, 'y', callback)
移除事件监听scrollMethods.removeEventListener()removeEventListener(500, 'y')
清除所有监听scrollMethods.clearAllEventListeners()clearAllEventListeners()

实现源码 ​

查看 useScroll 的实现
tsx
import { useCallback, useEffect, useState, useRef } from "react";
import {
  ScrollDetectionOptions,
  ScrollDirection,
  ScrollMethods,
  ScrollState,
  ScrollOptions,
  ScrollAnimationType,
  targetPosition,
  ScrollEventCallback,
  ScrollEventListener,
} from "./types";
import { debounceRAF, getEasingFunction, parseScrollValue } from "./utils";
import type { BasicTarget } from "./utils/domTarget";
import { getTargetElement } from "./utils/domTarget";

const DEFAULT_OPTIONS: Required<ScrollDetectionOptions> = {
  scrollThreshold: 1,
  debounceDelay: 100,
};

const DEFAULT_SCROLL_OPTIONS = {
  duration: 300,
  animation: "smooth" as const,
};

export function useScroll(
  target?: BasicTarget<HTMLElement | Document>,
  options?: ScrollDetectionOptions
): [ScrollState, ScrollMethods] {
  const { scrollThreshold, debounceDelay } = {
    ...DEFAULT_OPTIONS,
    ...options,
  };

  // 滚动事件监听器存储
  const scrollEventListeners = useRef<ScrollEventListener[]>([]);

  // 获取滚动容器,默认为 document
  const getScrollContainer = useCallback(() => {
    return getTargetElement(target, document) as HTMLElement | Document;
  }, [target]);

  // 获取滚动信息的辅助函数
  const getScrollInfo = useCallback((container: HTMLElement | Document) => {
    if (container === document) {
      return {
        scrollTop: Math.max(
          window.pageYOffset,
          document.documentElement.scrollTop,
          document.body.scrollTop
        ),
        scrollLeft: Math.max(
          window.pageXOffset,
          document.documentElement.scrollLeft,
          document.body.scrollLeft
        ),
        scrollHeight: Math.max(
          document.documentElement.scrollHeight,
          document.body.scrollHeight
        ),
        scrollWidth: Math.max(
          document.documentElement.scrollWidth,
          document.body.scrollWidth
        ),
        clientHeight: window.innerHeight,
        clientWidth: window.innerWidth,
      };
    } else {
      const element = container as HTMLElement;
      return {
        scrollTop: element.scrollTop,
        scrollLeft: element.scrollLeft,
        scrollHeight: element.scrollHeight,
        scrollWidth: element.scrollWidth,
        clientHeight: element.clientHeight,
        clientWidth: element.clientWidth,
      };
    }
  }, []);

  // 设置滚动位置的辅助函数
  const setScrollPosition = useCallback(
    (container: HTMLElement | Document, x?: number, y?: number) => {
      if (container === document) {
        if (x !== undefined) window.scrollTo(x, window.pageYOffset);
        if (y !== undefined) window.scrollTo(window.pageXOffset, y);
      } else {
        const element = container as HTMLElement;
        if (x !== undefined) element.scrollLeft = x;
        if (y !== undefined) element.scrollTop = y;
      }
    },
    []
  );

  // 检查并触发滚动事件监听器
  const checkScrollEventListeners = useCallback(
    (scrollInfo: ReturnType<typeof getScrollInfo>) => {
      const { scrollLeft, scrollTop } = scrollInfo;

      scrollEventListeners.current.forEach((listener) => {
        const currentPosition =
          listener.direction === "x" ? scrollLeft : scrollTop;

        // 如果当前位置大于等于目标位置且还未触发过
        if (currentPosition >= listener.position && !listener.triggered) {
          listener.triggered = true;
          listener.callback(currentPosition, listener.direction);
        }
        // 如果当前位置小于目标位置,重置触发状态(支持重复触发)
        else if (currentPosition < listener.position && listener.triggered) {
          listener.triggered = false;
        }
      });
    },
    []
  );

  // 添加滚动事件监听器
  const addEventListener = useCallback(
    (position: number, direction: "x" | "y", callback: ScrollEventCallback) => {
      const listener: ScrollEventListener = {
        position,
        direction,
        callback,
        triggered: false,
      };

      scrollEventListeners.current.push(listener);

      // 返回取消订阅函数
      return () => {
        const index = scrollEventListeners.current.indexOf(listener);
        if (index > -1) {
          scrollEventListeners.current.splice(index, 1);
        }
      };
    },
    []
  );

  // 移除滚动事件监听器
  const removeEventListener = useCallback(
    (
      position: number,
      direction: "x" | "y",
      callback?: ScrollEventCallback
    ) => {
      scrollEventListeners.current = scrollEventListeners.current.filter(
        (listener) => {
          if (
            listener.position === position &&
            listener.direction === direction
          ) {
            // 如果指定了回调函数,只移除匹配的监听器
            if (callback) {
              return listener.callback !== callback;
            }
            // 如果没有指定回调函数,移除所有匹配位置和方向的监听器
            return false;
          }
          return true;
        }
      );
    },
    []
  );

  // 清除所有事件监听器
  const clearAllEventListeners = useCallback(() => {
    scrollEventListeners.current = [];
  }, []);

  const [state, setState] = useState<ScrollState>({
    hasVerticalScroll: false,
    hasHorizontalScroll: false,
    isBottom: false,
    isTop: true,
    isLeft: true,
    isRight: false,
    y: 0,
    x: 0,
  });

  // 动画核心逻辑
  const animateScroll = useCallback(
    (
      start: number,
      end: number,
      direction: ScrollDirection,
      scrollOptions: ScrollOptions = {}
    ) => {
      const container = getScrollContainer();
      if (!container || start === end) return;

      const { duration = 300, animation = "smooth" as ScrollAnimationType } = {
        ...DEFAULT_SCROLL_OPTIONS,
        ...scrollOptions,
      };
      const startTime =
        window.performance && performance.now ? performance.now() : Date.now();

      const scrollInfo = getScrollInfo(container);
      const initialX = scrollInfo.scrollLeft;
      const initialY = scrollInfo.scrollTop;

      //兼容IE9 及更旧版, 仅支持客户端
      const getRequestAnimationFrame = () => {
        if (typeof window === "undefined") return () => {};

        return (
          window.requestAnimationFrame ||
          (window as any).msRequestAnimationFrame ||
          (window as any).mozRequestAnimationFrame ||
          (window as any).webkitRequestAnimationFrame ||
          function (callback: FrameRequestCallback): number {
            return window.setTimeout(() => callback(Date.now()), 16);
          }
        );
      };

      // 得到兼容后的requestAnimationFrame
      const compatibleRAF = getRequestAnimationFrame();

      const updateScroll = (timestamp: number) => {
        const elapsed = timestamp - startTime;
        const progress = Math.min(elapsed / duration, 1);
        const ease = getEasingFunction(animation)(progress);

        switch (direction) {
          case "x":
            setScrollPosition(container, start + (end - start) * ease);
            break;
          case "y":
            setScrollPosition(
              container,
              undefined,
              start + (end - start) * ease
            );
            break;
          case "both":
            setScrollPosition(
              container,
              initialX + (end - initialX) * ease,
              initialY + (end - initialY) * ease
            );
            break;
        }

        if (progress < 1) {
          compatibleRAF(updateScroll);
        }
      };

      compatibleRAF(updateScroll);
    },
    [getScrollContainer, getScrollInfo, setScrollPosition]
  );

  // 滚动到顶部
  const scrollToTop = useCallback(
    (options?: ScrollOptions) => {
      const container = getScrollContainer();
      if (!container) return;
      const scrollInfo = getScrollInfo(container);
      animateScroll(scrollInfo.scrollTop, 0, "y", options);
    },
    [animateScroll, getScrollContainer, getScrollInfo]
  );

  // 滚动到底部
  const scrollToBottom = useCallback(
    (options?: ScrollOptions) => {
      const container = getScrollContainer();
      if (!container) return;
      const scrollInfo = getScrollInfo(container);
      animateScroll(
        scrollInfo.scrollTop,
        scrollInfo.scrollHeight - scrollInfo.clientHeight,
        "y",
        options
      );
    },
    [animateScroll, getScrollContainer, getScrollInfo]
  );

  // 滚动到左侧
  const scrollToLeft = useCallback(
    (options?: ScrollOptions) => {
      const container = getScrollContainer();
      if (!container) return;
      const scrollInfo = getScrollInfo(container);
      animateScroll(scrollInfo.scrollLeft, 0, "x", options);
    },
    [animateScroll, getScrollContainer, getScrollInfo]
  );

  // 滚动到右侧
  const scrollToRight = useCallback(
    (options?: ScrollOptions) => {
      const container = getScrollContainer();
      if (!container) return;
      const scrollInfo = getScrollInfo(container);
      animateScroll(
        scrollInfo.scrollLeft,
        scrollInfo.scrollWidth - scrollInfo.clientWidth,
        "x",
        options
      );
    },
    [animateScroll, getScrollContainer, getScrollInfo]
  );

  // 通用滚动方法
  const scrollTo = useCallback(
    (scrollTarget: number | targetPosition, options?: ScrollOptions) => {
      const container = getScrollContainer();
      if (!container) return;

      const scrollInfo = getScrollInfo(container);
      let targetX: number | undefined;
      let targetY: number | undefined;
      const currentX = scrollInfo.scrollLeft;
      const currentY = scrollInfo.scrollTop;

      //为单一number类型时,默认为y轴滚动
      if (typeof scrollTarget === "number") {
        targetY = parseScrollValue(scrollTarget, container, "y") ?? currentY;
      } else {
        targetX = parseScrollValue(scrollTarget.x, container, "x") ?? currentX;
        targetY = parseScrollValue(scrollTarget.y, container, "y") ?? currentY;
      }

      // 执行滚动
      if (targetX !== undefined && targetX !== currentX) {
        animateScroll(currentX, targetX, "x", options);
      }
      if (targetY !== undefined && targetY !== currentY) {
        animateScroll(currentY, targetY, "y", options);
      }
    },
    [animateScroll, getScrollContainer, getScrollInfo]
  );

  // 滚动状态检测逻辑
  useEffect(() => {
    const container = getScrollContainer();
    if (!container) return;

    const calculateState = () => {
      const scrollInfo = getScrollInfo(container);
      const {
        scrollTop,
        scrollLeft,
        clientHeight,
        clientWidth,
        scrollHeight,
        scrollWidth,
      } = scrollInfo;

      // 检查并触发滚动事件监听器
      checkScrollEventListeners(scrollInfo);

      const newState: ScrollState = {
        hasVerticalScroll: scrollHeight > clientHeight,
        hasHorizontalScroll: scrollWidth > clientWidth,
        isBottom: scrollTop + clientHeight >= scrollHeight - scrollThreshold,
        isTop: scrollTop <= scrollThreshold,
        isLeft: scrollLeft <= scrollThreshold,
        isRight: scrollLeft + clientWidth >= scrollWidth - scrollThreshold,
        y: scrollTop,
        x: scrollLeft,
      };

      setState((prev) =>
        JSON.stringify(prev) === JSON.stringify(newState) ? prev : newState
      );
    };

    const debouncedCalculate = debounceRAF(calculateState, debounceDelay);

    const events = ["scroll", "resize"];
    const eventTarget = container === document ? window : container;

    events.forEach((e) => {
      eventTarget.addEventListener(e, debouncedCalculate);
    });

    debouncedCalculate();

    return () => {
      events.forEach((e) => {
        eventTarget.removeEventListener(e, debouncedCalculate);
      });
      debouncedCalculate.cancel();
    };
  }, [
    getScrollContainer,
    getScrollInfo,
    checkScrollEventListeners,
    scrollThreshold,
    debounceDelay,
  ]);

  // 组件卸载时清除所有事件监听器
  useEffect(() => {
    return () => {
      clearAllEventListeners();
    };
  }, [clearAllEventListeners]);

  return [
    state,
    {
      scrollToTop,
      scrollToBottom,
      scrollToLeft,
      scrollToRight,
      scrollTo,
      addEventListener,
      removeEventListener,
      clearAllEventListeners,
    },
  ];
}

在实践中理解 React,在分享中共同成长。