加max-height和overflow-y: auto,它会自己滚动,但此时tbody里的tr会被截断、换行错乱,尤其在固定列宽或white-space: nowrap场景下更明显。
正确做法是把滚动容器设在table外层:div.table-container控制高度和溢出,table保持自然流式布局。否则你看到的“滚动”只是视觉错觉,tr实际被CSS裁剪,无法触发IntersectionObserver或准确计算滚动位置。
- 别给
table设height或max-height
-
div.table-container必须有明确height或max-height + overflow-y: auto
-
tbody不能用display: block强行滚动——会破坏表格语义和对齐
用IntersectionObserver监听底部占位符,而不是window.scroll
监听window滚动在表格场景下极易误判:表格高度变化、缩放、右侧滚动条宽度变动都会让document.documentElement.scrollHeight计算偏移。而IntersectionObserver直接观察容器内元素是否进入视口,不受这些干扰。
实操时,在tbody末尾插入一个<tr id="sentinel"><td colspan="100%" style="height: 1px;"></td></tr>(colspan覆盖所有列),然后观察它:
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading && hasMore) {
loading = true;
fetch(`/api/orders?page=${currentPage}`)
.then(r => r.json())
.then(data => {
data.items.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${item.id}</td><td>${item.qty}</td>`;
document.querySelector('#orders-tbody').appendChild(tr);
});
currentPage++;
hasMore = data.hasMore;
loading = false;
});
}
},
{ root: document.querySelector('.table-container'), rootMargin: '200px' }
);
observer.observe(document.getElementById('sentinel'));
-
root必须指定为.table-container,否则默认观察window,又回到老问题
-
rootMargin: '200px'表示占位符距容器顶部还有200px就触发,留出网络+渲染缓冲
- 每次追加完新
tr后,#sentinel会自动下移,无需手动重插——但要确保它始终在tbody最末
动态追加tr时别用innerHTML重写tbody
很多人习惯先清空tbody.innerHTML = ''再拼接全部HTML,这会导致三类问题:已绑定的事件监听器丢失、focus状态中断、第三方组件(如日期控件、图表)销毁后未重初始化。
正确方式永远是appendChild()或insertAdjacentElement('beforeend', tr)。哪怕后端返回的是HTML字符串,也该用DOMParser解析后再逐个appendChild,而非直接innerHTML注入:
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
doc.querySelectorAll('tr').forEach(tr => {
document.querySelector('#orders-tbody').appendChild(tr);
});
- 避免
innerHTML += ...——它会重新解析整个内容,性能差且易XSS
- 如果后端返回JSON,直接构造
tr比拼HTML字符串更安全、更易调试
- 每行
tr应带唯一data-id,方便后续更新或删除单行,而不是靠索引
后端分页字段必须明确返回hasMore,不能靠数组长度判断
前端看到data.items.length === 0就停,是常见错误。网络超时、数据库临时空、分页参数错位都可能返回空数组,但实际还有下一页。真正可靠的信号是后端显式返回hasMore: true/false。
同时注意游标分页的陷阱:如果用last_id做下一页参数,当某条记录被删除,后续数据会跳过。推荐用created_at + id复合游标,例如?cursor=2026-07-01T00:00:00Z_12345。
- 前端必须维护
hasMore状态,并在observer.unobserve(sentinel)前检查
- 请求失败时要恢复
loading = false,否则滚动将永久冻结
- 首次加载后,
#sentinel可能因内容变少而提前进入视口,需在fetch前加if (loading || !hasMore) return双保险
表格无限滚动真正的难点不在“怎么滚”,而在“怎么稳”:滚动容器边界、观察目标定位、DOM追加方式、后端状态同步,四者任一出错都会导致重复加载、白屏、错行或卡死。最容易被忽略的是root参数没设对,以及#sentinel被innerHTML操作意外删掉——这两处一错,整个机制就静默失效。