彻底玩转Scrollbar滚动条样式定制与性能优化

楠楠 组件 阅读 2,911
赞 11 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这项目是个后台数据看板,左侧是菜单,右侧是主内容区。客户提了个需求:希望滚动条能自定义样式,原生的那种又粗又丑的没法看。我一开始想,不就是改个滚动条吗,CSS 伪元素搞一下 ::-webkit-scrollbar 就完事了,10 分钟搞定。

彻底玩转Scrollbar滚动条样式定制与性能优化

结果上线前测试,产品经理说:“安卓机上滑不动啊”“iOS 上滚动卡顿”“PC 端 Chrome 没问题,但 Edge 样式没生效”。我当场就懵了——原来 ::-webkit-scrollbar 只在 WebKit 内核里有效,Chromium 系还行,但 Safari、Firefox、Edge(旧版)全都不支持,更别说移动端 WebView 的各种坑了。

后来一查兼容性表,果然,MDN 上写着“非标准,仅 WebKit/Blink 支持”。得,这条路走不通。于是开始调研第三方库,最后锁定了两个方案:

  • 纯 CSS hack:用 padding 模拟、隐藏原生滚动条,靠 JS 控制 scrollTop
  • 引入现成库,比如 SimpleBar、OverlayScrollbars 或者自研一套

权衡之后,我决定自己实现一个轻量级的。因为项目已经快交付了,不想再引入新依赖,而且功能其实很简单:只需要美化滚动条 + 支持 touch 滚动就行,不需要复杂的功能如滚动追踪、惯性动画等。

核心代码就这几行

思路很简单:保留原生 <div> 的 overflow: auto,监听 scroll 事件拿到 scrollTop,然后动态渲染一个绝对定位的滚动条 thumb(滑块),再通过 touchstart/touchmove 来反向控制 scrollTop。

HTML 结构如下:

<div class="scroll-container" style="position: relative; height: 400px; overflow: auto;">
  <div class="scroll-content" style="height: 2000px; background: linear-gradient(white, #eee);">
    <!-- 实际内容 -->
  </div>
  <div class="scrollbar-thumb" style="position: absolute; top: 0; right: 2px; width: 6px; border-radius: 3px; background: rgba(0,0,0,0.3); opacity: 0; transition: opacity 0.2s;"></div>
</div>

CSS 部分主要是隐藏原生滚动条和控制 thumb 显示逻辑:

/* 隐藏 WebKit 原生滚动条 */
.scroll-container::-webkit-scrollbar {
  display: none;
}

/* Firefox 隐藏滚动条(非标准) */
.scroll-container {
  scrollbar-width: none; /* firefox */
}

JS 是重点,我写了个简单的类来管理:

class CustomScrollbar {
  constructor(container) {
    this.container = container;
    this.thumb = container.querySelector('.scrollbar-thumb');
    this.hideTimer = null;

    this.init();
  }

  init() {
    this.updateThumb();
    this.bindEvents();
  }

  updateThumb() {
    const { scrollTop, scrollHeight, clientHeight } = this.container;
    const height = (clientHeight / scrollHeight) * clientHeight;
    const top = (scrollTop / scrollHeight) * clientHeight;

    this.thumb.style.height = ${Math.max(height, 20)}px; // 最小高度
    this.thumb.style.top = ${top}px;
  }

  showThumb() {
    this.thumb.style.opacity = '1';
    if (this.hideTimer) clearTimeout(this.hideTimer);
    this.hideTimer = setTimeout(() => {
      this.thumb.style.opacity = '0';
    }, 1000);
  }

  bindEvents() {
    // 监听滚动更新 thumb
    this.container.addEventListener('scroll', () => {
      this.updateThumb();
      this.showThumb();
    });

    // touch 控制滚动
    let startY, startScrollTop;
    this.thumb.addEventListener('touchstart', (e) => {
      e.preventDefault();
      startY = e.touches[0].clientY;
      startScrollTop = this.container.scrollTop;
    });

    document.addEventListener('touchmove', (e) => {
      if (!startY) return;
      const deltaY = e.touches[0].clientY - startY;
      const ratio = this.container.scrollHeight / this.container.clientHeight;
      this.container.scrollTop = startScrollTop + deltaY * ratio;
    });

    document.addEventListener('touchend', () => {
      startY = null;
      startScrollTop = null;
    });
  }
}

