0
点赞
收藏
分享

微信扫一扫

JS 实现丝滑文字滚动:从基础到进阶,覆盖垂直/水平场景

公告栏轮播、ticker、弹幕滚动……文字滚动是前端超高频动效需求。但很多新手实现的滚动要么“卡顿掉帧”,要么“切换闪屏”,甚至滚动到最后一项时直接“跳回开头”,体验极差。

其实想要实现“丝滑无缝”的文字滚动,核心就3个关键点:克隆元素做衔接逐帧动画控节奏边界处理防闪屏。本文会从基础布局讲到进阶功能,不仅覆盖垂直/水平两种滚动方向,还会加入“鼠标悬停暂停”“动态更新内容”等实用功能,用纯原生 JS 写出零依赖、可复用的滚动组件。

一、先搞懂:为什么普通滚动会“不丝滑”?

很多人一开始会用 setInterval 直接修改 scrollTop/scrollLeft,比如每 300ms 让元素滚 30px,这种方式有两个致命问题:

  1. 卡顿掉帧setInterval 不跟浏览器重绘同步,容易出现“跳步”;
  2. 切换闪屏:滚动到最后一项时,直接把 scrollTop 设为 0,视觉上会有明显跳跃;
  3. 边界错乱:如果文本高度/宽度不一致,滚动距离计算错误,会导致内容显示不全。

而我们的方案会针对性解决这些问题,先从最基础的“垂直滚动公告栏”开始拆解。

二、基础实现:垂直滚动公告栏(核心原理)

垂直滚动是最常见的场景(比如官网公告、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:鼠标悬停暂停,提升用户体验

用户想仔细看某条公告时,鼠标放上去应该暂停滚动,离开后继续。只需监听 mouseentermouseleave 事件:

// 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", ...]
  });

五、性能优化:避免内存泄漏与卡顿

  1. 销毁滚动动画:如果滚动容器在单页应用(SPA)的某个页面,页面卸载时要停止动画,避免内存泄漏:

    // 页面卸载时调用
    function destroyScroll() {
      isScrolling = false;
      startTime = 0;
      // 清除所有定时器(如果有)
      clearTimeout(window.scrollTimeout);
    }
    
  2. 避免频繁更新:如果动态更新内容频繁(比如每秒更新),要加防抖,避免短时间内多次重置滚动:

    // 防抖函数:500ms内只执行一次更新
    const debouncedUpdateTicker = debounce(updateHorizontalTicker, 500);
    // 变化时调用防抖后的函数
    stockWebSocket.onmessage = (msg) => {
      debouncedUpdateTicker(msg.data.prices);
    };
    
  3. 固定文本尺寸:无论垂直还是水平滚动,每条内容的高度/宽度必须一致,否则滚动距离计算错误,会出现“滚多了”或“滚少了”的问题。

六、总结:适用场景与核心要点

滚动方向 适用场景 核心参数
垂直 公告栏、通知列表、评论 单条高度、动画时长
水平 弹幕、新闻Ticker 单条宽度、动画时长

核心要点回顾

  1. 无缝衔接:克隆首项追加到末尾,滚动到克隆项时瞬间重置;
  2. 丝滑动画:用 requestAnimationFrame 替代 setInterval,加 easing 函数;
  3. 体验优化:鼠标悬停暂停,动态更新时重置状态;
  4. 性能保障:销毁动画避免内存泄漏,固定文本尺寸避免错乱。
举报

相关推荐

0 条评论