设 history.scrollRestoration = 'manual' 仅禁用浏览器自动滚动,需手动在 pushState 前保存 scrollY 到 history.state,并在 popstate 中读取恢复;须避开端口冲突、iOS 兼容性及渲染时机问题。

不能直接靠 history.scrollRestoration = 'manual' 就让 SPA 自动记住滚动位置——它只关掉浏览器默认行为,后续的保存、恢复、时机控制全得你手写。
为什么设成 manual 后页面反而不滚回去了
设 history.scrollRestoration = 'manual' 只是告诉浏览器:“别自作主张滚屏”,但不会帮你存位置,也不会在 popstate 时自动调 window.scrollTo()。很多开发者设完就以为万事大吉,结果后退时页面卡在顶部,是因为根本没补上恢复逻辑。
- 必须在每次导航前(如
pushState/replaceState前)手动记录当前window.scrollY - 必须监听
popstate,并在事件里主动调用window.scrollTo() - 注意:
popstate在页面首次加载时也会触发一次(即使没导航),要加判断避免误恢复
如何安全地保存和恢复滚动位置
滚动位置不能只存在内存变量里——前进/后退会触发新 popstate,但 JS 执行上下文可能已重置(尤其配合 React/Vue 的路由守卫或组件卸载时)。推荐把位置存在 history.state 本身,随路由状态一起持久化。
- 每次调
history.pushState({ scrollY: window.scrollY }, '', url),把当前位置塞进 state - 在
popstate回调中读event.state?.scrollY,存在才恢复:window.scrollTo(0, scrollY) - 对初始加载(
event.state === null),通常应保持原样或滚到顶部:window.scrollTo(0, 0) - 避免在 Vue Router 的
beforeEach或 React Router 的useEffect里做恢复——它们触发时机晚于popstate,可能被浏览器默认滚动覆盖
和现代框架(React Router / Vue Router)怎么共存
框架的路由系统内部也调 pushState 和监听 popstate,如果你自己也监听,容易冲突。关键是「谁负责写 state、谁负责读并恢复」要明确分工。
- React Router v6+:禁用其内置滚动恢复(
future: { v7_startTransition: true }不影响,但需关掉scrollRestoration配置),改用useNavigation+useLocation手动存取;更稳的方式是 patchhistory实例,在push/replace前注入scrollY - Vue Router:不要用
scrollBehavior,它和scrollRestoration: 'manual'冲突;改用router.beforeEach存位置,router.afterEach恢复 —— 但注意afterEach不触发于 popstate,所以仍要单独监听popstate - 通用原则:所有
pushState调用(包括框架内部的)都应确保 state 含scrollY;所有恢复动作必须在popstate事件回调内完成,且早于框架渲染
容易被忽略的边界情况
真实 SPA 中,这些点一漏就会导致“偶尔滚错”或“iOS 上失效”:
- iOS Safari 对
scrollRestoration: 'manual'支持不稳定,部分版本设了也无效,务必降级检测:if ('scrollRestoration' in history) { history.scrollRestoration = 'manual' } - 页面有
overflow: hidden的模态框打开时,window.scrollY是 0,但关闭后应恢复原位置——需额外缓存一个全局lastScrollY变量 -
scrollTo在 CSSscroll-behavior: smooth下可能被中断或延迟,生产环境建议用{ behavior: 'auto' }确保即时生效 - 如果路由切换伴随异步数据加载(比如拉列表),恢复滚动必须等 DOM 渲染完毕,可用
requestAnimationFrame包一层再scrollTo
真正难的不是设 scrollRestoration,而是把滚动位置当成路由状态的一部分来管理:存得准、读得早、恢复得恰到好处。任何环节脱节,用户后退时就会看到突兀的跳变。

