// 初始化
document.querySelectorAll('.scroll-container').forEach(container => {
  new CustomScrollbar(container);
});

就这么点代码,基本满足了需求:PC 上鼠标滚轮正常,滚动时显示半透明滑块;移动端可以通过拖动 thumb 来快速滑动,也能响应 touch 滚动。

最大的坑:touchmove 滚动失效

你以为这就完了?错。上线前 QA 在华为某机型上测试,发现 touch 滚动完全没反应。我当时第一反应是“是不是事件没绑定”,查了一圈发现事件都注册了,但 touchmove 就是触发不了。

折腾了半天才发现,是因为页面根节点有另一个全局的 touchstart 事件阻止了默认行为,导致事件穿透失败。这个全局事件是用来做点击遮罩层关闭弹窗的,里面写了 e.preventDefault(),结果把整个页面的 touch 滚动都干掉了。

踩坑提醒:这三点一定注意:

  • 不要轻易在 document 上对 touch 事件调用 preventDefault(),会阻断原生滚动
  • 多个 touch 交互模块要隔离作用域,最好加上判断条件再阻止默认行为
  • 移动端调试一定要真机测,模拟器和真机行为差异很大

解决办法是在那个遮罩层的事件里加了个判断:

if (!isClickOnMask(e.target)) return;
e.preventDefault(); // 只在点击 mask 时才阻止

这才恢复了滚动能力。

又踩坑了:thumb 拖拽太难控制

本来以为 thumb 拖拽挺顺的,结果用户反馈“滑块一碰就乱跳”“根本没法精准控制”。我亲自试了下,确实,手指稍微一动,页面直接滚到底。

问题出在这行代码:

this.container.scrollTop = startScrollTop + deltaY * ratio;

这里的 ratio 是放大系数,原本是为了让 thumb 移动距离和页面滚动匹配,结果在 touch 场景下变成了“手滑 5px,页面滚 500px”。

后来改成按比例映射 thumb 的 top 到 scrollTop:

// 在 touchmove 中
const thumbRect = this.thumb.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
const maxTop = containerRect.height - this.thumb.offsetHeight;
const ratioY = Math.max(0, Math.min(1, (thumbRect.top - containerRect.top) / maxTop));
this.container.scrollTop = ratioY * (this.container.scrollHeight - this.container.clientHeight);

但这又带来新问题:getBoundingClientRect() 在频繁 touchmove 中性能开销大,低端机直接卡顿。

最终妥协方案是:只允许点击 thumb 区域拖拽,不支持直接滑动手势控制 thumb。这样逻辑简化,性能也稳了。虽然体验没那么丝滑,但至少可用。

回顾与反思

现在回头看,这个方案不是最优的,但够用。优点很明显:

  • 不依赖第三方库,体积几乎为零
  • 兼容性好,Webkit 和非 Webkit 都能跑
  • 维护成本低,就几百行代码

缺点也有:

  • 没有惯性滚动,松手后不会继续滑
  • thumb 拖拽精度一般,尤其在小屏幕上
  • 某些 Android 浏览器 WebView 对 scrollbar-width: none 不支持,原生滚动条还会闪一下

那个闪一下的问题到现在都没彻底解决。试过用 clip-path 裁剪、负 margin 位移、甚至 iframe 隔离,效果都不稳定。最后只能接受:在部分设备上,滚动条会先出现再消失,大概 100ms 左右。用户感知不强,就没再深究了。

如果下次再做类似需求,我会直接上 OverlayScrollbars。虽然重一点,但成熟稳定,各种边界情况都处理好了。这次为了“轻量”自己造轮子,反而花了三倍时间 debug。

以上是我个人对这个滚动条实现的完整讲解

这个技巧的拓展用法还有很多,比如结合 ResizeObserver 自动更新 thumb 大小,或者加个 hover 显示策略。后续会继续分享这类实战经验。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论