12k
All articles

如何在原生 JavaScript 中实现无限滚动

使用原生 JavaScript 和 Intersection Observer 实现无限滚动,包含哨兵元素、分页、加载防重复以及无障碍备用方案。

OpenReplay Team
OpenReplay Team
如何在原生 JavaScript 中实现无限滚动

使用 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 仍将这个遮挡检测属性列为实验性且可用性有限。

什么是哨兵模式?

哨兵模式在列表底部放置一个标记元素;当观察器报告哨兵进入视口时,你就获取下一页并追加。哨兵只是最后一项之后的一个空元素,你永远不需要重新选取它,因为追加新项目会不断把它往下推。

三个组成部分:

  1. 构造观察器:new IntersectionObserver(callback, options)
  2. 开始观察:observer.observe(sentinel)
  3. 在回调中检查 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.oktry/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 按钮使导航映射到一个稳定、可复现的状态。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.