You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React中三列循环滚动的setInterval异常问题排查与解决

三列循环滚动布局问题解决

问题描述

实现一个三列布局的React组件,要求三列通过动画循环滚动:滚动至底部后返回顶部,到达顶部后再次向下滚动。当前实现中列能正常滚动到底部,但无法返回顶部。相关代码如下:

import React, { useState, useEffect } from "react";
import { map } from "lodash";

const column1InitialYPosition = -300;
const column2InitialYPosition = -150;
const column3InitialYPosition = 0;
const intervalTime = 500;

const imageUrl =
  "https://images.freeimages.com/variants/jzAZ1zYpxh11gSbgYPMheWun/f4a36f6589a0e50e702740b15352bc00e4bfaf6f58bd4db850e167794d05993d";
const column1Images = [imageUrl, imageUrl, imageUrl, imageUrl, imageUrl];
const column2Images = [imageUrl, imageUrl, imageUrl, imageUrl, imageUrl];
const column3Images = [imageUrl, imageUrl, imageUrl, imageUrl, imageUrl];

const isElementInViewport = el => {
  let r, html;
  if (!el || 1 !== el.nodeType) {
    return false;
  }
  html = document.documentElement;
  r = el.getBoundingClientRect();
  return !!r && r.bottom >= 0 && r.top <= html.clientHeight;
};

