前端阅读位置记忆:滚动位置保存与恢复的完整方案 做内容类产品的人一定遇到过这个需求用户刷一篇很长的文章已经看到 80% 的位置因为切出去回微信或者锁屏再回来时页面被刷新又得从最顶上重新滑下去。这个功能在行业里叫“阅读位置记忆”本质上是前端对滚动位置scroll position的管理。很多前端面试题也会问怎么让用户回到上次阅读的位置。今天我把这个问题的常见场景、实现思路、可复用代码和踩过的坑一次说完。前端处理滚动位置实际上面临的不只是一个 API 的调用而是“什么时候存、存哪里、什么时候恢复、恢复失败怎么办”这一整条链路。我以一个内容型 H5 项目为例完整拆解其中的技术细节。1. 先想清楚我们要记住的是哪种滚动位置1.1 三种最常见的场景不能混为一谈很多开发者一开始就直接写window.scrollY存localStorage但上线后发现有的场景恢复不对。其实“回到上次阅读位置”至少覆盖三种完全不同的情况必须分开设计。场景 A用户刷新页面或者关掉浏览器后再打开希望回到同一个页面的阅读位置。场景 B用户在列表页点进详情页看完详情后返回列表页希望列表停留在刚才的位置。场景 C用户在更新文章、切换 Tab 后浏览器的前进后退要能恢复位置。这三种场景的存储载体和恢复时机完全不同。场景 A 需要持久化关掉浏览器再打开还得能恢复所以用localStorage最合适。场景 B 是 SPA 内部路由栈切换理论上页面实例还在可以直接用内存变量、history.state或者缓存组件保存。场景 C 则涉及浏览器原生scrollRestoration和 BFCache 机制。我见过不少项目把所有场景都无脑写入localStorage结果列表页里存了几十个 key数据又乱又慢。正确的做法是先分场景跨会话恢复才用持久化同一会话内的列表返回直接存在内存中就行。为了帮大家快速判断我把对比列在这里场景是否需要持久化推荐方案注意事项刷新页面后恢复是localStoragekey 要包含页面唯一标识关闭浏览器后恢复是localStorage注意存储容量定期清理SPA 列表返回否sessionStorage 或内存变量路由离开时记录返回时恢复浏览器前进/后退否history.state 原生 scrollRestoration需要处理 SPA 的特殊情况1.2 为什么不用 cookie有新人问过滚动位置存 cookie 行不行技术上可行但不要这么做。cookie 会随着每个 HTTP 请求自动发送哪怕是一个很小的scrollTop1200也会在图片、接口、静态资源的请求里反复携带白白增加流量负担。而且 cookie 的存储上限一般只有 4KB虽然滚动位置只是数字但一个页面保存多个状态后很容易超。localStorage和sessionStorage就不会自动上传存储空间更大是更合适的载体。这两个存储方案也要区分sessionStorage的生命周期是“单个标签页 会话期间”刷新不会清掉但关闭标签页就会消失localStorage只要不清缓存就一直在。所以跨会话恢复必须用localStorage。如果用户同一域名开了两个标签页localStorage数据会被互相覆盖这是后文会提到的坑。1.3 先看看框架/组件库有没有现成能力在动手封装之前最好先确认项目里用的框架是否给了方案。比如 React Router 的ScrollRestoration组件、Nuxt 3 的useScrollPosition、以及很多组件库的BackTop组件往往附带“记录位置”的配套。如果项目本身用了路由框架优先用官方方案扩展性和维护成本都比自己写要低。但这类方案的问题在于它们通常只负责“路由变更时恢复”不会处理“文章阅读进度 刷新恢复 图片未加载”这类内容型场景。所以我在内容 H5 项目里最终还是自己封装了一个阅读位置管理器。后面第 3 部分会给出完整实现。2. 核心细节事件监听与数据保存2.1 不要每次滚动都写 storage滚动事件在浏览器里触发频率非常高现代设备普遍是 60Hz 刷新率也就是说一秒钟可能触发几十次。如果你在scroll里直接localStorage.setItem轻则造成掉帧重则直接卡顿尤其在低端安卓机上非常明显。解决方法是节流最简单的做法是每 300~500ms 保存一次。我实际用的节流代码长这样function throttle(fn, delay 300) { let timer null; return function(...args) { if (timer) return; timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; } function savePosition(key) { const top window.scrollY || document.documentElement.scrollTop; localStorage.setItem(key, String(top)); } const throttledSave throttle(() savePosition(article:123), 300); window.addEventListener(scroll, throttledSave, { passive: true });passive: true也很重要它告诉浏览器“我不会在这个监听器里调用preventDefault”浏览器因此可以跳过额外的检查让滚动更顺滑。如果不设置某些浏览器会警告并降级处理不影响功能但性能会略差。2.2 滚动事件的监听对象要选对默认页面滚动监听window没问题但页面内可能有独立的可滚动容器比如.article-content { height: 500px; overflow-y: auto; }。这时候你监听window永远拿不到容器的滚动位置应该监听该容器元素。注意一个小细节如果容器是overflow滚动要用element.scrollTop如果是整个页面滚动则应该用document.documentElement.scrollTop。很多项目中页面滚动高度既可能出现在documentElement也可能出现在body所以为了兼容建议取两者中更大的值const top window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;2.3 何时保存最稳定卸载时补一次如果你只靠节流里的“每 300ms 保存一次”用户快速滚动然后立刻切后台最后一次保存可能没来得及触发。所以最好在组件卸载、beforeunload或visibilitychange时强制保存最终位置。visibilitychange是移动端最容易忽略的用户切到后台时应该把当前滚动位置补一次因为页面可能随时被系统回收。document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { savePosition(article:123); } });这三个时机配合起来才能保证滚动位置不丢失正常滚动节流保存离开页面时强制保存页面隐藏时再补一次。2.4 恢复位置为什么经常“闪一下”又回顶这是最常见的坑。问题不在代码而在时序。如果你在DOMContentLoaded阶段直接执行window.scrollTo(0, 1200)但这个页面的正文图片还没有加载完成文章总高度还很小1200 可能无法生效随后图片一个接一个加载页面高度逐渐撑大滚动条会被顶回顶部。解决办法有几个层级第一层图片必须占位给img设置固定的宽高或者aspect-ratio。这样即使图片没加载完页面高度也是稳定的滚动位置能精确恢复。第二层等待window.load事件后再恢复因为load意味着页面资源加载完成。第三层如果页面里有异步接口渲染数据load事件也不可靠采用多次尝试恢复。比如在requestAnimationFrame或setTimeout里重复执行 5~10 次直到滚动值稳定。function restorePosition(key, maxAttempts 10) { const target Number(localStorage.getItem(key)); if (!target) return; let attempt 0; function tryRestore() { window.scrollTo(0, target); if (Math.abs(window.scrollY - target) 2 attempt maxAttempts) { attempt; requestAnimationFrame(tryRestore); } } tryRestore(); }3. 实操写一个可复用的“阅读位置管理器”3.1 先定义需求我做的项目是一个文章详情页文章有长图、文字、评论区。产品需求很简单用户刷新文章页能回到离开时的位置。用户退出浏览器后重新进入也能回到离开时的位置。不同文章不能串位置即文章 A 保存的位置不能恢复到文章 B。如果用户手动点击“回到顶部”应该清除这个文章的记录。为此我把存储 key 设计为read_position:{articleId}。这样每篇文章有独立的滚动位置。文章 ID 通过路由参数取得。点击“回到顶部”时手动移除该 key否则下次打开又会莫名其妙回到顶部事件之前的位置。3.2 原生 JavaScript 实现版这个实现不依赖框架适合直接放到普通 H5 或静态页面里。const POSITION_PREFIX read_position:; const positionStorage { get(articleId) { const raw localStorage.getItem(POSITION_PREFIX articleId); return raw ? Number(raw) : 0; }, set(articleId, top) { localStorage.setItem(POSITION_PREFIX articleId, String(top)); }, remove(articleId) { localStorage.removeItem(POSITION_PREFIX articleId); }, }; function throttle(fn, delay 300) { let timer null; return function(...args) { if (timer) return; timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; } function setPosition(articleId) { const top window.pageYOffset || document.documentElement.scrollTop; positionStorage.set(articleId, top); } function initArticlePosition(articleId) { const target positionStorage.get(articleId); const restore () { if (target 0) { window.scrollTo(0, target); } }; // 延迟到资源加载完成后恢复 if (document.readyState complete) { restore(); } else { window.addEventListener(load, restore, { once: true }); } // 节流保存滚动位置 const throttledSet throttle(() setPosition(articleId), 300); window.addEventListener(scroll, throttledSet, { passive: true }); // 页面隐藏或刷新时强制保存 document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { setPosition(articleId); } }); window.addEventListener(beforeunload, () { setPosition(articleId); }); } function clearArticlePosition(articleId) { positionStorage.remove(articleId); }有两点需要说明window.scrollTo(0, target)的第二个参数可以传behavior: smooth但“回到上次阅读位置”不建议 smooth因为用户知道自己要去哪里平滑滚动反而会产生几十到几百毫秒的“飘过去”动画体验上与预期不符。恢复时直接用auto瞬间到位最好。另一个点是在beforeunload里写localStorage通常没问题但如果数据量巨大有丢失风险滚动位置只是一个数字不用担心。3.3 在 React 里封装成 hook如果项目是 React可以封装成useReadingPosition方便多个页面复用。这个 hook 接收articleId内部绑定事件并且在组件卸载时清理监听器强制保存最终位置。import { useEffect, useRef } from react; function useReadingPosition(articleId) { const lastTop useRef(0); useEffect(() { const prefix read_position:; const key prefix articleId; const target Number(localStorage.getItem(key)) || 0; const restore () { if (target 0) { window.scrollTo(0, target); } }; if (document.readyState complete) { restore(); } else { window.addEventListener(load, restore, { once: true }); } let timer null; const handleScroll () { const top window.pageYOffset || document.documentElement.scrollTop; lastTop.current top; if (timer) return; timer setTimeout(() { localStorage.setItem(key, String(top)); timer null; }, 300); }; const handleVisibility () { if (document.visibilityState hidden) { localStorage.setItem(key, String(lastTop.current)); } }; window.addEventListener(scroll, handleScroll, { passive: true }); document.addEventListener(visibilitychange, handleVisibility); return () { localStorage.setItem(key, String(lastTop.current)); window.removeEventListener(scroll, handleScroll); document.removeEventListener(visibilitychange, handleVisibility); window.removeEventListener(load, restore); if (timer) clearTimeout(timer); }; }, [articleId]); } export default useReadingPosition;在组件里调用一行就完成了useReadingPosition(articleId);需要提醒 React 18 StrictMode 的朋友开发环境下 useEffect 会执行两次第一次执行后清理函数也会跑。由于清理函数里会写一次localStorage而lastTop.current在第一次注册事件后可能还是 0会把原来的位置覆盖掉。解决办法是在清理函数里加一个判断只有lastTop.current 0才写入。生产环境没有这个问题但调试时很容易踩到。3.4 在 Vue 项目里怎么做Vue 组合式 API 的写法与 React hook 类似核心是onMounted和onUnmounted。下面是一个极简版本适合放在vue-router页面组件里import { onMounted, onUnmounted } from vue; export function useReadingPosition(articleId) { const KEY read_position: articleId; function restore() { const target Number(localStorage.getItem(KEY)) || 0; if (target 0) window.scrollTo(0, target); } function save() { const top window.pageYOffset || document.documentElement.scrollTop; localStorage.setItem(KEY, String(top)); } onMounted(() { let timer null; const onScroll () { if (timer) return; timer setTimeout(() { save(); timer null; }, 300); }; if (document.readyState complete) { restore(); } else { window.addEventListener(load, restore); } window.addEventListener(scroll, onScroll, { passive: true }); onUnmounted(() { save(); window.removeEventListener(scroll, onScroll); window.removeEventListener(load, restore); if (timer) clearTimeout(timer); }); }); }Vue 3 的onMounted里注册事件onUnmounted里清理逻辑与 React 基本一致。如果项目里用的是 Vue 2 Options API可以放到mounted()和beforeDestroy()生命周期中思路不变。4. 进阶单页应用路由切换与虚拟滚动场景4.1 浏览器原生 scrollRestoration 的冲突现代浏览器有一个原生属性history.scrollRestoration它默认是auto意思是浏览器自己在前进后退时尝试恢复滚动位置。这个机制在普通多页网站挺好用但在 SPA 里通常会“帮倒忙”路由变化后如果页面内容是异步渲染的原生恢复会过早执行导致数组还没渲染恢复失败。然后浏览器又尝试恢复到 0把你手动恢复的效果也冲掉。所以当我们自己实现阅读位置恢复时建议在全局设置if (scrollRestoration in history) { history.scrollRestoration manual; }这样一来浏览器不会干预 SPA 内的滚动完全由前端代码控制。这个设置要尽早执行最好在应用入口的最前面调用。4.2 列表页返回详情页的恢复前面说到了场景 B列表页滚动位置恢复。这种场景不建议用localStorage因为用户在一个会话里可能多次进出列表页用持久化数据会造成混乱。推荐用history.state或内存模块变量。以 Vue Router 为例我常用下面这种做法// 列表页离开时记录 router.beforeEach((to, from) { if (from.name List) { const scrollTop window.pageYOffset || document.documentElement.scrollTop; sessionStorage.setItem(list_scroll, String(scrollTop)); } }); // 列表页返回时恢复 router.afterEach((to, from) { if (to.name List) { const saved Number(sessionStorage.getItem(list_scroll)); if (saved) { window.scrollTo(0, saved); } } });这里用sessionStorage而不是内存变量是为了防止用户误触浏览器刷新后列表位置还能救回来。但如果你的应用中列表内容会动态加载那恢复时机不能放在afterEach里而应该放在列表数据渲染完成后的下一个 tick 中或者使用nextTick。还有一个常见面试坑当路由切换时如果页面组件被复用了比如从列表 A 切到列表 B再次切回列表 Ascroll事件监听器会被重复添加。所以离开路由时必须移除监听否则执行两次scrollTo会引起闪烁。4.3 长列表虚拟滚动的特殊处理如果列表有几千条数据通常会使用虚拟滚动只渲染可视区域内的元素。此时页面滚动高度并不等于真实内容高度直接把scrollTop存下来再恢复是无效的因为渲染的数据条数不同偏移量完全不同。针对虚拟滚动正确做法是记录“第一个可见列表项的 id 或 index”。恢复时先滚动到该元素所在位置再根据当前渲染情况微调。比如使用scrollIntoView让第一个可见项重回视口顶部function restoreVirtualList(savedIndex) { const selector [data-item-index${savedIndex}]; const item document.querySelector(selector); if (item) { item.scrollIntoView({ block: start }); } }这种方式也会遇到一个副作用scrollIntoView会把元素顶部对齐到视口顶部但有些业务希望保留“滚动条在页面中的比例”而不是回到某一行。这时候需要额外计算补偿偏移量。一般我会建议产品接受“恢复到某个可见项”因为用户感知差异不大实现成本低很多。5. 常见问题排查与避坑清单5.1 恢复后立刻被顶回顶部这是比较多见的现象可能原因有三个原因是图片没有占位高度抖动。原因是路由数据在恢复后才渲染完成页面高度变化导致 scrollTop 失效。原因是浏览器原生 scrollRestoration 在作祟。排查方法打开控制台在恢复完成后主动打印window.scrollY。如果打印出的值是你想要的但屏幕效果还是在顶部说明后续代码或浏览器机制把滚动位置改了。如果打印出的值变成了 0说明恢复时机太早页面高度还不够。给图片加上宽高占位后绝大部分情况能解决。5.2 iOS Safari 的滚动位置不稳定iOS Safari 的滚动事件有一个经典问题惯性滚动期间会持续触发scroll事件滚动结束时的最后一个位置往往在 touchend 后还会再变化一点。如果你的核心逻辑依赖“最后一个 scroll 事件”那保存的值可能会略偏。我的经验是使用requestAnimationFrame读取滚动位置并忽略滚动事件里touchend前最后一次极小位移。也可以直接在touchend里再补一次保存因为 touchend 里读取到的滚动位置相对更准确。window.addEventListener(touchend, () { setPosition(articleId); }, { passive: true });5.3 多个标签页互相覆盖如果用户在两个标签页里同时打开同一篇文章两个标签页都会向同一个localStoragekey 写入滚动位置最后关闭的那个会覆盖前面的。这个场景很难完美处理推荐两个思路一是只在页面可见时读取隐藏时不写入二是存储时附带一个时间戳恢复时如果比对数据显示是很久之前的就忽略掉。对内容站来说用户通常不会开两个标签看同一篇文章所以我只处理了visibilitychange时隐藏不写入逻辑。这样可以降低但不完全消除覆盖问题。5.4 使用了 hash 路由时恢复失败SPA 如果采用 hash 路由比如#/article/123刷新时浏览器会自动根据 hash 尝试找到对应锚点。如果页面中有 id 与 hash 相同就会自动滚动到锚点位置与你手动恢复的滚动位置冲突。解决办法是在手动实现滚动位置管理时把history.scrollRestoration设为manual并且在应用初始化时不主动处理 hash。有些场景需要 hash 定位比如页内锚点导航那就需要分业务处理阅读位置恢复只针对长文页面锚点导航页面不要开启自动恢复。5.5 自动化测试怎么验证前端自动化测试比如 Playwright、Puppeteer里可以用下面的逻辑快速验证打开文章页。执行window.scrollTo(0, 1200)。刷新页面。断言window.scrollY是否等于 1200。注意 headless 浏览器默认视口可能较小页面高度不足 1200 时验证会失败。测试环境要设置一个较高的视口比如1920x1080并准备足够长的文章内容。5.6 性能与存储清理策略最后localStorage里如果积累了太多文章位置 key会越来越难维护。建议在写入时统计 key 数量超过 100 个就删除最早的记录。实现方式也很简单遍历所有 key解析出时间戳或文章 ID按时间淘汰。这个细节虽然不是前端面试高频题但在真实项目里非常重要否则用户看过的每篇文章都会遗留一个 key。6. 一点个人心得我在几个内容型项目里都用过这套方案最后发现最稳的组合是图片宽高占位 节流保存 页面隐藏补写 scrollRestoration manual。框架自带的恢复组件能覆盖一部分场景但内容站最头疼的“图片加载导致高度变化”这类问题它们往往不会替你考虑还是得自己动手。如果你正在做前端面试题准备记住一个“万能回答路径”先说场景分类再说存储选型重点是恢复时机最后主动提一次图片异步加载的坑。这比直接背代码要更容易打动面试官。这个功能后续还可以扩展成“阅读进度百分比”在页面上画一条进度条或者根据滚动位置自动标记章节高亮。核心都是拿到scrollTop然后结合scrollHeight和clientHeight计算比例const percent Math.min( 100, Math.round((scrollTop / (scrollHeight - clientHeight)) * 100) );读者最终在乎的是关掉页面再回来时自己刚才看到的内容还在面前而不是被扔回起点。把这个体验做顺了比很多花哨的动画都更能提升产品的好感度。