ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端列表页开发全指南:状态机、请求竞态与性能优化实战

前端列表页开发全指南:状态机、请求竞态与性能优化实战 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 TabB 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-virtualizedVue用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然后把手机切到飞行模式再打开这种体验才接近真实用户。我每次发版前都会这么自测一轮改掉了很多测试环境一切正常、用户环境就崩的问题。最后再分享一个小技巧如果列表页使用了缓存策略记得在用户退出或刷新条件变化时清掉非必要的缓存否则缓存的列表数据会越积越多内存占用越来越高长时间使用后页面会越来越卡。这个细节很多代码评审都不会注意到但它决定了长会话场景下的稳定性。列表页是所有前端的基础功但基础功不等于简单。把状态机、数据层、渲染层、交互层、兜底机制这五层都打磨到位了你的列表页才真正算得上实现了。
返回列表