尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

前端滚动位置恢复全指南:从localStorage到SPA路由的完整实践

发布时间:2026/9/29 17:40:00

资讯中心
01
ARTICLE

前端滚动位置恢复全指南:从localStorage到SPA路由的完整实践

前端滚动位置恢复全指南:从localStorage到SPA路由的完整实践
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和scrollHeightconst 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。下一页渲染后按ratio0恢复自然回到顶部。解法在即将卸载的生命周期里主动补一次保存。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 })再在骨架屏渲染完成后从接口拉一次数据即可。这种标注了时间戳的本地状态 服务端同步的做法是本地渐进增强到云端协作的一条平滑路径。以上就是我在阅读位置恢复这个功能上踩过、填平、再总结出来的全部内容。前端这类小功能往往比它看上去要复杂几个量级但也是最有性价比的部分——用户可能说不出哪里好但一旦缺失他会立刻感觉到这个页面不体贴。花两天时间把基础版做好再花半天把边界场景过一遍这笔投入非常值得。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。