更新说明(2026-04-16):这套自定义滚动恢复后来已经从站点里移除。它确实能恢复刷新位置,但代价是可见的重影和跳帧;当前站点改成了更保守的刷新策略:一律回到页首。
内容站的文章页有一个很具体的体验问题:读到中段甚至后段时,如果用户刷新页面,浏览器并不总能稳定回到原来的位置。
当前仓库给这个问题单独做了一个组件:
src/components/layout/ScrollRestore.astro
它只做一件事,尽量把刷新后的阅读位置恢复回来。
为什么不直接依赖浏览器默认行为
因为默认行为并不总稳定,尤其在下面几种情况下更明显:
- 页面内容较长
- 代码块、图片或字体造成布局延后稳定
- 浏览器已经开始还原位置,但文档高度还没准备好
结果通常就是:
- 刷新后先落在顶部
- 再跳一下
- 或者停在一个接近但不准确的位置
对内容站来说,这种体验很伤阅读连续性。
当前策略只在 reload 时触发
这段逻辑并不想接管所有导航,而只处理“当前页面被刷新”的场景:
const navigationEntry = performance.getEntriesByType?.("navigation")?.[0];
const isReload = navigationEntry?.type === "reload";
这样做的好处是边界清楚:
- 普通站内跳转不被干扰
- 真正需要恢复的位置,只限于刷新当前页
用 sessionStorage 按路径保存滚动值
当前实现的 key 不是一个固定字符串,而是:
const scrollKey = `scroll:${location.pathname}${location.search}`;
这意味着不同页面、不同查询参数下的滚动位置会分开保存,不会互相覆盖。
保存时机选的是 pagehide:
window.addEventListener("pagehide", saveScroll);
这比只在 beforeunload 上下注更稳,因为页面离开、刷新、进入 bfcache 等场景里,pagehide 更接近这类需求。
恢复前先隐藏页面,避免“先闪一下再跳”
当前实现里最关键的一步,其实不是 scrollTo,而是这句:
root.style.visibility = "hidden";
只有在满足两个条件时才会这样做:
- 这是一次刷新
sessionStorage里确实找到了合法的历史滚动值
这样页面在恢复目标滚动位置之前不会先以顶部状态短暂显示,用户看到的不是“闪一下再跳”,而是更接近直接回到原阅读点。
恢复不是一次 scrollTo,而是多帧校正
恢复函数现在不是简单地执行一次 window.scrollTo(0, restoreTarget) 就结束,而是通过 requestAnimationFrame 反复尝试:
const tryRestoreScroll = () => {
window.scrollTo(0, restoreTarget);
restoreAttempts += 1;
const reachedTarget =
Math.abs(window.scrollY - restoreTarget) < 2 ||
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight;
if (reachedTarget || restoreAttempts >= maxRestoreAttempts) {
revealPage();
return;
}
requestAnimationFrame(tryRestoreScroll);
};
这样做是因为内容高度有时会在加载后继续变化。单次恢复很容易太早,多帧校正则能给页面一点时间稳定下来。
为什么还要设置最大尝试次数
因为恢复逻辑不能无限跑。当前实现把最大次数设成 60,目的有两个:
- 避免异常页面一直占着循环
- 即使没精确到目标位置,也要在合理时间内把页面显示出来
也就是说,这个组件的目标不是“绝对精确”,而是“尽量准确,同时不把页面卡住”。
最终行为边界
当前这个 ScrollRestore 组件具备这些特点:
- 只处理刷新,不干扰普通导航
- 每个路径独立保存滚动位置
- 恢复前先隐藏页面,减少视觉跳动
- 通过多帧重试提高命中率
- 命中目标或达到上限后立即显示页面
它不是一个通用的复杂滚动管理系统,而是一个针对内容站阅读体验做的最小专用组件。
这一步的价值非常实际
很多“阅读体验优化”听上去都很抽象,但滚动恢复不是。它解决的是一个直接感知的问题:
用户读到哪,刷新后最好还在哪。
在当前仓库里,把这件事单独抽成一个布局级组件,是比把逻辑零散写进详情页更稳的一种做法。