纯前端实现可访问的分页与无限滚动:列表虚拟化、焦点保持与返回原位
列表是后台系统里最常见的界面,也是滚动行为最容易失控的地方。分页和无限滚动看似只是两种取数策略,实际上它们决定了键盘用户怎么走到下一页、读屏用户怎么知道总共有多少条、以及用户点进详情再返回时能不能回到原来的位置。这三件事做不好,列表越长越难用。
一、先决定策略,再决定结构
分页适合需要稳定定位和可分享位置的场景:用户能知道自己在第几页、总共有多少页,返回时能精确跳回。无限滚动适合以浏览为主、不需要精确定位的场景:时间线、信息流。不要为了“看起来现代”给一个有明确检索目标的列表套无限滚动——用户找第 40 条记录时会非常痛苦。
如果数据量真的很大,更稳妥的折中是“加载更多”按钮:语义上和分页一致,交互上比翻页轻,且键盘用户明确知道自己触发了一次加载。
二、分页的可访问结构
分页控件是一个导航区域,用 nav 加 aria-label 标明用途,内部是链接或按钮列表。当前页码用 aria-current="page" 表达,不要只靠一个高亮色块。上一页、下一页在首尾时要真正禁用,而不是点了没反应。
<nav aria-label="搜索结果分页">
<ul>
<li><a href="?page=1" aria-label="第 1 页">1</a></li>
<li><a href="?page=2" aria-current="page">2</a></li>
<li><a href="?page=3" rel="next">下一页</a></li>
</ul>
</nav>
翻页后要把焦点移到结果区域的标题上,并让该标题获得 tabindex="-1",否则键盘用户还停在页面底部的分页条上,得重新 Tab 一整页才能回到列表。同时用 aria-live 播报“第 2 页,共 48 条”。
三、无限滚动的可访问补偿
无限滚动对键盘和读屏用户天然不友好:内容会在你阅读时被插入,浏览光标的位置会漂移。必须做三件事补偿。第一,滚动容器要可聚焦并带 tabindex="0"、role="feed" 或合适的 landmark,让用户能用 PageDown 连续浏览;第二,加载新内容时在容器末尾放一个 role="status" 的哨兵,播报“已加载 20 条,共 120 条”;第三,永远提供一个“加载更多”按钮作为键盘可达的显式入口,滚动触发只是增强。
还要防止重复触发:一次请求未返回前不要再发第二次;用户快速回滚到顶部时不要清空已有列表。把请求状态显式建模成 idle、loading、done、error 四态,界面按状态渲染,比用一堆布尔量拼出来的分支可靠得多。
四、列表虚拟化与真实序号
上万条记录必须虚拟化,但虚拟化会让浏览器的查找和读屏的连续阅读失效。渲染窗口内必须使用真实的数据序号,不能被重新编号;同时用 aria-setsize 与 aria-posinset 告诉读屏这条数据在全集中的位置。
<div role="list">
<div role="listitem" aria-setsize="10000" aria-posinset="4821">…</div>
</div>
虚拟化还必须处理“焦点落在已被回收的行上”。焦点所在行要强制保留在渲染窗口内,或在回收前把焦点移到滚动容器,重渲染后再还给对应的逻辑行,否则 Tab 一次焦点就凭空消失。
五、返回原位:滚动恢复
用户点进详情再返回,期望回到原来的位置。浏览器对同页导航有自动的滚动恢复,但对前端路由的列表页往往不生效。做法是在进入详情前把当前滚动量与已加载页码写进 sessionStorage,返回时在首屏渲染完成后恢复。
const key = `list:${route.fullPath}`;
saveBeforeLeave(key, { scrollTop: container.scrollTop, page });
// 返回时
const saved = readSaved(key);
if (saved) {
await loadUntilPage(saved.page);
requestAnimationFrame(() => { container.scrollTop = saved.scrollTop; });
}
恢复要放在 rAF 里,避免与浏览器自身的锚点恢复互相覆盖。如果列表内容会变化(比如有新数据插入顶部),恢复后还要校验目标行是否还在,必要时给出“列表已更新”的提示而不是硬跳。
六、筛选、排序与分页的联动
改变筛选条件或排序字段时,页码必须重置回第一页,否则用户会看到一个空列表。这一点务必在状态层统一处理,而不是在十几个调用点各自记得重置。筛选后的结果数量要播报,无结果时给出明确提示与清空条件的入口。
如果把页码写进了 URL,那就是可分享的状态;没写进 URL 的筛选条件要注意别在刷新后悄悄丢失。经验做法是:只要能影响结果集的条件,都进 URL。
七、触底加载的性能细节
用 IntersectionObserver 监听哨兵比监听 scroll 事件更省。但要在哨兵进入视口前留出足够的提前量,否则用户滚到底才看到加载动画,会感觉卡顿。图片列表要预留固定高度或宽高比,避免加载完成后把滚动位置顶得乱跳——这是滚动恢复失效最常见的原因。
数据层要做去重与合并:按稳定主键合并新到的一页,而不是无脑拼接,否则快速滚动时容易看到重复条目。
八、测试清单
覆盖键盘 Tab 到分页条、翻页后焦点落点、首尾页按钮禁用、无限滚动时用 PageDown 连续浏览、加载播报、返回详情后的滚动位置、筛选变化后页码重置、虚拟化的焦点保持、以及快速滚动下的重复项与请求竞态。用读屏实际操作一遍,最能暴露“内容插入导致阅读位置漂移”这类问题。
九、小结
分页与无限滚动的差别只在取数策略,可访问性的要求却是一致的:位置要说得清、焦点要跟得上、返回要回得去。把策略选择、状态建模和滚动恢复分成三层来处理,列表才能在数据量、网络速度和辅助技术三种压力下都保持可控。
- 点赞
- 收藏
- 关注作者
评论(0)