如何在原生 JavaScript 中实现无限滚动
使用原生 JavaScript 和 Intersection Observer 实现无限滚动,包含哨兵元素、分页、加载防重复以及无障碍备用方案。
使用 Intersection Observer API 在原生 JavaScript 中实现无限滚动:在列表底部放置一个哨兵(sentinel)元素,对其进行观察,每当它进入视口时就获取下一页数据。
如果你以前做过这类功能,一定熟悉它的失败模式:滚动条稍微用力一甩,同样的十条数据就往列表里塞了三遍。让基础机制跑起来大概只要十分钟,而让它经得起真实用户的折腾,则要花掉一下午的剩余时间。这种方案取代了旧的 scroll 事件加 getBoundingClientRect 的做法——后者在每一次滚动 tick 上都要做位置计算。本文将构建一个完整、可运行的信息流,包含真实的 fetch、分页和 DOM 追加,然后覆盖四个生产环境中的坑(重复请求、永不停止、错误处理和预取时机),以及那些让代码从 demo 变为可上线产品的无障碍降级方案。
核心要点
- 使用
IntersectionObserver,而不是滚动事件:滚动监听器会在主线程上持续触发并迫使你手动做位置计算,而观察器只在目标真正穿过视口时才执行回调。 IntersectionObserver自 2019 年 3 月起就已在所有现代浏览器中达到 Baseline,因此如今实现无限滚动无需任何 polyfill。- 用一个布尔标志位守卫每一次 fetch,这样快速滚动就不会在第一个请求 resolve 之前触发多个重叠请求。
- 当 API 返回不足一页或空页时停止。调用
observer.disconnect()并隐藏哨兵元素,否则观察器会不断请求已经不存在的页面。 - 为无限滚动搭配一个可见的 “Load more” 按钮:它同时充当键盘、屏幕阅读器和无 JavaScript 环境下的降级方案。
为什么 IntersectionObserver 优于滚动事件?
应该使用 IntersectionObserver 而非 scroll 监听器,因为它通过回调异步报告可见性,且该回调仅在目标穿过视口时触发,而不是在每一帧滚动时都运行。旧模式会挂载一个 scroll 处理函数,并在每次 tick 上调用 getBoundingClientRect() 来计算列表底部是否临近。这是在主线程上读取布局的计算,运行频率远超实际需要,而且是众所周知的滚动卡顿来源。
scroll + getBoundingClientRect() | IntersectionObserver | |
|---|---|---|
| 触发时机 | 每一帧滚动 | 仅当目标穿过视口时 |
| 位置计算 | 手动,写在你的代码里 | 由浏览器处理 |
| 线程 | 主线程上同步执行 | 异步派发 |
| 需要 polyfill | 不适用 | 否(Baseline) |
无需 polyfill。MDN 将该 API 标记为 Baseline Widely available(广泛可用),所有主流浏览器的支持可追溯至 2019 年 3 月,因此那些推荐使用 polyfill(并引用 Chrome 51 时代支持情况)的旧建议已经过时。有一个例外:默认情况下不要使用 trackVisibility,因为 MDN 仍将这个遮挡检测属性列为实验性且可用性有限。
什么是哨兵模式?
Discover how at OpenReplay.com.
哨兵模式在列表底部放置一个标记元素;当观察器报告哨兵进入视口时,你就获取下一页并追加。哨兵只是最后一项之后的一个空元素,你永远不需要重新选取它,因为追加新项目会不断把它往下推。
三个组成部分:
- 构造观察器:
new IntersectionObserver(callback, options)。 - 开始观察:
observer.observe(sentinel)。 - 在回调中检查
entry.isIntersecting,为true时加载下一页。
应遍历 entries 数组,而不是直接读取 entries[0]。IntersectionObserver() 构造函数参考文档警告不要假定条目数量,因为你的回调一次运行可能携带多个交叉事件。
一个完整的原生 JavaScript 无限滚动示例
下面是一个针对 JSONPlaceholder 的完整可用实现,这是一个免费的模拟 REST API,底层运行在 JSON Server 和 LowDB 之上。它的 /posts 端点包含 100 条记录,接受 _page 和 _limit 查询参数,并以普通数组形式返回请求的切片。这提供了一个有限的数据集,便于演示数据耗尽时会发生什么。
标记结构:一个列表、一个降级按钮、一个哨兵元素和一行 live region 状态提示。
<main>
<ul id="list" aria-label="Posts"></ul>
<button id="load-more" type="button">Load more</button>
<div id="sentinel" aria-hidden="true"></div>
<p id="status" role="status" aria-live="polite"></p>
</main>
脚本将观察器与哨兵关联,每次交叉获取一页:
const LIMIT = 10;
let page = 1;
let loading = false; // guard against overlapping requests
let done = false; // stop at end of data
const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");
async function fetchPosts(page) {
const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function render(posts) {
const frag = document.createDocumentFragment();
for (const post of posts) {
const li = document.createElement("li");
li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
frag.appendChild(li);
}
list.appendChild(frag);
}
async function loadNextPage() {
if (loading || done) return;
loading = true;
status.textContent = "Loading…";
try {
const posts = await fetchPosts(page);
render(posts);
page += 1;
if (posts.length < LIMIT) { // short/empty page = no more data
done = true;
observer.disconnect();
loadMoreBtn.hidden = true;
status.textContent = "You've reached the end.";
} else {
status.textContent = "";
}
} catch (err) {
status.textContent = "Could not load posts. Tap Load more to retry.";
console.error(err);
} finally {
loading = false;
}
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadNextPage();
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);
每个请求都经过 loadNextPage,因此观察器回调、按钮点击和初始的 DOMContentLoaded 加载都共享同一套守卫与停止逻辑。
让代码从 demo 变为可上线产品的四个坑
大多数教程止步于”它能追加数据”。以下这四项修复才是让它扛得住真实用户的关键。
| 症状 | 原因 | 修复 |
|---|---|---|
| 快速滚动时产生重复请求 | 缺少请求守卫 | if (loading) return; 布尔标志位 |
| 列表永不停止,反复请求空页 | 没有结束检测 | if (posts.length < LIMIT) observer.disconnect() |
| 错误悄无声息地消失 | 没有 fetch 错误处理路径 | 检查 res.ok、try/catch,并暴露重试入口 |
| 滚到底部时有明显停顿 | rootMargin: "0px" | 用 rootMargin: "200px" 提前预取 |
防止重复请求。 快速一甩可能在第一个 await resolve 之前多次触发回调。loading 标志位让每一个额外调用提前返回,直到进行中的请求在 finally 块中结束。你也可以在请求期间对哨兵调用 unobserve,之后再重新观察。只是别把 unobserve(单个目标)和 disconnect(所有目标)搞混。
在数据末尾停下。 面对有限的数据源,如果一直请求下去,你就会不停地轰炸并不存在的页面。检测长度小于 LIMIT 的页面即可,这与 Prismatic 的分页 API 循环教程中使用的数据结束信号相同——该教程在请求返回空数组时立即跳出循环。随后调用 disconnect() 并隐藏哨兵和按钮。
优先采用 threshold: 0 搭配 rootMargin。 设置 rootMargin: "200px" 会在用户距离底部约 200 像素时就开始下一次获取,消除可见的卡顿。将它与 threshold: 0 组合,而不是 1.0:高度超过视口的哨兵可能永远无法 100% 可见,因此完全可见的阈值可能会静默地不触发。
无限滚动的 bug 依赖于时序和滚动速度,因此小心翼翼的本地滚动很少能复现它们。通过 session replay 这类工具观察真实会话,是揭示这类在快速测试中隐形的故障的一种方式:快速甩动时的重复请求,或者永远停不下来的列表。
无障碍与 “Load more” 降级方案
始终为无限滚动搭配一个可见的 “Load more” 按钮:它既是键盘和屏幕阅读器的降级方案,也是无 JavaScript 时的降级方案,而且往往是用户能够暂停信息流以抵达页脚的唯一途径。无休止的自动加载内容会困住辅助技术用户,把页脚链接埋在不断增长的内容之下,并在用户返回一个 DOM 中已不复存在的位置时破坏后退按钮的滚动位置恢复。
三个具体步骤,上面的代码中都已包含:
- 通过 live region 播报加载状态:
<p role="status" aria-live="polite">让屏幕阅读器能听到 “Loading…” 和 “You’ve reached the end.”。 - 保留按钮作为真正可聚焦的控件,这样当观察器从未触发或 JavaScript 被禁用时它仍然可用。
- 将哨兵标记为
aria-hidden="true"。它是一种机制,而非内容,不应进入无障碍树。
如果某个信息流的页脚确实重要(联系链接、法律条款、用于深层链接的分页),请考虑仅使用 “Load more” 按钮是否是更好的模式,并把自动加载留给那些”无尽流”本身就是核心体验的内容。
原生 JavaScript 中的无限滚动归结为一个经久不变的思路:观察一个哨兵,在交叉时获取数据,并处理好边界情况。取用上面的完整文件,把 fetchPosts 指向你自己的分页端点,并在上线前确认守卫逻辑和结束停止路径都能正常触发。正是这两行代码,把一个能跑的 demo 变成了你可以在生产环境中信赖的代码。
常见问题
IntersectionObserver 上的 unobserve 和 disconnect 有什么区别?
当你希望观察器放弃某一个特定元素而继续观察其余元素时,调用 unobserve;当你希望它释放当前正在观察的全部元素时,调用 disconnect。对于无限滚动而言,这对应于:在请求进行中用 unobserve 暂停那个唯一的哨兵,而在数据耗尽、观察器再无工作可做时调用 disconnect。
什么时候该用分页或 Load more 按钮而不是无限滚动?
当页脚很重要时——比如联系链接、法律文本或深层链接分页——应选择分页或 Load more 按钮,因为无休止的自动加载会把页脚内容永久地推到用户够不着的地方,并困住键盘和屏幕阅读器用户。无限滚动适合开放式内容,即无尽流本身就是核心体验的场景,例如社交信息流。当用户需要一个停顿点或必须抵达底部时,显式控件是更好的模式。
为什么 threshold 设为 1.0 有时无法触发无限滚动?
阈值为 1.0 要求被观察元素 100% 可见后回调才会触发,因此高度超过视口的哨兵永远无法完全进入视野,回调也就悄无声息地永不执行。应改用 threshold 0 搭配 rootMargin 缓冲区:这样只要哨兵的任意部分穿过扩展后的 root 边界,回调就会触发,这对无限滚动来说是更可靠的默认设置。
我需要在 IntersectionObserver 回调中处理多个 entry 吗?
需要。MDN 的构造函数参考文档提醒你不要依赖 entries 数组具有某个特定长度,因为回调的一次运行可能携带不止一个交叉事件。对于单个哨兵,entries[0] 在实践中往往可行,但遍历所有 entries 并逐个检查 isIntersecting 才是正确做法,可以在多个目标同时上报时避免遗漏或错误归因的交叉事件。
无限滚动会破坏浏览器的后退按钮吗?
会,无限滚动可能破坏后退按钮的滚动位置恢复,因为在导航时动态加载的内容被丢弃后,浏览器仍试图把用户带回一个 DOM 中已不存在的滚动位置。结果用户会落在错误的位置或列表顶部。缓解措施包括持久化已加载的状态、手动恢复滚动位置,或提供 Load more 按钮使导航映射到一个稳定、可复现的状态。