1. 从需求说起:为什么"回不到上次位置"会劝退用户
先聊一个我最近真实遇到的场景。
后台有一个资产管理页面,列表很长,每行资产点进去是一个详情页,详情页里还有若干个 Tab 标签页,用户可能从"设备台账"切到"维修记录"再切到"折旧信息",看完之后点击浏览器返回。结果呢?浏览器回到列表页,直接顶到了第一行。用户如果刚才一直在看第 37 行的某台设备,他就得拿着鼠标滚轮往下滑很久,运气不好滑过头还得再往上找。
这种体验,放在内容型产品里更致命——在线文档、技术文章、电子书阅读器,读者看到第 70% 的位置,中途切出去回复了一条消息,再回到浏览器标签页,页面刷新了,直接回到文章顶部。哪怕文章写得再好,第一次可能忍了,第二次就关掉走人了。
"回到上次阅读位置"这件事,前端实现起来本质上就是"把滚动位置存下来,下次访问时再还原回去",逻辑听上去简单到一句话能说完,但真正落地到项目里,会牵扯出滚动容器判断、存储方案选型、异步数据加载、路由切换时机、图片撑开高度等一系列问题。这篇文章我想把我从做阅读类应用和长列表页面里摸索出来的完整方案、踩过的坑、以及不同场景下的取舍全部摊开写清楚,希望能帮到正在做类似功能的人。
这个功能适合谁?不光是做长篇资讯和电子书的,任何有列表页-详情页跳转关系、有长表单填写、有大屏报表查阅、甚至后台管理系统的同学,基本都会在某个版本排期里遇到"用户要求记住上次浏览位置"的需求。早一点把这套东西吃透,后面真正做的时候会省心非常多。
2. 动手之前先选型:滚动位置到底存在哪里更合适
很多人拿到需求的第一反应是"存 localStorage 不就行了",这种直觉没毛病,但只对了一半。把滚动位置存下来是有代价的——存了不清理会变垃圾数据,存的位置不对会在刷新后失效,存的载体选错了甚至会在多页签场景下出现互相干扰。在写第一行scrollTop之前,建议先花两分钟把存储方案和适用场景理清楚。
2.1 三个候选存储方案的边界,用一张表说清楚
前端能用来存滚动位置的常规方案无非三个:Cookie、sessionStorage、localStorage。我把它们的关键差异列一下。
| 方案 | 容量 | 生命周期 | 跨页签性质 | 适用场景 |
|---|---|---|---|---|
Cookie | 约 4KB | 可设置过期时间 | 共享 | 基本不推荐,只为传输给服务端时才考虑 |
sessionStorage | 约 5MB | 当前页面会话,关闭页签即失效 | 不共享 | 同页签内的临时状态、列表返回恢复 |
localStorage | 约 5MB | 持久化,除非手动清理 | 所有同源页签共享 | 跨刷新、跨会话的文章阅读进度 |
注意sessionStorage有一个比较让人意外的行为:在绝大多数浏览器里,如果用户在同一个页签里点链接跳走再点"后退"回来,sessionStorage是保留的;但如果用户直接复制链接到新页签打开,sessionStorage就是空的。这个特性让它天然适合做"列表页跳详情页再返回"这类单页签内的恢复需求。
2.2 我的选型逻辑:按"用户预期"来分
选哪个方案,核心不是看技术指标,而是看用户下次访问时"还想不想要这个位置"。
如果是内容阅读进度,比如一篇一万字的深度长文,用户可能今天读到一半关掉,明天早上打开还想继续往后读。这种场景用户预期是"我上次没看完,你最好记得我看哪了",所以我用localStorage,跨天、跨刷新、跨浏览器重启都能恢复。代价是数据一直占着存储,需要设计合理的失效清理策略,后面我会专门讲。
如果是列表页与详情页之间的位置还原,用户从列表点进详情再退回来,他的预期是"我刚才在列表里逛到大概中间位置了,退回来你应该把我停在原地"。这种场景里sessionStorage就够了——它天然跟随当前页签,用户关掉页面重开,列表重新加载后回到顶部反而是更合理的行为。
还有第三种少见但确实存在的场景:带自定义参数的长链路页面,比如多步骤表单,用户每一步勾选的内容都要能恢复。这种就得把状态序列化成一个对象,和滚动位置一起存。我见过有人把整个localStorage当成低配版状态管理库用的,虽然听起来不太优雅,但在没有后端配合的中小型项目里,这确实是性价比最高的方案。
2.3 一个应该一开始就定下来的键名规范
存储方案定了之后,接着就踩了第一个坑——键名乱写。
我早期做阅读进度记录的时候,键名随手写了个"currentPosition",结果页面上有文章阅读、有视频列表、有问答详情,全共用这一个键。用户在文章 A 里读到中间,再打开文章 B 滚动了几下,回头刷新文章 A,位置已经被文章 B 的位置覆盖了。
现在我的写法是:
// 统一前缀 + 内容类型 + 内容ID const STORAGE_PREFIX = 'reading_'; const readingKey = (type, id) => `${STORAGE_PREFIX}${type}_${id}`; // 例如 reading_article_10086这样每个内容单元有独立的存储槽位,互不干扰。如果将来要支持多端同步,这个键名前缀还可以直接复用为后端同步的recordKey,属于很划算的早期设计投入。
3. 核心原理:先把"滚动容器"这件事彻底弄清楚
网上很多教程会把核心代码写成这样:
window.addEventListener('scroll', () => { localStorage.setItem('scrollTop', window.scrollY); });这个写法在页面整窗滚动的场景里是对的,但你只要把页面结构改成"头部固定 + 中间内容区独立滚动"这种常见后台布局,这套代码就完全失灵。因为滚动事件根本没有发生在window上,而是发生在一个带overflow: auto的内层容器上。先搞清楚滚动发生在哪个元素上,是这个功能能不能做对的前置条件。
3.1 整窗滚动与容器滚动,事件绑定完全不同
整窗滚动时,监听得靠window.addEventListener('scroll', handler),读取位置也有三种等效写法:
window.scrollY; // 现代浏览器通用 document.documentElement.scrollTop; // 老项目里更常见 document.body.scrollTop; // 兼容早期怪异模式的写法而容器滚动则要把事件绑在具体的 DOM 元素上,读取的是这个元素的scrollTop和scrollHeight:
const container = document.getElementById('article-scroll-box'); container.addEventListener('scroll', handler); // 读取位置 console.log(container.scrollTop);判断当前滚动发生在整窗还是容器内部,没有捷径,最可靠的方式就是打开浏览器 DevTools,在 Elements 面板里看哪个元素的高度超出了视口且 CSS 里带有overflow: auto/scroll/hidden。还有一个实用的经验:如果一个页面你写了position: fixed或position: sticky的顶部导航栏,而内容区没有被它遮挡,那大概率整窗滚动;如果导航栏固定后内容区还有独立滚动条,那就是容器滚动。
3.2 百分比进度 vs 绝对像素值,必须分开看待
在确定"存什么"之前,要先回答一个问题:用户读到一半刷新,内容长度的变化会影响恢复精度吗?
答案是在大多数场景下会影响,而且影响很明显。假设用户读到第 5000 像素的位置,但刷新后接口返回的数据变了,图片加载快慢不同,推荐文章模块多了一条,页面总高度从 20000 像素变成 21000 像素,你如果还硬按 5000 像素去恢复,用户实际看到的上下文可能已经偏离了原来的阅读段落。
所以我通常的做法是同时记录绝对像素值和相对进度百分比:
const state = { scrollTop: container.scrollTop, totalHeight: container.scrollHeight, ratio: totalHeight > 0 ? container.scrollTop / container.scrollHeight : 0 };恢复时优先按绝对像素值定位;如果发现当前页面总高度和记录时的总高度差异超过一定阈值(比如 10%),就改用比例换算:targetPosition = ratio * currentScrollHeight。
这个设计在图文混排的详情页里特别有用。文章首屏是封面大图,加载完成后高度可能多出几百像素;如果只记绝对像素,用户每次看到的起始点都会比上次稍微偏下一点,虽然不至于完全错乱,但总归是不精准。比例换算则是把"用户阅读进度"而不是"像素坐标"作为锚点,更贴近人的感受。
3.3 一个容易被忽略的"恢复时机的注册表"
即便存和读都正确,"恢复"动作本身也有讲究——必须在滚动容器渲染出足够高度的之后再执行定位,否则目标位置超出了当前可滚动范围,scrollTop会直接停在最大值或者根本不动。
最常见的失败案例是:页面数据是异步请求的,文章正文 200 毫秒后才插入 DOM,但你在DOMContentLoaded或useEffect里立刻执行了container.scrollTop = 5000。此时容器还没内容,scrollHeight趋近于容器自身高度,浏览器对超范围的scrollTop做了静默修正,等 200 毫秒后正文渲染出来,位置已经丢失了。
正确思路是把恢复动作注册成"等待内容就绪后再执行"。一个比较通用的做法是用MutationObserver监控容器子节点变化,配合一个"已恢复"的标记位:
let restored = false; function tryRestoreScroll() { if (restored) return; if (container.scrollHeight < MIN_CONTENT_HEIGHT) return; // 高度没起来,继续等待 container.scrollTop = savedState.scrollTop; restored = true; } const observer = new MutationObserver(tryRestoreScroll); observer.observe(container, { childList: true, subtree: true });在 React 项目里更省事的方案是:把恢复逻辑放在数据请求.then()里面,SPA 路由切换场景还需要额外配合页面状态就绪的标记。后面讲到路由时我会把具体写法补上。
4. 完整可复用的落地代码:从数据采集到位置还原
理论部分说得差不多了,接下来给出我实际项目里抽出来的完整实现,按"工具模块-页面接入-边界处理"三层来组织。这套代码不依赖框架,React/Vue/原生 JS 都能套用。
4.1 封装一个通用的阅读位置管理器
我倾向于把存取逻辑收敛成一个独立模块,页面里只关心业务:
// reading-position.js const STORAGE_PREFIX = 'reading_'; function getStorageKey(type, id) { return `${STORAGE_PREFIX}${type}_${id}`; } export function saveReadingPosition({ type, id, scrollTop, totalHeight }) { const ratio = totalHeight > 0 ? scrollTop / totalHeight : 0; const payload = { scrollTop, ratio, totalHeight, updatedAt: Date.now() }; try { localStorage.setItem(getStorageKey(type, id), JSON.stringify(payload)); } catch (e) { // 常见于隐私模式下存储被禁用,静默失败即可 console.warn('[reading-position] save failed', e); } } export function getReadingPosition(type, id) { const raw = localStorage.getItem(getStorageKey(type, id)); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { return null; } } export function clearReadingPosition(type, id) { localStorage.removeItem(getStorageKey(type, id)); }注意几个细节。第一,JSON.parse必须包try/catch,因为旧版本代码可能写入过格式不同的数据,解析失败不应该影响页面主流程。第二,存储写入放在try/catch里——Safari 的隐私模式以及部分浏览器禁用第三方存储时,setItem会直接抛异常,不捕获的话后果是整个页面逻辑被中断。第三,updatedAt字段一开始就要加,后面做过期清理全靠它。
4.2 页面监听:记录滚动位置
以原生 JS 为例,绑定滚动监听并做节流:
import { saveReadingPosition } from './reading-position.js'; const container = document.getElementById('article-scroll-box'); const ARTICLE_ID = 'article_10086'; let ticking = false; function handleScroll() { if (ticking) return; ticking = true; requestAnimationFrame(() => { saveReadingPosition({ type: 'article', id: ARTICLE_ID, scrollTop: container.scrollTop, totalHeight: container.scrollHeight }); ticking = false; }); } container.addEventListener('scroll', handleScroll, { passive: true });这里用requestAnimationFrame把存储频率压缩到每帧一次,避免滚动事件每秒触发几十次导致频繁写入localStorage。如果你用的是 Lodash,也可以用_.throttle(handler, 200)达到类似效果,两种方式都能很好地把写入次数压下来。
在 React 函数组件里,同样的逻辑是这样挂的:
useEffect(() => { const container = containerRef.current; if (!container) return; let ticking = false; const handleScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { saveReadingPosition({ /* ... */ }); ticking = false; }); }; container.addEventListener('scroll', handleScroll, { passive: true }); return () => container.removeEventListener('scroll', handleScroll); }, []);4.3 页面还原:等待容器高度就绪
还原部分我结合前面的"恢复时机"原理,给出带就绪轮询的版本。之所以用轮询而不是单纯MutationObserver,是因为有些页面内容是通过 iframe 或第三方组件渲染的,DOM 子树变动不一定观察得到,轮询虽然朴素,但更稳:
import { getReadingPosition } from './reading-position.js'; function restoreScrollPosition(type, id, container) { const saved = getReadingPosition(type, id); if (!saved) return; const applyPosition = () => { const containerHeight = container.scrollHeight; if (containerHeight <= container.clientHeight) { // 容器还没有可滚动的高度,继续等待 return false; } const historyTotal = saved.totalHeight || 0; const currentTotal = container.scrollHeight; const heightDiffRatio = Math.abs(currentTotal - historyTotal) / historyTotal; container.scrollTop = heightDiffRatio > 0.1 ? saved.ratio * currentTotal : saved.scrollTop; return true; }; // 尝试次数限制,防止容器内容一直没渲染,轮询卡死页面 let retryCount = 0; const maxRetry = 50; const timer = setInterval(() => { retryCount += 1; if (applyPosition() || retryCount >= maxRetry) { clearInterval(timer); } }, 100); }轮询间隔 100ms、上限 50 次的做法,意味着最多 5 秒内还没就绪就放弃恢复。这个上限值可以按你接口的实际速度调整,但一定要设上限——我见过有人忘了加,结果页面一直空转 setInterval,切走再回来也不停。
4.4 实战页面接入示例
把上面三块串起来,一个完整页面接入大概是这样的:
const container = document.getElementById('article-scroll-box'); const articleId = new URLSearchParams(location.search).get('id') || 'default'; restoreScrollPosition('article', articleId, container); container.addEventListener('scroll', handleScroll, { passive: true });这套代码跑通后,"刷新后回到上次位置"的基础需求就已经完成了。接下来真正拉高完成度的,是下面这些真实项目里才会遇到的边界问题。
5. 真实项目中的四个"恢复失灵"时刻与排查链路
代码跑起来了,demo 验证也通过,但部署到测试环境后,总会有几个场景是还原不了的。这一节我把踩过的坑整理成四个高频故障,每个都按"现象-根因-解法"的结构给出来,方便排查时当字典查。
5.1 图片撑开高度:恢复时永远差一截
现象:文章加载完成后恢复了滚动位置,但用户看到的内容比上次往下偏移了几百像素。刷新一次偏移几百像素,再刷新又偏移几百像素。
根因:scrollHeight在图片未加载完成前是不包含图片占位高度的。记录位置时,如果恰好发生在图片都加载完后,记录的总高度是完整高度;但恢复时如果图片还在加载,容器高度不足,scrollTop被浏览器静默钳制到了当前最大值附近。图片加载结束后高度突然变大,用户就感觉自己被"弹"到了文章中间偏下的位置。
解法:恢复时不能只做一次,需要在关键资源加载完成后做校正。我在生产环境用的是window.addEventListener('load', ...)配合图片元素监听组合方案:
function correctAfterImagesLoaded(container) { const images = container.querySelectorAll('img'); let pending = images.length; if (pending === 0) { applyPosition(); return; } const onLoad = () => { pending -= 1; if (pending === 0) { applyPosition(); } }; images.forEach((img) => { if (img.complete) { onLoad(); } else { img.addEventListener('load', onLoad, { once: true }); } }); }如果图片量很大,也可以退而求其次,在window.load之后统一再校正一次就够了,不必每张都监听。
5.2 SPA 路由切换:返回时保存的是"上一页"的位置
现象:从列表点进详情,点击浏览器后退回到列表,列表滚动位置丢失,直接回到顶部。而列表页根本没刷新,理论上滚动位置应该还在。
根因:这是单页应用最常见的坑。React Router 或 Vue Router 在路由切换时,列表页组件被卸载了。卸载前如果scroll监听没有移除,列表页 DOM 都没了,事件自然不会再触发;更隐蔽的是,组件卸载时你在useEffect清理函数里removeEventListener,但列表页源码里把"切走时的容器滚动位置"原样压进了路由history状态里,返回后新的组件实例并没有读这个状态。
解法:对于列表-详情的返回恢复,我不建议用全局存储,而是用history.state在路由栈里携带位置信息,实现"只对上一次返回有效"的精准恢复:
// 列表页切走前保存 function beforeLeave() { const state = window.history.state || {}; state.listScrollTop = container.scrollTop; window.history.replaceState(state, ''); } // 列表页重新挂载后恢复 function onListMounted() { const state = window.history.state; if (state && typeof state.listScrollTop === 'number') { container.scrollTop = state.listScrollTop; // 注意顺手清理,避免下次回到这个页面时又恢复一次 delete state.listScrollTop; window.history.replaceState(state, ''); } }注意这里用的是replaceState而不是pushState,这是为了不污染路由栈。如果你用的框架路由本身就带类似useScrollRestoration之类的 API,优先用框架提供的,我们这套方案当作"框架没提供时的兜底"。
5.3 路由切换事件丢失:页面组件被提前销毁
现象:在 React 里给容器绑了scroll事件,滚动时也能正常保存,但只要一触发路由跳转,回来就发现恢复到顶部,既没有报错误,getReadingPosition也能读到数据。
根因:组件卸载时执行了removeEventListener,但最后一次滚动事件的保存动作还没触发,或者触发了但容器已经脱离文档流,scrollHeight已经被框架置为 0,保存的ratio也变成 0。下一页渲染后按ratio=0恢复,自然回到顶部。
解法:在"即将卸载"的生命周期里主动补一次保存。React 函数组件可以这么做:
useEffect(() => { return () => { // 卸载前主动把最后的位置刷进去 saveReadingPosition({ /* 从ref里取最新的 scrollTop 和 scrollHeight */ }); }; }, []);为了避免状态过期,要把scrollTop和scrollHeight存在 ref 里,每次滚动更新 ref,卸载时直接读 ref 写入存储。Vue 里对应的就是onBeforeUnmount里做同样的动作。
5.4 浏览器自带的恢复机制仅对刷新有意义,对"关闭再打开"无效
现象:有些同学生产环境不写任何代码,刷新后位置也能恢复。
根因:现代浏览器(Chrome、Edge、Safari 都支持)本身就内置了"刷新后恢复滚动位置"机制,原理是浏览器在刷新前保存整个标签页的滚动状态,刷新后自动还原。这个机制只在"页面重载"场景下生效,用户关闭标签页再打开新窗口就失效了,而且这种原生恢复不能针对某个容器准确还原,只能大致对齐整窗滚动位置。
解法:不要因为浏览器自带能力而放弃localStorage方案。自带恢复是锦上添花,真正要保证的是"浏览器原生机制帮不到的那些场景"——比如列表-详情返回、跨会话阅读进度、容器内滚动。一个项目里这两者可以共存:原生机制覆盖整窗刷新场景,我们的代码覆盖容器和跨会话场景,互不干扰。
提示:不要试图在代码里关闭浏览器的原生恢复机制。
history.scrollRestoration = 'manual'可以禁用整窗恢复,但代价是你会失去一个免费的兜底能力,而且这个 API 在部分浏览器上并不生效,刻意依赖它反而容易引入兼容性问题。
6. 从"能用"到"好用":防抖、多记录与垃圾清理策略
基础功能做完,剩下的都是细节。但这些细节恰恰决定了这个功能在真实用户手里是"一个安静存在的贴心设计"还是"一个时不时误事的小毛病"。
6.1 写入频率过高会真卡:我实测过的数据
localStorage.setItem看起来很快,但它是一个同步操作,而且实际上会对整个 5MB 存储区域做序列化。我截获过一个用户长列表场景,滚动一次触发 60 次写入,每次写入 2KB 左右,肉眼可能感受不到什么,但如果这个页面同时还有其他模块在频繁写存储,两个写入操作叠加起来,在低端安卓机上可以明显看到滚动掉帧。
我的经验阈值是:单次滚动事件最多每 200ms 或每帧(16ms)执行一次保存,两者取更宽松的。用requestAnimationFrame保证每帧一次已经是相当高的频率了,实际生产环境我更多用_.throttle(handler, 500),因为阅读进度根本不需要秒级精度,用户不会感知到 500ms 的差值,但写入量会直接少 90% 以上。
6.2 多文章、多页签场景的键名设计与去重
如果一个 SPA 里承载多篇文章、多个电子书、多个模块,键名设计不好就会互相覆盖。我前面提到的前缀方案reading_article_10086可以按内容类型区分,但如果同一种类型下内容非常多,localStorage的 5MB 容量会很快被占满。到这一步,我建议做一个"保留最近 N 条记录"的清理函数。
function pruneReadingRecords({ type, maxPerType = 20 } = {}) { const prefix = `${STORAGE_PREFIX}${type}_`; const entries = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(prefix)) { try { const value = JSON.parse(localStorage.getItem(key)); entries.push({ key, updatedAt: value ? value.updatedAt : 0 }); } catch (e) { entries.push({ key, updatedAt: 0 }); } } } entries.sort((a, b) => b.updatedAt - a.updatedAt); entries.slice(maxPerType).forEach((entry) => { localStorage.removeItem(entry.key); }); }这个函数可以在每次保存时抽样调用,也可以放到window的pagehide事件里统一执行,避免频繁遍历存储造成额外开销。每条记录按updatedAt倒序,超出阈值的旧数据直接丢掉,保证存储池永远在可控范围内。
6.3 失效策略:用户读完了,还要不要记录位置
还有一个被很多人忽略的产品决策:用户如果从文章开头一路滚到底部,已经读完了,下次再打开还会被恢复到接近底部的位置吗?从用户体验来说,读完之后再次打开通常应该从头开始,或者至少不应该恢复到末尾——那里已经没有可读内容了。
我的实践策略是:ratio大于 0.85 时视为"接近读完",保存时直接置空,或者存一个特殊标记finished: true,下次恢复时空位置兜底为顶部。具体阈值你可以按产品形态调整——如果是连载小说,用户可能读到 90% 还想从 90% 继续;但如果是单篇技术文章,读完就是读完了。
const FINISHED_RATIO = 0.85; function saveReadingPosition({ type, id, scrollTop, totalHeight }) { const ratio = totalHeight > 0 ? scrollTop / totalHeight : 0; if (ratio >= FINISHED_RATIO) { localStorage.removeItem(getStorageKey(type, id)); return; } // ... 常规保存逻辑 }这个"读完即清"的策略还有一个额外好处:自动腾出了存储空间,不太需要频繁执行 6.2 里的清理函数了。两者搭配使用,存储池的健康度会高很多。
6.4 恢复动作的视觉反馈:避免用户误认为页面出 bug 了
最后一个细节是体验层面的。如果用户在刷新后看到页面直接从中间开始,但浏览器滚动条位置还没纠正过来,会有很短暂的一瞬"页面被快速拉到下面"的感觉。如果这个动作是在内容加载完成后突然发生的,用户可能会困惑:"我是不是点到了什么奇怪的锚点?"
我的做法是在恢复前给容器加一个过渡动画,让滚动还原过程保持在 120ms 到 200ms 之间,不至于太慢让用户等待,也不至于快到看不清来由:
function restoreWithSmoothEffect(container, targetScrollTop) { container.style.scrollBehavior = 'auto'; container.scrollTop = targetScrollTop; // 如果想做肉眼可见的过渡,可以配合 transform 或 CSS scroll-behavior }有些场景反而应该禁用动画——比如用户从列表页返回,希望"瞬间回到原位置"以便继续上一秒的浏览节奏,这时候任何动画都会打断沉浸感。我的判断标准很简单:跨会话/跨刷新的恢复用平滑过渡,因为用户需要时间重新定位上下文;同会话内返回场景则瞬间定位,因为提速大于一切。
7. 由"阅读位置"延伸出去:这套思路还能解决哪些事
读到这里,阅读位置恢复的实现已经完整了。但如果你认真做过一次就会发现,这类"记住用户现场"的需求在前端里其实是同一个套路——用本地存储记录状态,在适当的时机写入和还原。顺着这个思路,下面几个需求也可以直接复用这套架构。
7.1 长表单草稿:频率和位置一起存
多步骤表单填写到第五步,用户不小心关了页面。用同样的localStorage结构,把滚动位置和表单字段值一起序列化成对象存储:
const draft = { formData: { name: '张伟', phone: '138xxxx', step: 5 }, scrollTop: 3200, updatedAt: Date.now() };恢复时可先还原formData到各个输入框,再按step跳转到对应表单步骤,最后把scrollTop定位到用户失焦的那个字段附近。这一套下来,用户基本感觉不到自己关过一次页面。
7.2 大屏报表查看器的观察位记忆
大屏页面往往有多个 Tab 报表,每个报表的高度差异巨大。用户可能一直在看第三个 Tab 中间的某个图表,刷新后系统却回到第一个 Tab 顶部。对于这种"看板观察位",用前面讲的type + id键名规范,每个 Tab 单独存一份滚动位置,下次进入时按 Tab 维度恢复,体验提升会非常明显。
7.3 记住"上次操作到一半"的交互状态
比如用户在一个列表页勾选了若干项准备批量导出,翻了一页之后发现勾选状态丢了——这也是同一类问题:交互过程中的瞬时状态没有被持久化。把勾选的 ID 数组按列表页的type + 页码存起来,用户翻页返回时勾选状态仍在。注意这种场景要控制存储量,勾选 ID 一多,存储空间会涨得快,建议只存"用户主动操作过的 ID",而不是整个列表数据。
7.4 跨端同步的扩展思路
如果产品后续要做"手机上看了一半,电脑上接着看"这类跨端同步,那么localStorage就无能为力了,需要把updatedAt字段在后端做合并。好在我们从一开始就设计了type + id + updatedAt的结构,迁移到后端接口时,客户端逻辑几乎不用改,只需要把saveReadingPosition内部从localStorage.setItem换成fetch('/api/reading-progress', { method: 'POST', body: payload }),再在骨架屏渲染完成后从接口拉一次数据即可。这种"标注了时间戳的本地状态 + 服务端同步"的做法,是本地渐进增强到云端协作的一条平滑路径。
以上就是我在阅读位置恢复这个功能上踩过、填平、再总结出来的全部内容。前端这类"小功能"往往比它看上去要复杂几个量级,但也是最有性价比的部分——用户可能说不出哪里好,但一旦缺失,他会立刻感觉到"这个页面不体贴"。花两天时间把基础版做好,再花半天把边界场景过一遍,这笔投入非常值得。