在 React 函数组件中,使用 useEffect 编写循环轮播功能是非常普遍的需求,例如首页 banner 自动切换。看似几行 setInterval 就能搞定,但实际项目中往往出现暂停失效、内存泄漏、切换错乱等隐蔽问题。理解这些现象背后的运行机制,才能写出健壮的轮播组件。

一、最常见的陷阱:闭包与依赖数组错误
很多轮播组件在 useEffect 中启动定时器,并在回调里调用 setIndex 更新当前页。但当 effect 的依赖数组写得不完整,或者干脆写成空数组时,定时器回调会始终引用首次渲染时的变量,导致状态更新异常。这种闭包陷阱在轮播里表现为:翻页永远停留在第二张,或者手动切换后自动播放又跳回旧位置。
下面是一段有问题的代码,它在挂载时开启定时器,但依赖数组为空,内部使用的 index 被永久锁定为初始值 0:
import React, { useState, useEffect } from 'react';
function BadCarousel() {
const [index, setIndex] = useState(0);
const list = ['A', 'B', 'C'];
useEffect(() => {
const timer = setInterval(() => {
// 这里的 index 永远是首次渲染的 0
setIndex((index + 1) % list.length);
}, 2000);
return () => clearInterval(timer);
}, []); // 错误:依赖为空,但内部用了 index
return <div>{list[index]}</div>;
}
上述写法在语法上不会报错,但逻辑上 setIndex 接收的是基于旧 index 的计算,而旧 index 始终为 0,因此界面每两秒都试图设置成 1,手动改了也没用。正确方式应当使用函数式更新,不依赖外部 index 变量,从而避开闭包问题。
改进后的定时器回调写成 setIndex(prev => (prev + 1) % list.length),此时 effect 的依赖数组可以为空,因为更新逻辑完全由 React 自身状态函数托管,不再读取渲染作用域里的 index。
二、清理机制缺失导致的内存泄漏
另一个高频陷阱是组件卸载或暂停播放时,没有清除已有定时器。比如父组件通过条件渲染隐藏轮播,再次显示会重复创建多个 interval,页面切到后台再回来后出现加速轮播。useEffect 的返回函数就是专门做清理的,但不少人忘了写或者写错位置。
下面示例展示如何在暂停状态变化时正确开关定时器,并在卸载时清理:
import React, { useState, useEffect, useRef } from 'react';
function SafeCarousel({ paused }) {
const [index, setIndex] = useState(0);
const list = ['A', 'B', 'C'];
const timerRef = useRef(null);
useEffect(() => {
if (paused) {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return;
}
timerRef.current = setInterval(() => {
setIndex(prev => (prev + 1) % list.length);
}, 2000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [paused]);
return <div>{list[index]}</div>;
}
使用 useRef 保存定时器 ID,可以保证无论 effect 执行几次,清理时都能拿到正确的句柄。依赖数组只放 paused,当外部传入暂停信号时,effect 先清理旧定时器再决定是否新建,避免叠加。
这种结构也方便后续扩展,例如鼠标悬停暂停、页面可见性变化暂停,只需改变 paused 属性即可,不需要在每个交互处手动 clearInterval。
三、用 requestAnimationFrame 优化性能
setInterval 虽然简单,但它在后台标签页里也会被浏览器节流,且精度不高,容易造成轮播与时间不同步。对于追求流畅感的场景,可以用 requestAnimationFrame 自行计算时间差来驱动切换,既能在页面隐藏时自动停止,又能减少不必要的重渲染。
以下示例用 ref 记录上一次切换时间,在每一帧判断是否已经过了间隔:
import React, { useState, useEffect, useRef } from 'react';
function RafCarousel() {
const [index, setIndex] = useState(0);
const list = ['A', 'B', 'C'];
const rafRef = useRef(0);
const lastRef = useRef(0);
useEffect(() => {
const step = (ts) => {
if (!lastRef.current) lastRef.current = ts;
if (ts - lastRef.current >= 2000) {
setIndex(prev => (prev + 1) % list.length);
lastRef.current = ts;
}
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafRef.current);
}, []);
return <div>{list[index]}</div>;
}
该方案在组件卸载时通过 cancelAnimationFrame 停止循环,不会留下任何全局计时器。由于 requestAnimationFrame 在标签页不可见时浏览器会自动暂停,因此天然解决了后台加速问题。
当然,如果业务只需要基础轮播,setInterval 配合完善清理也足够。选择哪种方式取决于对流畅度与兼容性的要求,但核心原则不变:effect 里产生的副作用必须可追踪、可清理,状态更新尽量使用函数式写法。
四、手动操作与自动播放的冲突处理
真实轮播组件通常允许用户点击圆点手动切换,如果此时自动播放还在按旧节奏走,会出现刚点完又被弹回的错觉。解决思路是在用户交互时重置计时起点,或者临时暂停几秒再恢复。
可以用一个 ref 存储用户最后操作时间,在自动切换逻辑里判断距离上次手动操作是否超过阈值:
import React, { useState, useEffect, useRef } from 'react';
function InteractiveCarousel() {
const [index, setIndex] = useState(0);
const list = ['A', 'B', 'C'];
const lastActionRef = useRef(Date.now());
useEffect(() => {
const timer = setInterval(() => {
if (Date.now() - lastActionRef.current > 3000) {
setIndex(prev => (prev + 1) % list.length);
}
}, 1000);
return () => clearInterval(timer);
}, []);
const goTo = (i) => {
setIndex(i);
lastActionRef.current = Date.now();
};
return (
<div>
<div>{list[index]}</div>
{list.map((item, i) => (
<button key={i} onClick={() => goTo(i)}>{i + 1}</button>
))}
</div>
);
}
这样手动点击后三秒内自动播放不会插手,体验更自然。注意 lastActionRef 用 ref 而非 state,避免每次点击触发额外渲染。
综合来看,useEffect 写轮播并不难,难在理清副作用生命周期与状态闭包。把握清理、函数式更新、ref 缓存三个要点,就能避开绝大多数线上故障。