1. 先弄清列表页真正难在哪:看起来是展示,本质是状态机
先说个我自己的经历。前年接了一个移动端H5的货品列表页面,需求文档上就一句话:"做个列表,展示商品,支持下拉加载更多。"当时觉得这活也太轻松了,结果真正开发完、自测、再交给QA跑了一轮之后,我才发现这句话背后藏了一整套状态管理、请求竞态、交互边界和异常兜底的逻辑。列表页是所有前端页面里最典型的"看似简单、实则暗坑无数"的场景,几乎每个前端都会在入职后的第一年里被它上一课。
这个列表页要处理的核心问题其实是四件事:数据从哪来、数据怎么存、数据怎么渲染、用户怎么操作。数据从哪来牵扯接口设计、分页参数、请求时机;数据怎么存牵扯缓存、去重、排序;数据怎么渲染牵扯骨架屏、懒加载、虚拟列表;用户怎么操作牵扯下拉刷新、上拉加载、空态错误态。如果只盯着"把列表渲染出来",那你做的只是一个静态Demo,不是真正能上线的列表页。
我常跟新人说一句话:列表页是一台状态机,不是一块画布。画布只负责把内容画上去,状态机却要在不同状态下切换、记录、恢复。你能把这台状态机设计明白,列表页就成功了一大半。
1.1 列表页必须覆盖的四种基础视觉状态
第一个要明确的是,一个完整列表页至少要有四种视觉状态,缺一个都会在真实使用中出问题:
- 加载中:首屏数据未返回时的过渡,最常见的是骨架屏或loading图标。
- 空状态:接口正常返回但数据为空,比如筛选条件无结果、商品下架清空。
- 错误状态:网络异常、接口报错、超时时显示的失败提示。
- 正常状态:数据渲染完成,用户可以浏览、滚动、点击。
很多实现方案里,错误状态最容易漏。接口挂了直接白屏,或者loading转两秒就闪现一下错误又消失,这些都是我在Code Review里最常见到的问题。更讲究一点的团队还会把空状态拆成"无搜索结果显示"和"列表本身为空"两种,因为它们的文案和引导动作不一样。
设计这四个状态时,还要想清楚一件事:状态之间是怎么转换的?加载中到正常,是数据返回成功;加载中到错误,是请求失败;错误点击重试,又回到加载中。空状态和正常状态之间的切换,则引入了分页、筛选、重置这条链路。状态转换的规则,比状态本身更需要画清楚。
1.2 视觉状态之外的"时间线":分页追加与重置刷新
如果列表只展示一屏数据,那上面的四种状态就够了。但真实的列表都是可以滚动的,这就引出了两条"时间线":
追加时间线:上拉加载更多,data数组不断变长,page递增。这条时间线里要关心的是:当前是否还有下一页、加载更多是不是正在执行中、新数据会不会和旧数据重复。
重置时间线:下拉刷新、切换Tab、改变筛选条件、重新搜索。这些操作都会把列表重置回第一页,重新拉取数据,旧的列表内容要被清空或替换。
这两条时间线最容易碰撞的地方在于:用户下拉刷新的一瞬间,之前的上拉加载请求还没返回。如果不做处理,旧请求的返回结果可能会覆盖新请求的数据,页面就会出现"明明刷新了却显示旧内容"的诡异问题。这个我在后文的数据层会详细展开。
1.3 高频操作下的边界情况
列表页的用户操作频率远高于普通详情页,而且经常是连环操作:快速滚动到底部触发加载、马上又下拉刷新、刷新过程中又点了筛选条件、筛选弹层还没关,请求已经发出去了。
这种场景下,列表页设计不再只是"渲染正确",还要保证"操作安全"。举几个我实际遇到的边界:
- 用户连续快速触发上拉,加载请求被发了三次;
- 下拉刷新的动画还没结束,用户又拖了一次;
- Tab切换后旧请求返回,渲染到了新Tab的列表里;
- 搜索关键字变了,但旧的搜索结果还在页面上残留短暂时间。
这些边界如果不在设计阶段拆出来,等测试阶段再修,往往就只能靠"加个loading锁"之类的临时手段解决,也是很多列表页代码逐渐腐化的重要原因。所以在开工之前,先把状态机和操作边界列清楚,比急着写代码重要得多。
2. 数据层设计:分页、竞态与缓存的完整约定
数据层是列表页的地基。地基没打好,后面渲染层和交互层写得再漂亮,也会在不经意间出现各种问题。我这部分会按"接口约定 → 状态更新 → 请求竞态 → 缓存策略"的顺序,把一套可以复用的方案完整拆开讲。
2.1 先和后端把分页协议钉死
很多列表页的问题并不在前端,而在接口约定不清晰。我建议在写第一行代码之前,先和后端确认好分页相关的几个字段,省得到联调阶段反复改。
一个合理的分页列表接口返回结构大致是这样的:
{ "code": 0, "message": "ok", "data": { "list": [...], "page": 1, "pageSize": 20, "total": 156, "hasMore": true } }关键就看三点:page是当前页码,pageSize是每页数量,total是总条数,hasMore是是否还有下一页。
这里有个特别容易踩坑的点:不要相信前端自己计算hasMore。比如前端判断"返回的list长度小于pageSize就说明没有更多了",听起来合理,但遇到服务端做了过滤、或最后一页恰好等于pageSize时,这个判断就失灵了。更稳妥的方式是让后端返回hasMore,由服务端根据真实数据量判定。
还有一些接口不走页码,走游标分页,返回nextCursor和hasMore。游标分页在数据量大、并发高的场景下更稳定,因为新增数据不会导致页码偏移。但无论哪种,核心约定就一句话:前端不要自己做"是否还有更多"的猜测逻辑,尽量以服务端返回为准。
我实际项目中会做一个小的分页请求封装,大致长这样:
async function fetchPage({ page, pageSize, params }) { const res = await request('/api/list', { method: 'GET', data: { page, pageSize, ...params } }); return { list: res.data.list, total: res.data.total, hasMore: res.data.hasMore, }; }返回结构统一后,上层不管用React还是Vue,调用逻辑都能保持一致。
2.2 状态更新必须用"函数式更新",避免陈旧闭包
列表数据的核心状态就是数组本身,外加加载标识位。很多新手写列表时,直接这样写:
// 错误示范 const [list, setList] = useState([]); const loadMore = async () => { const res = await fetchPage({ page: page + 1, pageSize: 20 }); setList(list.concat(res.list)); // 这里用了当前的 list };这段代码在低频率操作下问题不大,一旦用户快速滚动、多次触发,list是闭包里的旧值,就可能出现丢数据或重复数据。正确的做法是用函数式更新:
const [list, setList] = useState([]); const loadMore = async () => { const res = await fetchPage({ page: pageRef.current + 1, pageSize: 20 }); setList(prev => prev.concat(res.list)); };setList(prev => prev.concat(res.list))这种写法保证每次拿到的都是最新状态,React的批处理机制也不会在并发更新时出问题。类似的,加载标识位我一般也用loadingRef来同步判断,避免状态更新是异步的、判断时机对不上。
这一点看起来很简单,但我见过太多线上列表页的"偶发数据重复",根因其实都是陈旧闭包。养成函数式更新的习惯,能帮你避开一大类问题。
2.3 请求竞态:列表页最大的隐形杀手
请求竞态是列表页数据层里最值得花时间讲的问题。什么叫竞态?就是同一个列表的位置,先后发出了两个请求,但响应返回的顺序和发出顺序不一致,后发出的请求先返回了,先发出的反而后返回,最终页面上渲染的是较早那次请求的数据。
举一个真实场景:用户在A Tab下,列表加载到第3页,这时他切到B Tab,B Tab发起请求。但A Tab的第3页请求还没返回,网络慢,B Tab数据都回来了,A Tab的响应才姗姗来迟,直接把B Tab的列表数据覆盖成了A Tab的第3页内容。
解决这个问题的方案有很多,我选一个工程上最通用、改动成本最低的:请求序号标记法。
let requestSeq = 0; function fetchList(params) { const currentSeq = ++requestSeq; fetchPage(params).then(res => { if (currentSeq !== requestSeq) { // 说明这个请求已经不是最新的了,直接丢弃 return; } setList(res.list); setHasMore(res.hasMore); }); }每次发起新请求前,请求序号加一;响应返回时,只有当序号等于最新序号时才会更新状态。后发起的请求会把之前的请求"判死刑",旧请求的响应自然被丢弃。
除此之外,现代浏览器原生提供的AbortController也能用来取消请求,效果更好:
let currentAbortController = null; async function fetchListWithAbort(params) { if (currentAbortController) { currentAbortController.abort(); } currentAbortController = new AbortController(); const res = await fetch('/api/list', { signal: currentAbortController.signal }); // 处理数据 }AbortController的优点是真正断掉了网络请求,节省了无用流量和服务器压力;序号标记法比较轻量,适合团队还没统一请求层的场景。我的建议是:如果请求层是自己封装的,优先用AbortController;如果是直接用的现成请求库不好改动,就用序号标记法兜底。两条路都能解决问题,本质都是"保证只有最新的请求能写入状态"。
2.4 缓存与会话内数据保留
最后一个数据层问题是:列表切走再切回来,要不要重新请求?重新请求虽然简单,但每个Tab都重新加载一遍,用户的体验感很差,也浪费流量。
我的做法是给列表页设计一个"会话内缓存"的层级:在页面生命周期内,Tab切换时保留每个Tab的数据状态,只把当前激活Tab之外的列表数据存起来,不销毁也不重新拉取。这里有个实现细节需要注意——缓存不是无脑存所有历史数据。
- 如果你有搜索条件、筛选条件,条件变化时应该清掉这个条件对应的缓存;
- 缓存数据过大时,可以只保留前两页的内容,切回来时先展示缓存再静默刷新后续页;
- 如果业务要求数据实时性高(比如库存、价格频繁变化),切回来时最好做一次静默刷新,用缓存撑首屏,请求更新后用新数据替换。
静默刷新的实现很简单:先展示缓存,同时后台重新请求第一页,等到结果返回后替换列表。用户几乎无感知,数据也不会过期。这是我在实际项目里最常用的一种策略。
3. 渲染层:从骨架屏到滚动的性能关键点
数据层解决了"数据怎么存",渲染层要解决"数据怎么画得又快又稳"。很多人觉得渲染就是遍历数组塞JSX或模板,其实列表页的渲染层是前端性能问题的重灾区,特别是长列表和图片密集的场景。这一章我把几个关键优化点按优先级讲一遍。
3.1 骨架屏不是装饰,是首屏体验的保底方案
骨架屏刚流行的时候,很多人觉得它只是"看起来好看",实际没啥用。我自己在优化列表页性能时才发现,骨架屏最重要的作用是防止页面跳动和缩短用户感知等待时间。
用户点击进入列表页的那一瞬间,如果屏幕上什么内容都没有,用户大脑会进入"等待模式",哪怕只等了500ms,也会觉得卡顿;但如果在首屏出现一个和真实布局一致的灰色骨架,用户会产生"页面在加载"的心理预期,等待600ms也不会觉得慢。
具体实现上,骨架屏不要整页一个灰色块,而是尽量贴近真实列表结构。比如商品列表,每个列表项就是一个方形图区域加两行文字条;信息流列表,就是一个圆形头像加三行文字条。颗粒度越细,视觉过渡越平滑。
技术实现:最简单的方案是纯CSS抖动块,复杂一点可以用Base64图片或SVG。我建议不要为了骨架屏去引入重型骨架屏库,纯CSS动画就足够:
.skeleton-item { background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 37%, #f0f0f0 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; } @keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }这个动画就是让一个浅灰渐变色块反复移动,模拟"加载中"的动态感。不用JavaScript,不耗CPU,也不影响外面渲染。
3.2 列表项key必须稳定,这是渲染性能的隐形开关
列表渲染还有一个基础但影响巨大的点:key的使用。React、Vue在渲染列表时都会要求给每一项一个key,这个key的作用是让框架追踪每个节点的身份,从而做最小化DOM更新。
key千万不要用数组下标。我第一次做列表页时也这么写过,直到发现了诡异的Bug:列表前插了一条数据后,所有项的选中状态全乱了。原因是key绑定下标后,框架认为每一项的身份没变,只是内容变了,导致带状态的子组件没有重新创建,而是被复用到了错误的数据上。
正确的key应该选业务唯一ID,比如商品的id、订单的orderNo。如果后端没有返回唯一ID,可以在数据进入前端时先补一个:
const withKey = list.map((item, index) => ({ ...item, _key: item.id || item.code || `${index}_${Date.now()}` }));但要记住,运行时生成的key要尽量稳定。不要用Math.random()生成key,否则每次渲染都会重新创建所有节点,性能反倒更差。
key选对了,列表项的复用率会大幅提升;key选错了,你后面做的所有列表渲染优化都会事倍功半。
3.3 图片懒加载:列表页首屏提速的最大功臣
图片是列表页体积的大头,一张没压缩的图2MB起步,一百个列表项就是200MB的待加载数据。懒加载的意义不只是省流量,更重要的是首屏渲染时间。
懒加载的标准实现是IntersectionObserver,它可以在图片进入视口时才真正加载。相比传统监听scroll位置计算偏移的做法,IntersectionObserver不需要在主线程上跑大量计算,性能开销小得多。
const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '100px 0px' }); document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));这里rootMargin: '100px 0px'的意思是视口往下100px就开始加载,做一个预加载缓冲,避免滚动到图片位置才加载导致的闪烁感和卡顿感。
实现懒加载的时候有几个容易踩的坑:
- 占位防抖动:图片没加载完时必须有一个固定宽高的占位容器,否则图片一个个加载就位时,页面布局会不断跳动,滚动位置也会跟着闪。
- 失败重试:图片加载失败时,要给一个占位错误图,不然会显示一个破损的图标,体验极差。
- CDN图片拼接:多数公司用CDN图片时,会在URL跟上尺寸参数,缩略图用200px宽,点击大图时再加载原图,这一操作能让前几屏图片体积缩小好几倍。
3.4 什么时候才需要虚拟列表
虚拟列表是列表页性能优化的"重型武器",但也是滥用最多的方案。虚拟列表的原理是只渲染视口内的节点,滚动过程中动态替换渲染内容,让数百上千条的列表在DOM里始终只有几十个节点。
如果你的列表页数据量级在100条以内,或者列表项高度很高导致总共只有两三百像素的滚动区域,那完全不需要虚拟列表,用了反而是过度设计。但如果是以下场景,虚拟列表几乎是必须的:
- 消息记录、日志列表,数据量几千条起步;
- 每行列表项比较矮,一屏内几十条节点;
- 列表项本身包含复杂子组件,渲染成本高;
- 需要高性能滚动的场景,比如iOS Safari上滚动卡顿明显。
虚拟列表的实现方案推荐直接使用成熟库,React用react-window或react-virtualized,Vue用vue-virtual-scroller,不建议手写。手写虚拟列表要处理滚动偏移、可视范围计算、缓冲条数、动态高度测量,复杂度很高,而且动态高度列表的虚拟化是公认的难题,自己写的很容易在边界场景翻车。
在引入虚拟列表之前,可以先做一次评估:图片是否已懒加载?列表项组件是否太胖导致渲染成本高?key是否稳定?这些前置优化如果都没做,虚拟列表只是掩盖了长期积累的性能债。
4. 交互层:下拉刷新、上拉加载与状态联动的落地细节
数据层和渲染层做完,列表页已经能跑、能看了,但真正决定用户体感的是交互层。交互做得好,用户不会觉得特别惊艳,但交互有瑕疵,用户会非常明显地觉得别扭。这一章讲的都是实操细节,每个都是我踩过的坑。
4.1 下拉刷新:三个层次的实现选择
移动端列表页的标准操作里,下拉刷新几乎是标配。实现方式从简单到完整有三个层次:
第一层:伪下拉,按钮触发。页面顶部一个刷新按钮,点击后重新加载。这是最简陋的方案,开发成本最低,但用户没有"下拉"这个交互,体感比较差。
第二层:利用scroll事件判断。监听滚动事件,滚到顶部继续下拉时,显示一个下拉区域。这个方案的手势判断比较复杂,要处理元素回弹效果。
第三层:使用成熟下拉刷新组件。移动端组件库(Vant、Ant Design Mobile等)都内置了PullRefresh组件,直接配置即可。这是我最推荐的方式,因为下拉刷新的手势判定、临界值、回弹动画都有很多细节,自己写很容易出"下拉卡顿""回弹错位"的问题。
如果业务要求自己实现,有几个核心参数需要关注:下拉距离阈值、到达阈值后松手触发的刷新、刷新过程中的Loading文案、刷新完成后的回弹动画。这里最容易被忽略的是刷新完成后列表数据的替换逻辑。很多人直接setList(newData),但万一新数据比旧数据短,列表高度就会瞬间收缩,用户正看着中间位置,一下子被弹到顶部,体验非常差。正确的做法是刷新前记录列表的滚动位置和当前浏览的itemID,刷新后尽量恢复相近位置,或者至少保证列表高度平滑过渡。
4.2 触底加载:判定方式与防重保护
上拉加载更多是列表页的又一大交互核心。触发时机是"用户滚动到接近底部",实现上有两种常见判定:
// 方案一:scroll监听 + 距离判定 window.addEventListener('scroll', () => { const docHeight = document.documentElement.scrollHeight; const scrollTop = document.documentElement.scrollTop; const windowHeight = window.innerHeight; if (docHeight - scrollTop - windowHeight < 80) { loadMore(); } });这种方案有一个问题:scroll事件触发频率非常高,如果不做防抖,loadMore会被连续调用。我见过的最严重案例是用户快速滚动时一次发了七八个请求,导致列表数据翻倍。
最佳实践是用IntersectionObserver监听一个底部占位节点,当这个节点进入视口时触发加载:
const sentinelRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !loadingRef.current && hasMoreRef.current) { loadMore(); } }); if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); }, []);在列表底部放一个高度为1px的占位节点,当这个节点出现在视口里,说明用户已经快滚到底了,就触发加载。这种方案不用自己算滚动位置,不需要防抖,也不会漏触发。
但光靠IntersectionObserver还不够,还需要一个加载锁。当加载请求发出后,loadingRef.current置为true,等请求返回后再置为false。这就是我在数据层说的"函数式更新+Ref"组合,判断是否加载更多用Ref,更新列表数据用函数式更新,两者配合才能既防重复请求又不出旧数据覆盖问题。
4.3 筛选、排序、搜索的状态联动
列表页一旦加上筛选、排序、搜索条件,状态联动就开始变复杂了。我见过最典型的报错现象是:用户选了筛选条件,数据正常更新;再选第二个条件,数据却还是老的。原因往往是没有在条件变化时重置分页参数。
正确的联动规则是:任何一个筛选条件、排序方式、搜索关键字变化,都把页码重置为1,清空现有列表,重新请求第一页。
function handleFilterChange(newFilter) { // 重置分页 pageRef.current = 1; setFilter(newFilter); setList([]); // 清空旧数据 setHasMore(true); requestFirstPage({ filter: newFilter, keyword: keywordRef.current }); }这里还有一个细节:请求参数的获取要用Ref或state的当前值,不要在事件回调里直接用旧闭包。如果筛选条件和关键字互相影响,建议把它们合并成一个query对象管理,组件只消费query变化后的结果。我一般会把这些筛选条件统一放进URL query参数,这样做的好处是页面刷新时筛选状态还能保留,分享链接时也能带上筛选上下文。
4.4 滚动位置恢复:离开再回来不能白屏
列表页还有一个高频需求:用户从列表点进详情页,看完返回,列表应该保持原来的位置。如果不做处理,返回时会重新从顶部开始,用户想继续看第50条,还得重新滑回去。
最原始的做法是存储scrollTop,返回页面时手动赋值。这个方案在组件卸载后重新挂载的场景下也有效,但需要自己在合适的时机存和恢复。更好的做法是利用前端路由的ScrollBehavior,比如Vue Router的scrollBehavior(to, from, savedPosition)方法:
scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition; // 返回之前的滚动位置 } return { top: 0 }; }如果你们的列表页做了缓存处理(比如Vue的keep-alive),那么页面状态和滚动位置天然保留,不用额外处理。但要注意,缓存可能导致列表数据过期,所以配合我数据层讲的"静默刷新"策略最稳妥:保留旧位置和旧数据,后台更新,更新完成后待用户滚动时才替换到新数据。
5. 兜底机制:空态、错误态与异常边界的工程化配置
列表页的"正常人"路径走完,最后要处理的是各种异常路径。这些场景虽然不常见,但一旦触发,用户感知往往是最强烈的。我见过太多列表页在弱网环境下的表现堪称灾难:要么一直转圈,要么白屏,要么直接报错。这部分讲的就是兜底机制的工程化配置。
5.1 空状态:至少要区分"没有内容"和"没有结果"
空状态不是简单放一张"暂无数据"的图就完了。结合业务场景,空状态至少要区分两类:
- 初始无内容:这个列表本来就没有任何数据,比如新用户的消息列表、空的收藏夹。文案应该是引导性质的,比如"去逛逛""去添加第一个收藏"。
- 筛选/搜索无结果:列表本来有数据,但筛选或搜索条件下查不到。文案应该是建议修改关键词或清除筛选。
这两种空状态的按钮和引导逻辑完全不一样,共用一套模板只会让用户困惑"明明收藏过东西,为什么显示暂无收藏?"
空状态组件本身我建议做成可配置的通用组件,传入icon、title、description、buttonText、onButtonClick几个属性,所有列表页复用。这样即使你们的PM改十遍文案,前端也不用挨个页面去调整。
5.2 错误状态:统一的失败提示与重试链路
接口报错时,列表页最常见的处理是弹一个Toast然后什么都不做,或者干脆白屏。我经历过一次生产事故,某个列表接口服务端超时,线上用户看到的就只有一个空白页面,没有任何提示,当时排查了很久才定位到是接口超时。
正确的错误处理链路是:请求失败 → 展示错误状态组件 → 用户点击重试 → 重新发起请求。如果页面里已有旧数据,比如下拉刷新失败但旧列表还在,那就不能清空页面,应该保留旧列表并弹Toast提示"刷新失败"。
另外,API请求层最好统一做一个封装,列表页的错误处理不要自己在组件里写。
try { const res = await fetchPage(params); // 正常处理 } catch (error) { setListError(error); }setListError之后根据错误类型展示不同的错误提示文案,比如:
| 错误类型 | 提示文案 | 操作按钮 |
|---|---|---|
| 网络异常 | 网络开小差了,请检查网络设置 | 重试 |
| 超时 | 请求超时,请稍后重试 | 重试 |
| 服务端错误 | 服务器繁忙,请稍后再来 | 重试 |
| 权限不足 | 暂无查看权限,请联系管理员 | 返回首页 |
5.3 弱网和断网:列表页最容易被忽略的体验陷阱
移动端网络状况复杂,弱网和断网是列表页绕不开的场景。这里的"弱网"不是3G信号差那么简单,而是用户在地铁里、电梯里、地下停车场里打开App的场景。我实际处理过的弱网有两种典型表现:
- 请求长时间无响应:用户干等着,列表页一直转圈,没有任何反馈。
- 请求秒失败:网络切换(Wi-Fi切4G)或者信号弱,请求立即报错。
对第一种情况,建议前端加一个全局的请求超时控制,比如10秒或15秒。超过时限还没返回就直接进入错误状态,不要让用户无限等待。对第二种情况,除了错误提示之外,还可以增加一个visibilitychange监听,当页面重新获得焦点时自动重回到刷新状态。
还有一个和网络状态强相关的操作:用户断网状态下点击列表项。很多列表页在这个场景下没有反馈,用户以为是页面卡住了。应该在列表项的点击处理函数里做统一拦截,断网时Toast提示"当前网络不可用",而不是直接跳到详情页然后白屏。
5.4 埋点与线上问题追踪
最后讲一个很多团队不重视但线上排查必备的机制:列表页的埋点。列表页是用户高频页面,也是最容易出线上问题的页面,如果没有埋点数据支撑,出bug时排查成本极高。
我建议每个列表页至少埋以下几类事件:
- PV/UV:页面访问的基础曝光数据。
- 请求失败:接口报错、超时的场景,记录错误码、错误信息、接口地址。
- 渲染异常:列表数据渲染报错未捕获的异常。
- 性能指标:首屏渲染完成时间、接口返回耗时、图片加载失败数量。
- 用户交互:下拉刷新次数、上拉加载次数、筛选条件使用频次。
有了这些埋点之后,线上出问题时可以直接在数据平台检索,看到底是哪个接口失败率高、哪个机型性能差、哪个筛选条件组合查不到数据。做列表页优化时也有数据依据,而不是凭感觉判断。
这里还有一条经验:埋点不要只埋用户侧的成功事件,更要埋失败事件和静默失败事件。静默失败指的是页面看起来显示正常,但数据其实不是最新的,比如请求失败但前端没报错、缓存兜底了。这种问题用户不主动反馈你可能根本不知道,只能靠埋点里的异常日志蛛丝马迹查出来。
把我这些年写列表页的经验浓缩成最后几点
实践出真知,这些经验是我在多次迭代和踩坑后沉淀下来的,分享几点核心体会。
第一,列表页的架子要在第一天就搭好。状态机、网络层、错误处理、缓存策略,这些不要等页面出了问题再补。一开始就按完整链路设计,后面每个新列表页都只是复制这套模式,成本反而最低。
第二,封装一个统一的useList或useInfiniteList组合式函数。把我在数据层讲的请求竞态、函数式更新、加载锁、缓存这些逻辑全封装进去,业务组件只需要提供获取数据的函数和列表项渲染函数。我目前所在的团队,所有列表页都共用这一套封装,新功能开发效率至少提升了一倍,线上问题也明显变少了。
第三,在"够用"和"过度设计"之间找到平衡。100条以内的列表不要上虚拟列表,单页场景不需要下拉刷新,简单数据展示也可以不搞骨架屏。但请求竞态处理、错误兜底、空态区分这些底层保障,不管列表多简单都应该保留。
第四,重视弱网和异常环境的自测。Chrome DevTools里的Network面板把网速调成Slow 3G,然后把手机切到飞行模式再打开,这种体验才接近真实用户。我每次发版前都会这么自测一轮,改掉了很多"测试环境一切正常、用户环境就崩"的问题。
最后再分享一个小技巧:如果列表页使用了缓存策略,记得在用户退出或刷新条件变化时清掉非必要的缓存,否则缓存的列表数据会越积越多,内存占用越来越高,长时间使用后页面会越来越卡。这个细节很多代码评审都不会注意到,但它决定了长会话场景下的稳定性。
列表页是所有前端的基础功,但基础功不等于简单。把状态机、数据层、渲染层、交互层、兜底机制这五层都打磨到位了,你的列表页才真正算得上"实现"了。