export default function App() {
  const [column1YPosition, setColumn1YPosition] = useState(
    column1InitialYPosition
  );
  const [column2YPosition, setColumn2YPosition] = useState(
    column2InitialYPosition
  );
  const [column3YPosition, setColumn3YPosition] = useState(
    column3InitialYPosition
  );

  let intervalScroll;

  useEffect(() => {
    setTimeout(() => startScrollInterval(), 3000);
    return () => removeScrollToBottomInterval();
  }, []);

  const calculateNewPosition = (
    currentPosition,
    maxPosition,
    minPosition,
    bottomReached,
    topReached
  ) => {
    let newPosition = bottomReached
      ? currentPosition + 100
      : currentPosition - 100;
    newPosition = -1 * newPosition > maxPosition ? -maxPosition : newPosition;
    newPosition = newPosition > minPosition ? minPosition - 1 : newPosition;
    return newPosition;
  };

  const startScrollInterval = () => {
    const screenHeight = window ? window?.innerHeight : 0;
    const column1Bottom = document.getElementById("column-1-bottom");
    const column1Top = document.getElementById("column-1-top");
    const column1 = document.getElementById("column-1");
    const column1MaxTop = column1 ? column1?.scrollHeight - screenHeight : 0;

    const column2Bottom = document.getElementById("column-2-bottom");
    const column2Top = document.getElementById("column-2-top");
    const column2 = document.getElementById("column-2");
    const column2MaxTop = column2 ? column2?.scrollHeight - screenHeight : 0;

    const column3Bottom = document.getElementById("column-3-bottom");
    const column3Top = document.getElementById("column-3-top");
    const column3 = document.getElementById("column-3");
    const column3MaxTop = column3 ? column3?.scrollHeight - screenHeight : 0;

    const intervalHandler = () => {
      const column1BottomReached = isElementInViewport(column1Bottom);
      const column1TopReached = isElementInViewport(column1Top);
      const column2BottomReached = isElementInViewport(column2Bottom);
      const column2TopReached = isElementInViewport(column2Top);
      const column3BottomReached = isElementInViewport(column3Bottom);
      const column3TopReached = isElementInViewport(column3Top);

      setColumn1YPosition(column1YPosition => {
        return calculateNewPosition(
          column1YPosition,
          column1MaxTop,
          column1InitialYPosition,
          column1BottomReached,
          column1TopReached
        );
      });

      setColumn2YPosition(column2YPosition => {
        return calculateNewPosition(
          column2YPosition,
          column2MaxTop,
          column2InitialYPosition,
          column2BottomReached,
          column2TopReached
        );
      });
      setColumn3YPosition(column3YPosition => {
        return calculateNewPosition(
          column3YPosition,
          column3MaxTop,
          column3InitialYPosition,
          column3BottomReached,
          column3TopReached
        );
      });
    };

    intervalScroll = setInterval(intervalHandler, intervalTime);
  };

  const removeScrollToBottomInterval = () => {
    clearInterval(intervalScroll);
    intervalScroll = null;
  };

  return (
    <div className="relative w-full h-screen overflow-hidden">
      <div className="grid grid-cols-3 px-24 gap-24">
        <div className="relative overflow-hidden h-screen no-scrollbar">
          <div
            id="column-1"
            className="absolute inset-0 grid grid-cols-1 gap-24"
            style={{
              transition: "all 6s linear",
              top: column1YPosition
            }}
          >
            {map(column1Images, (image, i) => {
              const isFirst = i === 0;
              const isLast = i === column1Images?.length - 1;
              return (
                <div key={`column-1-vehicle-${i}`} className="relative w-full">
                  <img src={image} alt="Vehicle" className="w-full h-80" />
                  {isFirst && (
                    <div
                      id="column-1-top"
                      className="absolute opacity-1 w-full h-20"
                      style={{ top: -1 * column1InitialYPosition }}
                    />
                  )}
                  {isLast && (
                    <div
                      id="column-1-bottom"
                      className="absolute bottom-0 opacity-1 w-full h-20"
                    />
                  )}
                </div>
              );
            })}
          </div>
        </div>

        <div className="relative overflow-hidden h-screen no-scrollbar">
          <div
            id="column-2"
            className="absolute inset-0 grid grid-cols-1 gap-24"
            style={{
              transition: "all 5s linear",
              top: column2YPosition
            }}
          >
            {map(column2Images, (image, i) => {
              const isFirst = i === 0;
              const isLast = i === column2Images?.length - 1;
              return (
                <div key={`column-2-vehicle-${i}`} className="relative w-full">
                  <img src={image} alt="Vehicle" className="w-full h-80" />
                  {isFirst && (
                    <div
                      id="column-2-top"
                      className="absolute opacity-1 w-full h-20"
                      style={{ top: -1 * column2InitialYPosition }}
                    />
                  )}
                  {isLast && (
                    <div
                      id="column-2-bottom"
                      className="absolute bottom-0 opacity-1 w-full h-20"
                    />
                  )}
                </div>
              );
            })}
          </div>
        </div>

        <div className="relative overflow-hidden h-screen no-scrollbar">
          <div
            id="column-3"
            className="absolute inset-0 grid grid-cols-1 gap-24"
            style={{
              transition: "all 6s linear",
              top: column3YPosition
            }}
          >
            {map(column3Images, (image, i) => {
              const isFirst = i === 0;
              const isLast = i === column3Images?.length - 1;
              return (
                <div key={`column-3-vehicle-${i}`} className="relative w-full">
                  <img src={image} alt="Vehicle" className="w-full h-80" />
                  {isFirst && (
                    <div
                      id="column-3-top"
                      className="absolute opacity-1 w-full h-20"
                      style={{ top: -1 * column3InitialYPosition }}
                    />
                  )}
                  {isLast && (
                    <div
                      id="column-3-bottom"
                      className="absolute bottom-0 opacity-1 w-full h-20"
                    />
                  )}
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
}

问题分析与修复

核心问题

  1. 滚动方向切换逻辑失效:仅依赖视口检测元素的可见性判断边界,但顶部检测元素设置了固定偏移,导致无法正确触发顶部到达的判断。
  2. 边界计算逻辑混乱:calculateNewPosition中的边界限制公式错误,到达底部后无法正确计算向上滚动的位置。
  3. 缺乏持久方向状态:没有为每列维护滚动方向状态,单次检测容易出现判断失误。

修复方案

1. 为每列添加滚动方向状态

新增状态变量记录每列当前滚动方向,确保方向切换稳定:

const [column1Direction, setColumn1Direction] = useState('down');
const [column2Direction, setColumn2Direction] = useState('down');
const [column3Direction, setColumn3Direction] = useState('down');

2. 修正顶部检测元素定位

移除顶部检测元素的固定偏移,让它固定在列的顶部:

{isFirst && (
  <div
    id="column-1-top"
    className="absolute top-0 opacity-1 w-full h-20"
  />
)}

3. 重写位置计算与边界判断逻辑

简化位置计算逻辑,结合方向状态和实际滚动边界判断,到达边界时自动切换方向:

const calculateNewPosition = (
  currentPosition,
  maxScrollDistance,
  initialPosition,
  direction,
  setDirection
) => {
  let newPosition;
  // 向下滚动:top值减小(更负)
  if (direction === 'down') {
    newPosition = currentPosition - 100;
    // 到达底部:top的绝对值 >= 最大滚动距离
    if (-newPosition >= maxScrollDistance) {
      newPosition = -maxScrollDistance;
      setDirection('up');
    }
  } else {
    // 向上滚动:top值增大
    newPosition = currentPosition + 100;
    // 到达顶部:回到初始位置
    if (newPosition >= initialPosition) {
      newPosition = initialPosition;
      setDirection('down');
    }
  }
  return newPosition;
};

4. 更新定时器状态逻辑

在定时器回调中使用方向状态计算新位置:

setColumn1YPosition(prev => 
  calculateNewPosition(
    prev,
    column1MaxTop,
    column1InitialYPosition,
    column1Direction,
    setColumn1Direction
  )
);

完整修改后的代码

import React, { useState, useEffect } from "react";
import { map } from "lodash";

const column1InitialYPosition = -300;
const column2InitialYPosition = -150;
const column3InitialYPosition = 0;
const intervalTime = 500;

const imageUrl =
  "https://images.freeimages.com/variants/jzAZ1zYpxh11gSbgYPMheWun/f4a36f6589a0e50e702740b15352bc00e4bfaf6f58bd4db850e167794d05993d";
const column1Images = [imageUrl, imageUrl, imageUrl, imageUrl, imageUrl];
const column2Images = [imageUrl, imageUrl, imageUrl, imageUrl, imageUrl];
const column3Images = [imageUrl, imageUrl, imageUrl, imageUrl, imageUrl];

export default function App() {
  const [column1YPosition, setColumn1YPosition] = useState(column1InitialYPosition);
  const [column2YPosition, setColumn2YPosition] = useState(column2InitialYPosition);
  const [column3YPosition, setColumn3YPosition] = useState(column3InitialYPosition);
  // 新增滚动方向状态
  const [column1Direction, setColumn1Direction] = useState('down');
  const [column2Direction, setColumn2Direction] = useState('down');
  const [column3Direction, setColumn3Direction] = useState('down');

  let intervalScroll;

  useEffect(() => {
    setTimeout(() => startScrollInterval(), 3000);
    return () => removeScrollToBottomInterval();
  }, []);

  const calculateNewPosition = (
    currentPosition,
    maxScrollDistance,
    initialPosition,
    direction,
    setDirection
  ) => {
    let newPosition;
    if (direction === 'down') {
      newPosition = currentPosition - 100;
      // 到达底部:top的绝对值 >= 最大滚动距离
      if (-newPosition >= maxScrollDistance) {
        newPosition = -maxScrollDistance;
        setDirection('up');
      }
    } else {
      newPosition = currentPosition + 100;
      // 到达顶部:回到初始位置
      if (newPosition >= initialPosition) {
        newPosition = initialPosition;
        setDirection('down');
      }
    }
    return newPosition;
  };

  const startScrollInterval = () => {
    const screenHeight = window?.innerHeight || 0;
    const column1 = document.getElementById("column-1");
    const column1MaxTop = column1 ? column1.scrollHeight - screenHeight : 0;

    const column2 = document.getElementById("column-2");
    const column2MaxTop = column2 ? column2.scrollHeight - screenHeight : 0;

    const column3 = document.getElementById("column-3");
    const column3MaxTop = column3 ? column3.scrollHeight - screenHeight : 0;

    const intervalHandler = () => {
      setColumn1YPosition(prev => 
        calculateNewPosition(
          prev,
          column1MaxTop,
          column1InitialYPosition,
          column1Direction,
          setColumn1Direction
        )
      );

      setColumn2YPosition(prev => 
        calculateNewPosition(
          prev,
          column2MaxTop,
          column2InitialYPosition,
          column2Direction,
          setColumn2Direction
        )
      );

      setColumn3YPosition(prev => 
        calculateNewPosition(
          prev,
          column3MaxTop,
          column3InitialYPosition,
          column3Direction,
          setColumn3Direction
        )
      );
    };

    intervalScroll = setInterval(intervalHandler, intervalTime);
  };

  const removeScrollToBottomInterval = () => {
    clearInterval(intervalScroll);
    intervalScroll = null;
  };

  return (
    <div className="relative w-full h-screen overflow-hidden">
      <div className="grid grid-cols-3 px-24 gap-24">
        <div className="relative overflow-hidden h-screen no-scrollbar">
          <div
            id="column-1"
            className="absolute inset-0 grid grid-cols-1 gap-24"
            style={{
              transition: "all 6s linear",
              top: column1YPosition
            }}
          >
            {map(column1Images, (image, i) => {
              const isFirst = i === 0;
              const isLast = i === column1Images.length - 1;
              return (
                <div key={`column-1-vehicle-${i}`} className="relative w-full">
                  <img src={image} alt="Vehicle" className="w-full h-80" />
                  {isFirst && (
                    <div
                      id="column-1-top"
                      className="absolute top-0 opacity-1 w-full h-20"
                    />
                  )}
                  {isLast && (
                    <div
                      id="column-1-bottom"
                      className="absolute bottom-0 opacity-1 w-full h-20"
                    />
                  )}
                </div>
              );
            })}
          </div>
        </div>

        <div className="relative overflow-hidden h-screen no-scrollbar">
          <div
            id="column-2"
            className="absolute inset-0 grid grid-cols-1 gap-24"
            style={{
              transition: "all 5s linear",
              top: column2YPosition
            }}
          >
            {map(column2Images, (image, i) => {
              const isFirst = i === 0
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.08 00:30:54