无限滚动列表听起来是个标准的“前端基础题”但真上手做的时候你会发现坑比想象中多。这个需求这些年我前前后后做了不下一二十次从最早给某任务管理小程序做消息流到后来给某资讯站做信息流列表凡是涉及“不断往下刷、不断加载”的场景几乎都绕不开这套东西。虽然现在前端框架遍地都是Vue、React顺手就能搞定但不少老项目、独立站或者某些对原生依赖要求比较紧的环境里还是要用HTMLjQuery原生JS这套组合拳。我自己在实操中反而觉得这套组合更透明、更好控制出了问题也容易排查。这里就把我是怎么一步步把无限滚动列表落地、优化、排坑的过程完整梳理一遍。内容会比较细从需求拆解、技术选型到代码结构、性能处理再到实际开发中踩过的坑尽可能让你看完就能在自己项目里用起来。1. 内容整体设计与思路拆解1.1 无限滚动到现在还有没有使用的必要不少人一听到jQuery就觉得是“远古技术”但实际上很多存量项目、后台管理系统、部分电商大促页面的核心列表仍然跑在jQuery这套生态上。尤其是那种承接了历史包袱、没有重构预算的系统你不可能为了一两个功能把所有脚本全都推翻重来。这时候用jQuery原生JS完成一个高质量的无限滚动列表反而是最稳妥、最省成本的方案。退一步说即便是在现代框架底下无限滚动的底层逻辑依然没有变监听滚动事件、判断触底条件、发请求取数据、渲染追加DOM。这套思路理解透了你用任何框架都是套壳的问题。用jQuery和原生JS来学这个能力反而更能看清底层逻辑不被框架的语法糖掩盖本质。1.2 为什么选择HTML JQ JS这套组合技术选型从来不是越新越好而是越合适越好。先说背景。这类无限滚动列表通常要嵌入在既有页面里往往还牵涉到服务端渲染出来的HTML片段有些接口返回的还不是JSON直接返回了一段要塞进页面的HTML片段。这个场景下jQuery的优势非常明显——它选择器顺手、DOM操作好写、Ajax写法成熟而且对拼接字符串插入页面这种事相当兼容。再说原生JS的部分。无限滚动的核心逻辑——滚动监听的节流、触底距离的判断、分页参数的自增——这些其实用原生JS写反而更干净不需要依赖jQuery就能清晰、稳定地完成。别小看混搭这个思路代码里JQ负责DOM、Ajax原生JS负责逻辑控制各管一摊职责分明代码可读性反而更高。1.3 核心问题拆解一个无限滚动列表必须考虑哪些事一个“能跑起来”的无限滚动列表很简单但一个“用得稳”的无限滚动列表你必须至少想清楚下面这些环节触发机制怎么判断用户已经滚动到接近底部从而加载更多数据数据协议每次加载到底拿多少条数据页码怎么递增接口字段怎么和列表结构对应渲染方式新数据是直接拼接字符串插入还是需要再包一层模板函数处理节流控制滚动事件触发的频率极高不做节流的话性能会出问题。防重复请求用户快速滚动或网络慢的时候一个请求还没返回另一个又发出去会导致数据重复甚至错乱。状态管理加载中状态、下拉到底状态、加载失败状态这些都需要在界面上体现否则用户会觉得很异常。初始化与重置列表是首次进入就加载还是需要翻页之后重新加载搜索条件变化之后列表要如何重置这一圈拆分下来你会发现这个“基础题”一点都不基础。下面每个环节我都会拆开了讲并且给出可直接拿过去用的代码。2. 核心细节解析与实操要点2.1 触底判断的几种常见方式无限滚动最核心的一点就是判断“应该去加载更多数据了”。而要判断这个时机通常是看滚动条离容器底部还有多少距离。我实际用下来主要有这么几种写法——第一种是基于整体文档高度的判断$(window).scroll(function() { var scrollTop $(window).scrollTop(); var windowHeight $(window).height(); var docHeight $(document).height(); // 当滚动条距底部小于某个阈值时触发加载 if (docHeight - (scrollTop windowHeight) 200) { // 加载更多 } });这种写法适合整个页面就是滚动容器的场景。阈值取多少合适我以前取过50px结果发现高网速下没什么问题但弱网环境里用户滑动稍快就会“追着底跑”后来经验值固定在150到250px之间体感上比较从容。第二种是基于局部容器的滚动判断$(.list-container).on(scroll, function() { var container this; if (container.scrollTop container.clientHeight container.scrollHeight - 200) { // 加载更多 } });这种适合列表区域是独立于页面的、高度固定或者单独滚动的块级容器。比如弹窗里的列表、侧边栏里的消息流都会用到这种写法。第三种是最近几年在实践中用得越来越多的IntersectionObserver的方案。严格说是原生API不依赖滚动事件的反复触发而是观察一个“哨兵元素”是否进入视口。这个方案的性能开销比监听滚动事件节流要低不少。虽然有兼容性考虑现在主流浏览器支持度已经很好。但如果项目里必须兼容老旧内核比如一些政企项目里还在用老版浏览器的话滚动事件方案仍然是更保险的选择。2.2 数据协议与分页设计无限滚动的数据接口我强烈建议在项目初期就和后端定好一套统一的分页参数规范。最常用、最不容易出错的是这几对参数客户端传给服务端的参数参数名含义示例值page当前页码1pageSize每页条数10query搜索关键词可选前端模板服务端返回的数据结构字段名含义示例值code状态码0为成功0data.list列表数组[{id: 1, title: 标题}]data.total数据总条数100data.hasNext是否还有下一页true注意一个容易被忽略的细节——分页参数里的page到底从0开始还是从1开始一定要确认好。这种事情看起来小但是前后端一旦理解不一致第二页数据就变成第一页的重复数据了用户一刷就全是重复内容体验非常糟糕。我自己在这个问题上吃过亏后来干脆在联调文档里单独列一条“分页起始值”的说明每次都要核对。2.3 渲染与DOM操作的几个决策点拿到接口数据之后就得决定把数据拼接成什么形式的HTML插入页面。这里有几个经验性的选择一是模板字符串。以前我们喜欢用字符串拼接加循环代码可读性很差。ES6之后的模板字符串配合map、join写起来舒服很多function buildItemHtml(item) { return div classlist-item>$(.list-container).on(click, .list-item, function() { var id $(this).data(id); // 处理点击逻辑 });这样无论后面追加多少数据只要容器不销毁事件就一直有效。使用委托还有一个好处是不用在每次加载完新数据后重复绑定事件对于“无限追加”这个场景简直是绝配。三是虚拟列表是否要引入。对于几千条以上的长列表如果一次性把数据全部渲染到DOM里性能一定会出问题。我以前的处理策略是如果列表数量通常控制在1000条以内那么无限滚动配合按批加载就够用如果列表总量可能上万级比如通讯录、订单交易流水那才需要引入虚拟列表的机制——只渲染可视区域内的节点。但是要注意虚拟列表的实现复杂度要高一个量级会涉及高度估算、滚动偏移补偿等逻辑。所以现实情况是可以先按“分批加载无限滚动”来应对等真出现性能瓶颈了再考虑虚拟化改造。2.4 加载状态与用户体验细节无限滚动最容易出现的体验问题是用户不知道底部到底还有没有数据或者根本不知道现在是否在加载中。所以几个状态一定要做出来初始化加载状态页面进来时至少要有基本骨架屏或者加载提示效果。下拉加载中的状态数据显示“正在加载中…”之类的内容同时为防止重复请求通过锁变量来控制。全部加载完毕状态调用接口返回的total条数已经全部加载完列表底部要给出“已经到底啦”之类的提示让用户明确知道“没有了”。加载失败状态支撑老一点的业务时还需要考虑断网、接口超时等异常情况给出“点击重试”的交互。这些状态如果做齐了用户在使用过程中就不会有困惑。别看这些都是小细节很多无限滚动列表体验差就是差在这些状态没有处理好。3. 实操过程与核心环节实现3.1 页面基础结构与样式准备先准备一个最基础的HTML结构。我一般习惯直接把列表容器和“加载更多触发哨兵”分开来写这样逻辑更清晰。div idfeedList classfeed-list !-- 动态追加的列表项会放在这里 -- /div div idloadMore classload-more span classloading-text正在加载中.../span /div哨兵元素的作用是方便判断是否滚动到底部。当这个元素出现在视野里并且当前没有在加载中、也没有加载完时就可以触发新一轮加载这是前面说到的IntersectionObserver方案中最优雅的一种做法。样式部分按常规列表即可但有一点要注意——列表容器和哨兵元素不能高度塌陷如果列表内容不满一屏也要确保哨兵元素能被看到。否则就会出现“明明数据没加载完但怎么也触发不了加载”的诡异情况。解决办法是给列表容器一个min-height或者在初始化完成前先手动触发一次加载。3.2 核心JS逻辑逐步实现接下来看到完整的核心逻辑代码。按照我习惯的分层方式会先定义好状态变量再封装加载函数最后绑定事件。// 状态管理 var listState { page: 1, // 当前页码 pageSize: 10, // 每页数量 total: 0, // 总条数 isLoading: false, // 是否正在加载 isFinished: false, // 是否已经加载完毕 keyword: // 搜索关键词 }; // 初始化加载第一页 function initList() { listState.page 1; listState.isFinished false; listState.total 0; $(#feedList).empty(); setLoadStatus(loading); loadData(); } // 加载数据 function loadData() { if (listState.isLoading || listState.isFinished) { return; } listState.isLoading true; setLoadStatus(loading); $.ajax({ url: /api/list, method: GET, data: { page: listState.page, pageSize: listState.pageSize, keyword: listState.keyword }, dataType: json, success: function(res) { if (res.code ! 0) { setLoadStatus(error); return; } handleSuccess(res.data); }, error: function() { setLoadStatus(error); }, complete: function() { listState.isLoading false; } }); } // 处理成功返回 function handleSuccess(data) { var list data.list || []; var html ; if (list.length 0) { listState.isFinished true; setLoadStatus(empty); return; } for (var i 0; i list.length; i) { html buildItemHtml(list[i]); } $(#feedList).append(html); listState.page; listState.total data.total; // 判断是否已经加载完 if (list.length listState.pageSize || listState.page * listState.pageSize listState.total) { listState.isFinished true; setLoadStatus(finished); } else { setLoadStatus(loading); } } // 设置加载区域状态 function setLoadStatus(status) { var $box $(#loadMore); switch (status) { case loading: $box.find(.loading-text).text(正在加载中...); $box.find(.loading-text).show(); $box.find(.finished-text).hide(); $box.find(.error-text).hide(); break; case finished: $box.find(.loading-text).hide(); $box.find(.finished-text).show(); $box.find(.error-text).hide(); break; case error: $box.find(.loading-text).hide(); $box.find(.finished-text).hide(); $box.find(.error-text).show(); break; } }这里有几个设计点我特意说说。第一个是loadData开头的双重判断。发现正在加载或者已经加载完毕就直接返回这是防止重复请求最朴素但也最有效的锁机制。注意这个锁必须是函数顶部的同步判断在异步请求回来之前isLoading始终为true。JS虽然是单线程但异步回调是不会打断这个判断的。第二个是page的递增时机。我刻意在请求成功之后才page这样如果某一页请求失败重试时还会用当前页码不会因为页数已经自增而漏掉数据。这个细节比较容易忽视但实际用起来很关键。第三个是isFinished的判断条件。既要考虑返回list长度小于pageSize的情况也要预判总条数是否已经被当前页耗尽。有些后端返回list长度始终等于pageSize但最后一页只剩一条数据时用空数组返回也会正确处理。但要注意的是有的后端即使没有数据了返回的也是个空数组而不是报错所以代码里要判断list.length 0就来兜底。3.3 触底触发与IntersectionObserver的应用基于前面的分析我这里给出两种触发方案。你先按项目需求选择其中一种后续再进行封装。方案一基于滚动事件jQuery的触发兼容性最好var canTrigger true; $(window).on(scroll, function() { if (!canTrigger) return; var distance $(document).height() - ($(window).scrollTop() $(window).height()); if (distance 200) { canTrigger false; loadData(); // 限制触发频率至少延迟300ms后再启用下次触发 setTimeout(function() { canTrigger true; }, 300); } });注意canTrigger这个标志位它是第二层锁。我这么设计的目的是即使你防住了重复请求快速滚动时还会出现一个情况——加载动作触发得过于频繁但接口还没返回数据下一次滚到底触发时又进来了也只能被isLoading拦下。从效果上看不仅没有帮助还会因为反复进入逻辑而消耗一点性能。方案二基于IntersectionObserver的现代方案性能更好var observer new IntersectionObserver(function(entries) { if (entries[0].isIntersecting) { loadData(); } }); observer.observe(document.getElementById(loadMore));这个API的逻辑是当哨兵元素进入视口时回调触发。没用滚动事件所以不需要节流IntersectionObserver本身就是异步高性能的。这个方案唯一的限制就是需要浏览器支持。我实测下来在移动端上IntersectionObserver的表现明显优于scroll方案可以做到非常顺滑。而在一些老项目里若遇到兼容问题再平稳降级为scroll方案。这也是一个比较推荐的渐进增强策略。3.4 搜索、筛选与重置逻辑无限滚动列表往往还会叠加搜索或筛选条件。这个需求里对应的是当用户修改了搜索关键词列表需要重新初始化页码回到1并且清空现有DOM。这里最关键的问题是必须把旧的筛选状态同步到请求变量中。稍微复杂一点的场景下在这些条件变化之后。最好直接调用initList由它统一重置所有状态。不要想着手动去清空某一项那样容易漏$(#searchBtn).on(click, function() { listState.keyword $(#searchInput).val().trim(); initList(); });initList这个函数就统一承担了列表重置的职责不需要额外为搜索场景单独写逻辑。这也是我比较推荐的做法。3.5 失败重试与容错接口请求失败这事无论概率多低用户都有可能遇到。我的做法是在报错状态下给用户一个点击重试的入口点击后调用loadData。而loadData里有锁判断但此时isFinished为false、isLoading已经被complete置为false所以重试可以正常进行。为了让这个交互更明显代码结构也需要在setLoadStatus里把错误提示的点击事件绑定好$(#loadMore).on(click, .error-text, function() { setLoadStatus(loading); loadData(); });这属于一个很小的交互但补上它之后列表整体的健壮性会提高一个档次。4. 常见问题与排查技巧实录4.1 页面刚打开时就加载不完第一屏我遇到过很多次这个问题接口数据量不大但就是看不到第二页怎么滑都触发不了加载。排查下来发现原因几乎都出在“列表容器高度撑不开”上哨兵元素被挤到了不可见的位置。排查方法很简单在浏览器控制台执行document.getElementById(loadMore).getBoundingClientRect().top如果这个值大于当前窗口的可视高度说明哨兵不在屏幕内需要调整页面的布局逻辑。也可以给容器设置一个合适的min-height.feed-list { min-height: 400px; }设置min-height之后能保证第一屏在数据没能撑满时哨兵元素已经出现在视口内可以主动触发加载。4.2 快速滚动导致数据重复加载这个场景我在反复测试中碰到过。表面现象是数据没有重复但网络请求里出现连续好几次相同page的请求造成浪费。出现这个问题的根本原因是滚动监听的触发频率远高于数据渲染的速度。我用的解决方案就是前面代码里提到的双重锁isLoading负责在请求过程中拦截canTrigger负责控制前后两次触发之间的最短时间间隔。两把锁叠加之后基本能杜绝重复加载。如果你在体验测试中仍然发现重复数据还有一种可能——后端接口把page从0开始计但前端默认从1开始。这种情况去翻接口文档或者用postman实测一次就能定位。4.3 数据已经全部加载完但滚动条仍出现大量空白区域这个情况通常是加载完成后渲染的DOM高度不够造成的。无限列表在触底后会留出大段空白页面的现象本质上是总数据量太少、不足以撑满页面高度但页面逻辑上已经“加载完”了。处理思路有两个方向。一是给已完成状态设置一个相对明显的视觉提示比如“已经到底啦”区域天然占一定高度二是从布局层面让页面其他部分参与填充避免把单个列表当成整页的唯一内容。我在做某跨平台系统时列表页左侧还有辅助信息栏右侧是详情预览区列表区即使内容加载完了也不会出现刺眼的空白因为整体布局本身是平衡的。4.4 搜索后再继续加载出现了旧数据这个问题的根源在于列表重置时旧数据没有真正清空或者分页数据没有完全归零。尤其在异步场景中之前一页的请求还没返回用户就点了搜索新一页的请求又发出去先返回的旧数据显示在了新页面上。这个场景比较隐蔽也更需要警惕。我采用的方案是加一个请求序列号var requestSeq 0; function initList() { // 每次重置都递增序列号 requestSeq; // ...后续逻辑 } function loadData() { var currentSeq requestSeq; // 在ajax回调里判断当前seq是否仍然等于requestSeq // 如果不相等说明期间列表已经被重置过了丢弃这次数据。 }这个方案绝对可靠彻底堵死了旧请求串位的问题。凡是做列表类功能异步串位问题都必须提前规避毕竟“用户搜索了但列表里混进旧数据”是体验事故级别的问题。4.5 图片列表场景下滚动卡顿问题如果是图片列表且图片直接使用原图加载后会非常卡。通常我是这么处理的缩略图单独给一个尺寸点击后才加载大图同时给图片加上lazyload用原生或第三方库均可。特别是移动端图片尺寸控制在几百KB以内是个硬性要求。这里再分享一个优化细节。使用CSS控制图片的占位尺寸不要等图片本身load完之后再撑开布局。否则列表高度会在图片加载过程中反复跳动用户会感觉到“滚动位置一直在跳”。加上固定宽高比之后就能极大降低这种错位感。4.6 接口性能差导致的触发不稳定实际业务中如果接口平均耗时达到3秒以上前端再怎么优化也不够顺滑。这种情况下我们可以在前端加一个“感应提前量”——提前到距离底部500像素时就触发加载。这样在接口慢的场景下用户滚到底时新一批数据大概率已经加载完成体验会有明显提升。这个参数怎么调我经验值是在200到500之间做测试。先上线默认300再根据接口平均耗时分档调整接口1秒内用1502秒左右用300超过3秒建议上500同时配合loading遮罩。5. 总结与拓展建议无限滚动列表整体实现并不复杂但这套练习对打磨细节非常有价值。我在实际做这类功能时最大的体会就是一个平稳的无限滚动列表绝不是“滚动请求追加”三段式那么简单。需要对同步异步状态、网络边界、渲染性能、用户感知等多方面的细节做综合考量。如果你手上正好有这样一个需求我建议不要急着套现成插件先把底层原理和代码跑透。当你理解了触发时机、数据协议、状态锁这些核心概念之后后续不管是用框架封装成组件还是自己抽象成一个可配置的模块都会顺理成章。另外如果你做的是需要支撑大量数据的场景比如几千条以上的列表可以在这个基础上进一步研究虚拟滚动——只渲染可视区域内的节点。但虚拟滚动是另一个量级的复杂度先不要把步子迈得太大。普通业务场景中无限滚动分批加载的方案在数据量控制在几百到一千条之间时体验可以做到非常流畅。最后分享一个小技巧无限滚动列表上线之后最好在真机上多测几轮尤其是弱网环境。模拟网速调到慢速3G快速滑动列表观察是否出现白屏、加载状态缺失、页码错乱等问题。真实用户遇到最多的问题场景基本就是在弱网里的操作。在开发工具的网络模拟环境里提前测几轮能省掉后续很多线上投诉。