
先讲一个发生在我自己项目上的真实需求移动端商城首页商品列表滚了三屏用户滑到第五屏点了一个商品进详情返回后首页直接从顶部重新开始。用户当场就懵了他得重新回忆刚才在看哪一档价格、对比到哪个商品运气好滑回原位运气不好浏览链路直接断掉。这个需求看起来不大但上线后连续几天出现在客诉列表里产品同事拿着用户反馈找我对账我才意识到“返回原位置”可能是移动端 H5 里最容易忽略、又最能摧毁体验的细节。这篇文章不聊脚手架怎么搭也不聊列表组件选型专注讲清楚一件事移动端长列表在用户进入详情再返回时怎样把滚动位置记得住、找得回、对得准。不管你是用 React、Vue 还是原生 JS 写长列表只要被“回列表回顶部”这个问题困扰过这篇应该能给你一条直接落地的路线。1. 一个上线前觉得不值一提、上线后被用户反复投诉的需求1.1 用户视角的完整路径把用户的完整路径拆开你会发现问题比想象中复杂。用户进入商品列表后通常不是一口气看完而是反复浏览、比较、点开详情再返回继续滑动。每一次“详情 → 返回 → 继续浏览”都是对列表位置信息的一次考验。如果返回后列表重置到顶部用户被迫做三件事先确认“我原来在哪”再回想“我看到了哪个商品”最后重新滑动寻找。这个认知负担在天热、地铁晃、单手操作的环境下会被无限放大。真实场景里大部分用户的反应不是耐心找回而是直接退出 App 或者关掉页面顺手给个差评。我做这个需求之前一直以为“返回原位置”是个体验优化项结果被用户投诉之后才知道在某些业务里它等于核心可用性。商品详情、搜索结果、消息列表、订单列表、短视频信息流凡是“列表 → 子页 → 回列表”的路径一旦位置丢了前面的浏览行为相当于白做。1.2 为什么原生 App 默认就有H5 却要单独做原生端的导航栈设计里页面实例在压栈之后并没有销毁滚动位置天然被保留。iOS 的 UIScrollView、Android 的 RecyclerView 状态都跟着页面实例走返回时直接把上次的 offset 抖回来就行开发几乎不用额外写代码。但 H5 不一样。H5 的页面生命周期是“随开随建、随关随毁”的。SPA 路由切换时上一页的 DOM 和组件实例大概率被销毁滚动容器里的 scrollTop 跟着一起消失。如果用了虚拟列表问题更严重虚拟列表为了性能只渲染可视区域附近的一小部分 DOM 节点你滑过的那些条目早就从文档里摘除掉了浏览器层面根本没有“滚动位置”可言。所以 H5 的“返回原位置”本质上是需要开发者自己实现的一套状态恢复机制而且要在多个层面做补偿后面我会详细展开。1.3 到底要做到什么程度才算“完成”这个需求上线之前我和产品对“完成”的标准拉扯了好几轮。一开始产品以为“回到上一次的滚动距离”就行我用一根手指滑了几下就被打脸列表里插入了几条新数据距离对了内容全偏了。结合这次经验我建议把“返回原位置”分成三个等级来验收等级体验描述验收标准L1位置近似回到列表的某一个分页 / 大约区域L2条目对齐用户此前看到的第一个可视条目仍在视线内L3视觉一致条目对齐之外偏移距离也几乎一致不闪现、不跳页做移动端长列表我建议直接把 L3 当默认目标否则用户打开详情再回来就算位置不远视觉上的位移也会让人心里发毛。真正的“完成”是用户根本感觉不到这次返回有发生过。2. 滚动位置恢复失败的底层机制列表的“头”被拆掉了要解决一个问题先得搞清楚它为什么难。我把它拆成了三个层面每个层面都对应一种“重置之手”而且这三只手经常同时发力。2.1 第一只手SPA 路由切换导致列表组件卸载移动端 H5 基本都是单页应用。从列表页进入详情页时路由变化会让列表组件从渲染树上被摘下来组件里一切本地状态包括scrollTop、已加载页数、滚动容器的引用都会随之销毁。有些项目喜欢用v-show或者 CSS 隐藏详情页、把列表页保持挂载这能省掉一部分组件销毁的成本。但你的详情页往往也是一个完整路由它需要参与浏览器的前进后退历史一旦 history 记录变化列表页该卸载还是会卸载只是时间早晚的问题。即使列表组件碰巧还挂在树上浏览器为了渲染详情页也会把列表滚动容器从可视区域挪走等返回时再挪回来中间是否保留滚动位置完全看浏览器心情。所以第一个结论是H5 里不能依赖组件存活来保住位置。2.2 第二只手虚拟列表把 DOM 节点“局部删除”了长列表的另一种形态是虚拟列表——比如react-window、rc-virtual-list或者自研的可视区渲染 占位符。虚拟列表核心逻辑是只渲染可视窗口上下少量条目窗口外的 DOM 节点直接从文档里拔掉用空白占位或者动态撑高来模拟完整列表高度。这个机制带来了一个致命问题返回列表时即使你还记得上一次的scrollTop 38000px滚动容器找到这个位置后发现那个位置是空的因为对应条目的 DOM 还没渲染出来。你必须告诉虚拟列表“把某个索引 / 某个 key 的条目渲染出来”然后再把滚动位置对齐过去否则坐标就是空气。所以虚拟列表场景下“滚动位置恢复”本质上变成了“数据定位问题”而不是“像素赋值问题”。2.3 第三只手浏览器自带的滚动恢复能力为什么不够用浏览器不是没有给过原生方案history.scrollRestoration这个 API 就是给页面返回时恢复滚动用的。它有两种取值auto表示浏览器你帮我恢复manual表示我自己处理。听着很美好用起来全是坑。首先它只对“浏览器前进后退”真正生效而且通常恢复的是window或body的滚动位置。如果长列表使用的是一个内部的滚动容器比如div classNamescrollbox很多 WebView 并不会帮你恢复内部容器的滚动值。其次移动端 WebView 对页面的缓存策略非常激进。iOS WKWebView 和部分 Android 内核在内存紧张时会把后退缓存里的页面直接回收掉返回时收到的是一次重新加载而不是一次页面还原此时scrollRestoration等于空谈。还有一个常被忽略的点图片懒加载导致的布局抖动。浏览器恢复坐标时页面上图片还没完全加载视口高度撑不起来恢复的 offset 会整体漂移。你给我一个auto我能用出事故的效果来。三层机制叠在一起结论很明确长列表的返回原位置必须要用业务层的快照和恢复逻辑来兜底。3. 我试过的五种“看起来能行”的方案与翻车点方案不是没试过而是一个一个试完才走到今天这套做法的。我把踩过的坑按“实现成本从低到高”列一下你看看有没有自己也踩过的。3.1 方案一内存变量保存 scrollTop这是最本能的思路在列表页定义一个let latestScrollTop 0监听滚动事件往变量里写返回列表时在onShow或useEffect里scrollTo(0, latestScrollTop)。普通列表数据量不大、DOM 节点全量渲染这个方案是能跑的。一旦列表变成虚拟列表就立刻翻车——因为虚拟列表渲染的节点永远只有可视区附近那十几个你scrollTo到38000px的时候那个位置的条目根本不在渲染范围内滚动容器直接停在一个空白区域。Vue 项目如果用了keep-alive能保住组件但虚拟列表照样恢复不到真实内容上。内存变量还有一个致命问题强刷新、WebView 回收、页面切换时内存就没了。移动端 WebView 的后台回收几乎随时发生用户锁屏放口袋里两分钟再回来内存里的latestScrollTop已经归零。3.2 方案二scrollRestoration 浏览器回退兜底我一度觉得是前面用错了 API于是专门去调history.scrollRestoration把页面设为manual自己监听popstate来恢复。实测发现这个方案在 iOS Safari 上表现尚可但在微信 WebView、Android 内嵌 H5 里行为很不一致。有的内核返回时恢复的是整个 window 的滚动根本不理会你内部容器的位置有的内核压根不触发恢复给你一个空页。scrollRestoration 更适合“一个页面只有一个滚动主体”的文档型场景比如长文章页。对于内部滚动容器 虚拟列表 SPA 这种组合它属于“知道有 API 但用不上”的情况。3.3 方案三锚点 URL scrollIntoView有人会想到给列表项加上id返回时用location.hash #item-342然后scrollIntoView()滚过去。这个方案有几个坑。第一虚拟列表下锚点目标元素可能还没渲染scrollIntoView找不到目标。第二列表是动态的前端筛选后id可能发生变化或者目标 item 在最新数据里已经不存在了。第三时刻带着一个 hash 在 URL 里路由管理会变得很脏详情页返回时还可能触发额外的浏览器滚动行为。如果你面对的只是一个静态、完全由后端一次性返回的列表锚点勉强能用。但在移动端长列表 分页 筛选的环境里这方案基本是给下一个维护者埋雷。3.4 方案四scrollTop 全量扔 localStorage比内存变量进步一点把scrollTop存到 localStorage刷新也能保住听起来能应对更多场景。实际上localStorage 的读写是同步的每次滚动节流写入都会卡一下 UI列表滚动本来就流畅性敏感加了这个操作肉眼可见掉帧。更大的问题是脏数据用户上一次在“分类 A 关键词 XXX”下浏览到 38000px下一次从“全部列表”返回拿着 38000px 直接设过去页面刷地一下跳到完全无关的区域体验比不恢复还差。localStorage 适合保存“跨浏览会话仍然有效的偏好”不适合保存“和当前列表上下文强绑定”的瞬时位置。3.5 方案五只在路由里回传页码这个方案是产品提的详情页返回时在路由参数里带一个page3列表页据此截取到第三页。它能恢复“位置近似”但对“条目级对齐”无能为力。真实场景里用户滚到第三页的四分之三处你只回第三页开头视觉上差了大半屏更麻烦的是如果列表数据在用户离开期间更新第三页开头的内容已经变了用户还以为自己进错了页面。这个方案可以作为降级兜底但不能当主方案。各种方案试下来我的体会是凡是只保存“像素数字”的方案都会在数据变化和虚拟列表面前失效。真正要靠的是“把位置变成一个带上下文的标记”也就是下一节讲的内容。4. 可靠方案把“一个数字”升级为“一个上下文”4.1 核心思想用锚点条目替代像素坐标如果只记一个数字你就被迫在像素层面解决问题如果把位置理解成“用户此刻停在哪个条目、这个条目距离视口顶部多远、当前列表的筛选条件是什么”你就会在一个更稳固的层级解决问题。打个比方你要告诉一个朋友你站在哪里不能说“我在东经 116 度北纬 40 度的第 37500 米处”因为这条路会随着地图版本变化而漂移。你应该说“我在第三个红绿灯右转后第五根路灯下面”这个描述里的“路灯”就是锚点就算整条路重新铺过只要路灯编号还在你依然能找到。列表场景里锚点就是“用户返回时视口顶部附近那个可见条目”。我们把条目的唯一 key 记下来再记下它当时距离视口顶部多少像素恢复时先定位到这个条目再精确把偏移量补回来。这样无论列表数据增删改只要这个条目还在数据里位置就不会跑偏。4.2 快照数据的结构设计落到代码上一份合理的快照结构长这样{ sceneKey: mall-home-item-list, filterKey: category3tabrecommendkeyword空调, anchorItemKey: sku-29384, anchorOffset: 120, scrollTop: 38200, dataVersion: 20240713-2, savedAt: 1720857600000 }逐个字段说下我为什么这么设计sceneKey页面标识避免多个长列表页面互相覆盖快照。商城主页的信息流和订单列表页肯定不能用同一份快照。filterKey列表“身份”。筛选、排序、搜索词、tab 切换任意一个发生变化当前位置就失去了对齐基础不能互相恢复。anchorItemKey核心字段保存的是视口上方附近的某一项而不是顶部第一个。顶部第一项很容易因为下拉刷新、顶部插入一条推荐而变动取视口中段偏上一点的条目会稳很多。anchorOffset锚点条目顶部到视口顶部的像素距离恢复后用来做精细补偿。scrollTop降级字段。万一锚点条目已经不在新数据里也可以用这个近似坐标准入至少别直接落在顶部。dataVersion记录快照生成时的数据版本返回后若数据版本对不上按策略决定是否放弃恢复。savedAt时间戳方便做快照过期判断。不要用这套结构去存整个 dataSource 本身。列表数据动辄几十 KB放进本地存储既占空间又会拿到过期数据毫无必要。快照只记录“定位信息”不记录“内容”。4.3 恢复流程的两步式对齐有了快照结构恢复流程就有了明确的两个阶段。第一阶段是“定位”。返回列表页后先根据filterKey判断当前列表和快照是不是同一个上下文。如果一致就尝试让虚拟列表把anchorItemKey对应的条目渲染出来。你可以把它理解为“先让目标节点在 DOM 里长出来”。第二阶段是“对齐”。拿到锚点条目渲染出的真实 DOM读取它的offsetTop然后用scrollTo(anchorTop - anchorOffset)把滚动容器对齐过去。这一步只需要一次赋值不依赖你之前记住的 scrollTop。我建议用两帧来校正第一帧在数据渲染完成后立即执行确保入口直接出现在目标区域第二帧在图片加载、列表高度稳定后再执行一次微调。因为长列表里图片未加载时高度是近似值第一次对齐可能偏了几像素到几十像素第二帧能修正这个偏差。4.4 存储与生命周期选用 sessionStorage存哪里我最终选了sessionStorage而不是内存变量也不是 localStorage。sessionStorage 的生命周期跟“标签页会话”绑定用户在当前标签页里的返回操作天然都还在这个会话内。页面强刷新后 sessionStorage 通常能保留WebView 被整体杀掉再重建时它清空但那大概率是冷启动原本的会话本身就结束了。它的主要好处是不会影响其他标签页、不会把旧数据一直焊死在 localStorage 里等下一次访问用。内存变量可以作为快照的补充缓存。如果你在同一个 SPA 里做路由跳转内存读取比重读 sessionStorage 更快可以在内存里放一份latestSnapshot返回时优先用内存内存没了再回退去读 sessionStorage。localStorage 的问题前面提过脏数据会一直存在本地和当前列表上下文不匹配的概率很高而且同步写入影响滚动性能我没有把它作为主存储。5. 移动端 React 项目落地从快照写入到虚拟列表对齐方案说清楚了我们来落地。我以 React 虚拟列表为例把完整链路写一遍Vue 项目思想完全一致只是换 API。5.1 离开页面时把快照写下来快照必须在离开列表页之前写入不能等到返回时再猜。写快照的时机有两个一是用户点击条目跳详情二是路由即将离开列表页。我封装了一个 hook叫useScrollRestoration它负责在跳转前捕获当前滚动状态function useScrollRestoration({ sceneKey, getFilterKey, containerRef, getAnchorItem }) { const saveSnapshot () { const container containerRef.current; if (!container) return null; const scrollTop container.scrollTop; // 找视口内中上方的可见条目作为锚点 const anchor getAnchorItem(scrollTop container.clientHeight * 0.3); if (!anchor) return null; const snapshot { sceneKey, filterKey: getFilterKey(), anchorItemKey: anchor.key, anchorOffset: container.scrollTop - anchor.top, scrollTop, dataVersion: getCurrentDataVersion(), savedAt: Date.now(), }; sessionStorage.setItem(scroll:${sceneKey}, JSON.stringify(snapshot)); }; return { saveSnapshot }; }这里getAnchorItem是在虚拟列表里根据像素位置算出来的用的是可视区域内相对靠上的条目而不是最顶部那个。anchorOffset的计算方式是“当时容器 scrollTop 减去这个条目的内容区 top”也就是锚点条目顶部到视口顶部的距离。跳转时调用saveSnapshot()比如在商品卡片的 click handler 里或者在路由 layer 观察离开事件时。5.2 回到列表页虚拟列表怎么找到锚点返回列表时React 组件重新挂载数据重新加载虚拟列表开始渲染。我们必须告诉虚拟列表“先别从第 0 项渲染从 anchor 附近开始”。我的虚拟列表是自己实现的也可用react-window的initialScrollOffsetitemKey定位核心代码useEffect(() { if (!listReady) return; const snapshot loadSnapshot(sceneKey); if (!snapshot) return; if (snapshot.filterKey ! currentFilterKey) { clearSnapshot(sceneKey); return; } // 找到锚点在数据源里的位置 const anchorIndex listData.findIndex(item item.id snapshot.anchorItemKey); if (anchorIndex 0) { // 第 1 步让虚拟列表直接渲染锚点条目 virtualListRef.current.scrollToItem(anchorIndex, { align: start, preloadCount: 10, // 锚点上方保留一些渲染条目避免滚动时闪白 }); } else { // 第 2 步锚点不存在回退到近似坐标 virtualListRef.current.scrollTo(snapshot.scrollTop); } // 第 3 步渲染完成后再对齐偏移量 requestAnimationFrame(() reconcileOffset(snapshot.anchorOffset)); }, [listReady]);scrollToItem是虚拟列表里“按 item 索引滚动”的 API。preloadCount是给锚点上方多渲染一些节点否则恢复后往上滑会突然出现大量空白加载区。这里有个关键点listReady不能是“列表刚开始渲染”的状态而必须是“列表数据已经拉到、并且分页已经包含锚点条目”的状态。如果锚点条目在第 3 页而返回时只加载了第 1 页就得先等第 3 页的数据回来否则找不到 anchorIndex。5.3 动态高度带来的微调长列表里每个 item 的高度往往不固定图片未加载时高度会跳。所以我用requestAnimationFrame做二次对齐function reconcileOffset(offset) { const itemEl getItemElementByKey(snapshot.anchorItemKey); if (!itemEl) return; const container containerRef.current; const itemTop getOffsetTop(itemEl); // 相对滚动容器内容顶部 const targetTop itemTop - (offset || 0); container.scrollTop targetTop; }第二次对齐要做在图片加载完成之后。简单做法是拉一个requestIdleCallback或者监听图片加载事件后节流执行一次。如果 item 高度因为图片加载膨胀了 40px第二次对齐会比第一次精准得多。5.4 回退策略数据对不上时怎么保护体验线上数据随时会变快照里的锚点条目可能在下一次返回时已经不存在了。这时候我不建议硬找而是按以下优先级处理锚点条目存在走完整对齐。锚点条目不存在但 filterKey 一致用scrollTop近似准入再小范围检查该位置附近是否可见合理内容。连 filterKey 都对不上直接丢弃快照回到列表顶部。这里有个容易被忽略的细节如果锚点条目不存在但列表新增了 5 条数据用户原本在第 25 条附近新数据把它顶到第 30 条按scrollTop恢复其实蛮合理的因为同样的像素值对应的信息密度差不多。真正要防的是“筛选条件变了还要硬恢复”和“数据整个换源还硬恢复”。6. 哪些场景会让恢复失效线上踩过的三种“污染”方案设计得再好线上环境还是有无数的意外。我把自己踩过的场景梳理成三类你可以照着对照自己的列表页。6.1 顶部插入新数据IM / 直播场景的锚点漂移如果你做的是消息列表、直播弹幕流、动态 feed列表顶部会不断插入新内容。这时候保存scrollTop是彻底的灾难回来之后按原来的像素滚动用户看到的已经是他下滑之后几屏之外的内容了。正确的做法是我在前面提到的“锚点条目”方案。在 IM 场景用户离开时看到的第一条消息 key 才是锚点返回时无论顶部插了多少条只要锚点消息还在就能滚到“当时看到的那条消息”附近。不要存绝对坐标存相对标识。6.2 筛选条件变化恢复到了另一个列表很多列表页有 tab 切换和筛选项。用户可能在“热销榜”下滚到第五屏然后点进某个商品返回时如果默认 tab 已经切回“推荐”你还拿着“热销榜”的锚点去恢复结果就是跳到完全不相干的位置。我的方案里靠filterKey做了拦截。每次保存快照都会带上完整的筛选上下文返回时如果filterKey不一致我宁可把快照丢进回收站也不拿去匹配。看起来好像损失了一次恢复机会实际上避免了“恢复错位置”带来的二次伤害。如果你确实想给每个筛选条件都保留位置可以把快照的 storage key 改成scroll:${sceneKey}:${filterKey}按筛选条件分别存返回时取当前条件下的快照。6.3 服务端数据和本地快照的版本落差有一次我们线上列表接口做了改版商品 ID 体系整体更换旧的快照里sku-29384已经不存在了。用户旧版本 App 里退出再进页面拿着旧 ID 找不到锚点触发了我写好的回退逻辑直接丢快照回顶部体验页不会崩但损失了一次精准恢复。更隐蔽的是分页数据差异用户离开时已经加载到第 3 页返回时如果网络慢数据只重新拉回第 1 页此时锚点条目在第 3 页数据里根本不存在。我的做法是快照里记下当时的数据版本号返回时等数据加载完成、分页补齐到锚点条目所在的页再执行对齐如果版本号差异太大启动上面的回退策略。6.4 进程被杀、WebView 回收、深链分享这三个场景我都遇到过共同点是“快照根本不在”。WebView 被回收后 sessionStorage 可能被清空页面重新冷启动用户从分享链接直接进入列表页也只会有一个全新会话没有上一跳的上下文。这种情况不要强行恢复能让列表正常从顶部加载就够。我通常会检查 URL 里是否带了分享标记如果带了就主动清掉快照避免用户从分享入口进来时看到一段诡异的自动滚动动画。自动滚动只有在“返回”语境下才是体验提升在“新打开”语境下反而是惊吓。7. 真机测试清单和一些“手感”修补方案写完只是第一步移动端真正的分水岭在真机验证。我整理了一份上线前必测的清单每一条都是血泪换来的。7.1 滚动容器必须统一移动端常见两类滚动window滚动和内部div滚动。如果列表页用了内部滚动容器那么整个应用的滚动主体必须保持一致不能有的页面滚 body有的页面滚 div否则快照恢复的containerRef根本对不上恢复代码在错容器上执行自然无效果。iOS Safari 上body滚动还伴有橡皮筋效果Android WebView 也可能有不同的 overscroll 行为。我最终统一把长列表放在position: fixed; top: 0; bottom: 0; overflow-y: auto的容器里这样浏览器自带的滚动逻辑最少介入我们的恢复代码可以完全控制。7.2 图片占位高度变化是恢复精度的最大敌人长列表里图片没加载完成之前item 高度普遍偏小恢复平移过去后图片加载出来把高度撑开位置就往下偏了。体验上的表现是“明明定位到了一眨眼的功夫又跳了 50 像素”。解法是给每个有图片的 item 预留固定比例的占位容器比如宽高比 4:3无论图片加载与否item 的高度在数据渲染那一刻就固定下来。这不仅能提升恢复精度对整个列表的滚动流畅度也有帮助。7.3 不要在每个 scroll 事件里写会话存储移动端 scroll 事件触发频率极高如果在滚动回调里直接同步读写 sessionStorage肉眼可见地掉帧。我用的是“监听 scroll 200ms 节流写入内存 跳转前才一次性写 sessionStorage”的组合。平时滚动只更新内存里的最新快照对象跳转的瞬间才序列化写入这样把存储 IO 压到最低。还有一个容易被忽略的点滚动容器如果在移动端设置了scroll-behavior: smooth恢复时的瞬间定位会被 JS 强制平滑播放导致用户看到一段诡异的滑动动画。恢复代码里我会显式用scrollTo({ top, behavior: instant })避开平滑过渡。7.4 真机测试清单我在测试用例平台里固定放了一张表每次改版都要跑一遍测试场景预期结果备注普通长列表滚到中部进详情返回条目和偏移都对齐无闪烁iOS / Android 都要测虚拟列表 1000 条滚到底部返回目标条目可见不白屏检查 overscan / preload 足够筛选条件切换后详情返回若 filterKey 变化不恢复确认不会跳到错误条目分页数据只加载到第 2 页时返回等待数据补齐后对齐弱网下也要可等列表顶部新增推荐位后返回锚点条目方案生效不漂移模拟数据插入WebView 后台被杀后返回快照丢失回顶部不白屏锁屏 5 分钟再回分享链接进入列表页不触发自动滚动URL 带分享标记时清快照图片懒加载场景第二帧微调后位置稳定低网速模拟真机测试时我习惯打开代理做弱网限速。快照和恢复代码在高速网络下很难暴露问题一旦限速到 200kbps数据加载顺序、图片高度变化、分页补齐的时序全都会浮现出来。关于“返回原位置”我个人最后总结一条凡是只保存一个滚动数字的方案都只能覆盖最简单、数据最静态的页面凡是把位置当作“锚点条目 偏移 上下文”来保存的方案才能在虚拟列表、动态数据、多筛选条件的真实环境里活下去。做移动端长列表的时候我一直默认列表是动态变化的头部可能被插入、内容可能被换源所以恢复逻辑宁可多做一层兜底也别让用户回来时面对一个陌生的新列表。