长文页面顶部加一条阅读进度条,实现只要十几行。但很多人写出来的版本一滚动就掉帧,尤其在手机上。
问题几乎不在监听器数量,而在「每帧都触发一次布局计算,再改一个会触发重排的属性」。把这两件事换掉,代码反而更短。
先说明适用范围:下面这套适合「整页滚动」的长文页。如果你的正文在某个 overflow: auto 的区域里,或者用的是虚拟滚动,逻辑要换——文末会提。
先算清楚进度#
公式只有一行:
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 行实现#
// 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();
三个细节撑起了「丝滑」:
requestAnimationFrame合并。滚动事件在一帧内可以触发多次(触控板、高刷屏更明显)。用queued标记把这一帧的所有事件折叠成一次 DOM 写入,渲染前只算一次。passive: true。告诉浏览器这个监听器不会调用preventDefault(),滚动线程不必等 JS 执行完再合成画面。少这一行,移动端的滚动手感会明显变「粘」。lastRatio去重。滚到顶部或底部后,后续事件算出的比例不变,直接返回,省掉无意义的样式写入。
queued 这个布尔量看起来多余,其实关键:requestAnimationFrame 本身不合并,连续调用会在一帧后执行多次。用「已排队」标记保证一帧只有一次回调,剩下的滚动事件全部丢弃——反正这一帧的最终位置就是最后一个事件的位置。
最后一行 schedule() 不是可选项。页面从缓存或前进后退恢复时,可能已经处于滚动状态,但不会补发 scroll 事件,不主动算一次就会停在错误的比例上。
样式:用 transform,不要用 width#
#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。不要在滚动过程中朗读它,否则会打断阅读。
怎么验证它真的不掉帧#
三步,都在浏览器里完成:
- 边界值:控制台执行
document.scrollingElement.scrollTop = 1e9,进度应该是 1;设成-100应该夹到 0;把窗口拉到比内容还高,进度应保持 0 而不是NaN。 - 性能录制:DevTools 的 Performance 面板录一段连续滚动,看是否有 Layout 事件。有 Layout 说明你改的还是
width或left。 - 高刷屏:在 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、打印隐藏和安全区适配。
完