仅设 history.scrollrestoration = 'manual' 不足,必须结合 beforeunload 和 pageshow 事件确保刷新/恢复时滚动置顶,尤其长列表需防渲染抖动。

history.scrollRestoration = 'manual' 本身不管理视口锚定,它只是关掉浏览器自动恢复滚动位置的开关。真正决定“刷新后停在哪”的,是你在什么时机、用什么方式调用 window.scrollTo(0, 0) —— 尤其对长列表这类 DOM 量大、渲染耗时、常配合虚拟滚动或懒加载的组件,时机错一点,用户就能看到明显的跳动或错位。
为什么只设 scrollRestoration = 'manual' 不够
这个设置只影响 history 导航(比如 history.back()、history.forward())和部分刷新行为,但对以下场景完全无效:
- 普通 F5 或地址栏回车刷新:浏览器仍可能从上一次记录的
window.scrollY开始渲染(尤其在服务端渲染或首屏直出页面中),scrollRestoration = 'manual'并不阻止这个初始渲染位置 -
location.reload()编程式调用:多数浏览器不会触发beforeunload,也就没机会提前置顶 - iOS Safari 的 page cache 恢复:页面被系统后台冻结后切回来,
pageshow事件会触发,但此时scrollRestoration设置早已生效,你却还没来得及干预滚动 - 长列表首次渲染慢:如果在
DOMContentLoaded后才调用scrollTo(0, 0),而列表还在 mount / fetch / virtualize 过程中,DOM 高度未稳定,scrollTo可能被忽略或失效
必须绑定 beforeunload 和 pageshow 两个事件
这两个事件不是可选项,是覆盖全部刷新/恢复路径的最小必要组合:
-
beforeunload:在页面卸载前同步执行,DOM 完整、渲染未中断,此时调用window.scrollTo(0, 0)能确保新页面从 (0, 0) 开始加载。注意必须用addEventListener,避免被其他脚本覆盖window.onbeforeunload -
pageshow:捕获浏览器从缓存恢复页面的场景(如 iOS 后退、Android 内存回收后重建)。只有当event.persisted === true时才需再次scrollTo(0, 0);否则是全新加载,beforeunload已处理过 - 二者顺序无关,但建议先设
scrollRestoration = 'manual',再注册事件,避免竞态
长列表组件要额外防“渲染抖动”
即使滚动置顶逻辑正确,长列表仍可能因 DOM 高度突变导致视觉跳动。常见诱因包括:
- 虚拟滚动容器高度依赖子项数量,而子项数据异步到达,初始渲染时容器高度为 0,
scrollTo(0, 0)实际无效 - 图片懒加载 +
loading="lazy"触发重排,拉高页面总高度,使scrollY在置顶后又被“撑”下去 - SSR 页面 hydration 完成前已有滚动位置,hydration 后 DOM 重建,浏览器尝试恢复旧位置(哪怕
scrollRestoration是manual)
应对方式不是延迟 scrollTo,而是让容器有确定高度:
- 给列表外层加固定
min-height或骨架占位 - 在数据加载完成、所有 lazy 元素已触发
load事件后再做最终置顶(可用requestIdleCallback或setTimeout(..., 0)微任务兜底) - 对 SSR 场景,在服务端注入
scrollRestoration = 'manual'并在客户端 hydration 前就注册beforeunload
location.reload() 刷新时的特殊处理
这个 API 不保证触发 beforeunload,尤其在无用户手势上下文中(比如定时器里调用)。可靠做法只有两种:
- 改用
location.replace(location.href):它会替换当前 history 条目,不新增记录,且能正常触发beforeunload - 在调用
location.reload()前手动执行一次window.scrollTo(0, 0),并确保此时 DOM 已 ready(比如在requestAnimationFrame回调里)
不要依赖 onload 或 setTimeout 后置顶 —— 此时页面已经完成初始滚动恢复,你只能“覆盖”,必然产生可见跳变。










