预览
Item 1Item 2Item 3Item 4Item 5Item 6Item 7Item 8Item 9
Item 1Item 2Item 3Item 4Item 5Item 6Item 7Item 8Item 9
Prev
Next
Pause
Resume
index.tsx
hooks/use-marquee.ts
'use client';
import { useMarquee } from './hooks/use-marquee';
const btnCls = 'bg-fore/3 cursor-pointer rounded-md px-2 py-1';
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const Index = () => {
const { trackRef, next, prev, pause, resume } = useMarquee({
autoPlay: true,
autoPlayStep: 0.3,
});
return (
<div className="w-full overflow-hidden">
<div ref={trackRef} className="mb-2 flex whitespace-nowrap">
<div className="flex">
{items.map((item) => (
<span key={item} className="px-4">
Item {item}
</span>
))}
</div>
<div className="flex">
{items.map((item) => (
<span key={item} className="px-4">
Item {item}
</span>
))}
</div>
</div>
<div className="flex items-center justify-center gap-2">
<div className={btnCls} onClick={() => prev(100)}>
Prev
</div>
<div className={btnCls} onClick={() => next(100)}>
Next
</div>
<div className={btnCls} onClick={pause}>
Pause
</div>
<div className={btnCls} onClick={resume}>
Resume
</div>
</div>
</div>
);
};
export default Index;
DOM 结构约定
这个 Hook 对 DOM 结构有一个简单约定:
trackRef └─ list ├─ item ├─ item └─ item
- trackRef:滚动轨道(通过
transform控制位移) - list:实际内容容器
- item:列表项
通常为了实现 无缝循环滚动,会在 DOM 中复制多份 list。
为什么使用 useRef 而不是 useState
Hook 内部使用了 useRef 来保存动画状态:
const position = useRef(0)
const target = useRef(0)
const listWidth = useRef(0)原因是动画需要 每一帧更新位置,如果使用 useState,每一帧都会触发 React 重新渲染,性能开销会非常大。
useRef 的值变化不会触发组件更新,因此非常适合保存动画状态。
动画核心:Lerp 平滑滚动
动画通过 requestAnimationFrame 持续执行:
const diff = target.current - position.current
position.current += diff * 0.08这是一个经典的 Lerp(线性插值) 动画公式:position += (target - position) * easing
这样当 target 改变时,position 会逐渐逼近目标位置,从而形成平滑滚动效果。
当差值非常小时,会直接对齐目标值:
if (Math.abs(diff) < 0.1) {
position.current = target.current
}避免出现无限接近但不结束的问题。
无限循环的实现
无限滚动的关键在于 位置回绕。
当滚动位置超过一整个列表宽度时:
if (position.current >= listWidth.current) {
position.current -= listWidth.current
target.current -= listWidth.current
}同理也需要处理反向滚动:
if (position.current < 0) {
position.current += listWidth.current
target.current += listWidth.current
}通过这种方式,可以让滚动在视觉上形成 无缝循环。
自动播放机制
自动播放只需要在每一帧增加 target:
if (autoPlay && !paused.current) {
target.current += autoPlayStep
}autoPlayStep 控制滚动速度。
由于动画使用 requestAnimationFrame 驱动,因此滚动会保持稳定的帧率。
Resize 自适应
列表宽度可能因为以下原因改变:
- 浏览器窗口 resize
- 图片加载
- 字体加载
因此 Hook 使用 ResizeObserver 监听尺寸变化:
const resizeObserver = new ResizeObserver(measure)
resizeObserver.observe(track)当尺寸变化时重新计算:
listWidth.current = list.offsetWidth这样可以保证循环逻辑始终正确。
性能特点
这个实现的性能开销非常小,原因是整个动画只修改了元素的 transform:
track.style.transform = `translate3d(-${position.current}px,0,0)`translate3d 会触发 GPU 合成层,不会导致 layout 或 reflow,因此滚动非常流畅。