公告栏轮播、ticker、弹幕滚动……文字滚动是前端超高频动效需求。但很多新手实现的滚动要么“卡顿掉帧”,要么“切换闪屏”,甚至滚动到最后一项时直接“跳回开头”,体验极差。
其实想要实现“丝滑无缝”的文字滚动,核心就3个关键点:克隆元素做衔接、逐帧动画控节奏、边界处理防闪屏。本文会从基础布局讲到进阶功能,不仅覆盖垂直/水平两种滚动方向,还会加入“鼠标悬停暂停”“动态更新内容”等实用功能,用纯原生 JS 写出零依赖、可复用的滚动组件。
一、先搞懂:为什么普通滚动会“不丝滑”?
很多人一开始会用 setInterval
直接修改 scrollTop
/scrollLeft
,比如每 300ms 让元素滚 30px,这种方式有两个致命问题:
- 卡顿掉帧:
setInterval
不跟浏览器重绘同步,容易出现“跳步”; - 切换闪屏:滚动到最后一项时,直接把
scrollTop
设为 0,视觉上会有明显跳跃; - 边界错乱:如果文本高度/宽度不一致,滚动距离计算错误,会导致内容显示不全。
而我们的方案会针对性解决这些问题,先从最基础的“垂直滚动公告栏”开始拆解。
二、基础实现:垂直滚动公告栏(核心原理)
垂直滚动是最常见的场景(比如官网公告、APP 通知),我们按“布局 → 克隆元素 → 逐帧滚动 → 边界处理”四步实现。
1. 布局:固定容器 + 溢出隐藏
核心样式要满足两点:容器固定高度(只显示1条内容)、溢出隐藏(隐藏未显示的内容),避免滚动时出现滚动条。
<!-- HTML:公告栏容器 + 列表 -->
<div class="notice-container">
<span class="notice-title">最新公告</span>
<!-- 滚动列表:overflow: hidden 隐藏溢出内容 -->
<ul class="notice-list" id="verticalNoticeList">
<li class="notice-item">2025年国庆放假安排:10月1日-7日放假,8日正常上班</li>
<li class="notice-item">系统将于9月30日23:00-次日2:00进行维护,期间暂停服务</li>
<li class="notice-item">新用户注册可领取20元无门槛优惠券,有效期7天</li>
</ul>
</div>
<style>
/* CSS:关键样式标注 */
.notice-container {
display: flex;
align-items: center;
gap: 12px;
width: 500px;
height: 40px; /* 固定高度:只显示1条公告 */
padding: 0 16px;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
}
.notice-title {
color: #ff4d4f;
font-weight: 500;
white-space: nowrap; /* 标题不换行 */
}
.notice-list {
margin: 0;
padding: 0;
list-style: none;
height: 100%; /* 跟容器同高 */
overflow: hidden; /* 核心:隐藏溢出内容 */
flex: 1; /* 占满剩余宽度 */
}
.notice-item {
height: 40px; /* 每条公告高度 = 容器高度:确保一次只显示1条 */
line-height: 40px; /* 垂直居中 */
white-space: nowrap; /* 文字不换行,避免高度错乱 */
overflow: hidden; /* 文字过长时隐藏 */
text-overflow: ellipsis; /* 文字过长显示省略号 */
}
</style>
2. 核心逻辑:3步实现无缝滚动
步骤1:克隆首项,解决“最后一项到第一项”的衔接问题
如果直接滚动到最后一项,再跳回第一项,会有明显闪屏。解决方案是把列表的第一个真实项克隆一份,追加到列表末尾——这样滚动到“克隆项”时,视觉上和“首项”完全一致,此时瞬间重置滚动位置,用户完全察觉不到。
// 1. 获取DOM元素
const noticeList = document.getElementById('verticalNoticeList');
const noticeItems = noticeList.querySelectorAll('.notice-item');
const itemHeight = 40; // 每条公告高度(跟CSS一致)
const totalRealItems = noticeItems.length; // 真实项数量(不含克隆项)
// 2. 克隆首项,追加到列表末尾
const firstItemClone = noticeItems[0].cloneNode(true); // true:深度克隆(包含子元素)
noticeList.appendChild(firstItemClone);
步骤2:逐帧滚动,用 requestAnimationFrame 替代 setInterval
requestAnimationFrame
会跟浏览器重绘节奏同步(约60fps,每帧间隔16.67ms),比 setInterval
更流畅。我们还加入简单的“缓进缓出”动画(easing),让滚动不是生硬的线性移动。
// 滚动状态变量
let isScrolling = true; // 是否正在滚动
let currentIndex = 0; // 当前滚动到的索引(从0开始)
const duration = 500; // 每滚动一条的动画时长(ms)
let startTime = 0; // 动画开始时间(用于计算进度)
// 3. 逐帧滚动函数(requestAnimationFrame 回调)
function scrollStep(timestamp) {
if (!isScrolling) return; // 暂停时不执行
// 初始化开始时间
if (!startTime) startTime = timestamp;
// 计算动画进度(0 ~ 1):(当前时间 - 开始时间) / 总时长
const progress = Math.min((timestamp - startTime) / duration, 1);
// 缓进缓出 easing:让滚动开头慢、中间快、结尾慢(更丝滑)
const easingProgress = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
// 计算当前滚动距离:进度 * 单条高度
const scrollTop = easingProgress * (currentIndex + 1) * itemHeight;
noticeList.scrollTop = scrollTop;
// 动画未结束:继续下一帧
if (progress < 1) {
requestAnimationFrame(scrollStep);
} else {
// 动画结束:准备下一条
startTime = 0; // 重置开始时间
currentIndex++;
// 关键:滚动到克隆项时,瞬间重置
if (currentIndex === totalRealItems) {
currentIndex = 0;
noticeList.scrollTop = 0; // 瞬间回到顶部(克隆项和首项一样,视觉无缝)
}
// 延迟1.5秒后,开始下一轮滚动(避免滚得太快)
setTimeout(() => {
requestAnimationFrame(scrollStep);
}, 1500);
}
}
// 启动滚动
requestAnimationFrame(scrollStep);
步骤3:鼠标悬停暂停,提升用户体验
用户想仔细看某条公告时,鼠标放上去应该暂停滚动,离开后继续。只需监听 mouseenter
和 mouseleave
事件:
// 4. 鼠标悬停暂停/离开继续
noticeList.addEventListener('mouseenter', () => {
isScrolling = false;
});
noticeList.addEventListener('mouseleave', () => {
isScrolling = true;
// 重新启动滚动(从当前进度继续)
requestAnimationFrame(scrollStep);
});
三、扩展:水平滚动
除了垂直公告栏,水平滚动也很常见(比如新闻标题)。核心原理和垂直一致,只是把“高度”换成“宽度”,scrollTop
换成 scrollLeft
。
1. 水平滚动完整代码
<!-- HTML:水平滚动容器 -->
<div class="ticker-container" id="horizontalTicker">
<ul class="ticker-list">
<li class="ticker-item">【新闻】2025年全球前端开发者大会将于11月在上海举办</li>
<li class="ticker-item">【天气】明日北京晴,气温18-28℃,适宜户外活动</li>
</ul>
</div>
<style>
/* CSS:水平滚动关键样式 */
.ticker-container {
width: 100%;
height: 40px;
line-height: 40px;
background: #f5f5f5;
overflow: hidden; /* 隐藏溢出内容 */
}
.ticker-list {
margin: 0;
padding: 0;
list-style: none;
display: flex; /* 水平排列 */
white-space: nowrap; /* 禁止换行 */
}
.ticker-item {
padding: 0 20px; /* 项之间的间距 */
white-space: nowrap; /* 文字不换行 */
}
</style>
<script>
// JS:水平滚动逻辑
const tickerList = document.querySelector('.ticker-list');
const tickerItems = tickerList.querySelectorAll('.ticker-item');
const totalRealItems = tickerItems.length;
let isScrolling = true;
let currentIndex = 0;
let startTime = 0;
const duration = 800; // 每滚动一项的时长(水平可稍长)
// 1. 计算单条item的总宽度(内容宽度 + padding)
function getItemTotalWidth(item) {
return item.offsetWidth; // offsetWidth = 内容宽度 + padding + border
}
// 2. 克隆首项,追加到末尾
const firstItemClone = tickerItems[0].cloneNode(true);
tickerList.appendChild(firstItemClone);
// 3. 逐帧水平滚动
function horizontalScrollStep(timestamp) {
if (!isScrolling) return;
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
const easingProgress = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
// 计算当前滚动距离:累加前currentIndex+1项的宽度
let scrollLeft = 0;
for (let i = 0; i <= currentIndex; i++) {
scrollLeft += getItemTotalWidth(tickerItems[i]);
}
scrollLeft *= easingProgress;
tickerList.scrollLeft = scrollLeft;
if (progress < 1) {
requestAnimationFrame(horizontalScrollStep);
} else {
startTime = 0;
currentIndex++;
// 滚动到克隆项时,重置
if (currentIndex === totalRealItems) {
currentIndex = 0;
tickerList.scrollLeft = 0;
}
// 延迟1秒后继续下一项
setTimeout(() => {
requestAnimationFrame(horizontalScrollStep);
}, 1000);
}
}
// 启动水平滚动
requestAnimationFrame(horizontalScrollStep);
// 4. 鼠标悬停暂停
tickerList.addEventListener('mouseenter', () => {
isScrolling = false;
});
tickerList.addEventListener('mouseleave', () => {
isScrolling = true;
requestAnimationFrame(horizontalScrollStep);
});
</script>
四、进阶功能:动态更新滚动内容
实际项目中,滚动内容可能需要从接口获取(比如实时公告),或者用户操作后更新。此时需要先清除旧的克隆项,更新真实内容,再重新克隆首项,避免内容错乱。
/**
* 动态更新垂直公告内容
* @param {Array} newContent - 新公告数组(如 ["公告1", "公告2"])
*/
function updateVerticalNotice(newContent) {
// 1. 暂停滚动
isScrolling = false;
// 2. 清空列表(保留容器)
noticeList.innerHTML = '';
// 3. 添加新的真实项
newContent.forEach(content => {
const li = document.createElement('li');
li.className = 'notice-item';
li.textContent = content;
noticeList.appendChild(li);
});
// 4. 重新克隆首项
const newItems = noticeList.querySelectorAll('.notice-item');
const newFirstClone = newItems[0].cloneNode(true);
noticeList.appendChild(newFirstClone);
// 5. 重置状态,重启滚动
totalRealItems = newItems.length;
currentIndex = 0;
startTime = 0;
noticeList.scrollTop = 0;
isScrolling = true;
requestAnimationFrame(scrollStep);
}
// 调用示例:从接口获取新公告后更新
fetch('/api/latest-notices')
.then(res => res.json())
.then(data => {
updateVerticalNotice(data.notices); // data.notices = ["新公告1", "新公告2", ...]
});
五、性能优化:避免内存泄漏与卡顿
-
销毁滚动动画:如果滚动容器在单页应用(SPA)的某个页面,页面卸载时要停止动画,避免内存泄漏:
// 页面卸载时调用 function destroyScroll() { isScrolling = false; startTime = 0; // 清除所有定时器(如果有) clearTimeout(window.scrollTimeout); }
-
避免频繁更新:如果动态更新内容频繁(比如每秒更新),要加防抖,避免短时间内多次重置滚动:
// 防抖函数:500ms内只执行一次更新 const debouncedUpdateTicker = debounce(updateHorizontalTicker, 500); // 变化时调用防抖后的函数 stockWebSocket.onmessage = (msg) => { debouncedUpdateTicker(msg.data.prices); };
-
固定文本尺寸:无论垂直还是水平滚动,每条内容的高度/宽度必须一致,否则滚动距离计算错误,会出现“滚多了”或“滚少了”的问题。
六、总结:适用场景与核心要点
滚动方向 | 适用场景 | 核心参数 |
---|---|---|
垂直 | 公告栏、通知列表、评论 | 单条高度、动画时长 |
水平 | 弹幕、新闻Ticker | 单条宽度、动画时长 |
核心要点回顾:
- 无缝衔接:克隆首项追加到末尾,滚动到克隆项时瞬间重置;
- 丝滑动画:用
requestAnimationFrame
替代setInterval
,加 easing 函数; - 体验优化:鼠标悬停暂停,动态更新时重置状态;
- 性能保障:销毁动画避免内存泄漏,固定文本尺寸避免错乱。