代码实战 · 2026 年 11 月 11 日

20 行 JavaScript 实现丝滑的滚动进度条

滚动监听本身不慢,慢的是每帧读写布局。用 requestAnimationFrame 合并事件、transform 代替 width,再处理 iOS 回弹和短页面,20 行就能做到不掉帧。

长文页面顶部加一条阅读进度条,实现只要十几行。但很多人写出来的版本一滚动就掉帧,尤其在手机上。

问题几乎不在监听器数量,而在「每帧都触发一次布局计算,再改一个会触发重排的属性」。把这两件事换掉,代码反而更短。

先说明适用范围:下面这套适合「整页滚动」的长文页。如果你的正文在某个 overflow: auto 的区域里,或者用的是虚拟滚动,逻辑要换——文末会提。

先算清楚进度#

公式只有一行:

text
ratio = scrollTop / (scrollHeight - clientHeight)

三个数都从滚动容器上取。文档滚动时容器是 document.scrollingElement,它比 document.documentElement 可靠——后者在 quirks 模式下是 <body>。

这个除法有四个边界,线上都会遇到:

  • 内容比视口还短:分母为 0,得到 NaN 或 Infinity,scaleX(NaN) 会让进度条直接消失。
  • iOS 橡皮筋回弹:scrollTop 会变成负数,进度条反向溢出。
  • 移动端地址栏伸缩:clientHeight 变了,但没有 scroll 事件,进度停在旧值。
  • 锚点跳转和浏览器前进后退:pageshow 恢复页面时不会触发 scroll。

对应的处理是 Math.min(1, Math.max(0, ...)) 夹紧,分母为 0 时返回 0,再监听 resize、pageshow 和 ResizeObserver。

还有一个容易被忽略的量:scrollHeight 包含内边距但不含边框和外边距,而 clientHeight 包含内边距、不含滚动条。所以分母是「可滚动的高度」,不是「内容总高度」。如果你在容器上加了 border: 1px,算出来的最大值会多出两像素,进度永远差一点点到 100%。这类误差肉眼看不出来,但会让「读完自动标记」之类的逻辑永远不触发。

20 行实现#

javascript
// scroll-progress.js —— 无依赖,现代浏览器
const bar = document.getElementById('progress-bar');
let queued = false;
let lastRatio = -1;

function update() {
  queued = false;
  const el = document.scrollingElement || document.documentElement;
  const max = el.scrollHeight - el.clientHeight;
  const ratio = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
  if (ratio === lastRatio) return;          // 没变化就不碰 DOM
  lastRatio = ratio;
  bar.style.transform = `scaleX(${ratio})`;
  bar.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}

function schedule() {
  if (queued) return;                       // 一帧只更新一次
  queued = true;
  requestAnimationFrame(update);
}

addEventListener('scroll', schedule, { passive: true });
addEventListener('resize', schedule, { passive: true });
addEventListener('pageshow', schedule);
new ResizeObserver(schedule).observe(document.body);

schedule();

三个细节撑起了「丝滑」:

  1. requestAnimationFrame 合并。滚动事件在一帧内可以触发多次(触控板、高刷屏更明显)。用 queued 标记把这一帧的所有事件折叠成一次 DOM 写入,渲染前只算一次。
  2. passive: true。告诉浏览器这个监听器不会调用 preventDefault(),滚动线程不必等 JS 执行完再合成画面。少这一行,移动端的滚动手感会明显变「粘」。
  3. lastRatio 去重。滚到顶部或底部后,后续事件算出的比例不变,直接返回,省掉无意义的样式写入。

queued 这个布尔量看起来多余,其实关键:requestAnimationFrame 本身不合并,连续调用会在一帧后执行多次。用「已排队」标记保证一帧只有一次回调,剩下的滚动事件全部丢弃——反正这一帧的最终位置就是最后一个事件的位置。

最后一行 schedule() 不是可选项。页面从缓存或前进后退恢复时,可能已经处于滚动状态,但不会补发 scroll 事件,不主动算一次就会停在错误的比例上。

样式:用 transform,不要用 width#

css
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: #2563eb;
  transform: scaleX(0);
  transform-origin: 0 50%;
  will-change: transform;
  pointer-events: none;
  z-index: 9999;
}

进度条本身是纯装饰,pointer-events: none 必须加,否则它会挡住顶部 3px 内的点击——移动端尤其容易踩到,比如顶部的返回按钮。

transform: scaleX() 只影响合成层,不触发 layout 和 paint。改 width 则每帧都要重新计算布局,页面越长、节点越多,成本越高。两者的差距在低端安卓机上非常明显。

will-change: transform 会让浏览器提前把元素提升为独立合成层,代价是常驻占用一点显存。一个 3px 的条子无所谓,但别把它加到列表项那种成百上千个节点上,否则显存会被吃光,反而更卡。

更新方式 每帧成本 主要风险
直接改 width 布局 + 绘制 长页面掉帧
scroll 里改 transform 合成 事件过密时重复写入
rAF 合并 + transform 合成 几乎无
章节式 IntersectionObserver 无滚动计算 只有离散进度,不适合连续条

如果设计上只需要「读到第几节」,用 IntersectionObserver 更省,完全不用监听滚动。要做连续进度条,还是得回到上面这套。

三个容易忽略的坑#

  • 安全区:iPhone 刘海屏上 top: 0 会被状态栏压住,用 top: env(safe-area-inset-top) 才不会被裁掉。
  • 打印:打印长文时进度条会印在每一页顶部。加 @media print { #progress-bar { display: none } }。
  • 自定义滚动容器:如果正文在一个 overflow: auto 的 div 里,document.scrollingElement 永远是 0。这时把 el 换成那个容器,监听器也挂到容器上,公式不变。

还有可访问性。进度条对读屏用户价值有限,最简单是 aria-hidden="true";如果确实想暴露,就给容器加 role="progressbar"、aria-valuemin="0"、aria-valuemax="100",代码里已经在同步 aria-valuenow。不要在滚动过程中朗读它,否则会打断阅读。

怎么验证它真的不掉帧#

三步,都在浏览器里完成:

  1. 边界值:控制台执行 document.scrollingElement.scrollTop = 1e9,进度应该是 1;设成 -100 应该夹到 0;把窗口拉到比内容还高,进度应保持 0 而不是 NaN。
  2. 性能录制:DevTools 的 Performance 面板录一段连续滚动,看是否有 Layout 事件。有 Layout 说明你改的还是 width 或 left。
  3. 高刷屏:在 120Hz 设备上滚一段,如果进度条明显滞后于内容,通常是 rAF 外面又套了 setTimeout 节流。

进度条最容易「看起来对、实际错」的地方是首屏:图片还没加载完,scrollHeight 偏小,进度被算得虚高;图片加载完高度变了,却没有 scroll 事件。这也是为什么代码里用 ResizeObserver 观察 body,而不是只在 load 时算一次。

自动化测试也可以做:用 jsdom 或一个假的 document 桩跑 update(),断言三个边界值分别是 0、0.5、1。这类纯计算的回归测试成本很低,值得加。

结论#

  • 进度公式只有一个,难点全在边界:短页面、负 scrollTop、视口变化、缓存恢复。
  • 用 requestAnimationFrame 合并滚动事件,用 passive: true 让出滚动线程。
  • 改 transform: scaleX(),不要改 width;两者差一整个布局阶段。
  • 监听 resize、pageshow 和 ResizeObserver,覆盖地址栏伸缩与异步内容撑高。
  • 进度条是装饰,记得 pointer-events: none、打印隐藏和安全区适配。

完

#JavaScript#性能#DOM
回到顶部 ↑