PostFeed 列表缓存和回退滚动恢复说明
这篇记录 components/PostFeed.tsx 里首页文章列表的滚动恢复逻辑。
目标是解决一个体验问题:
用户在首页向下滚动,自动加载了多页文章
-> 点击某篇文章进入详情页
-> 点击浏览器回退
-> 首页应该回到刚才那篇文章附近,并且列表不要闪动
现在的设计重点是:回退时使用缓存的列表快照,不再重新拉第 2 页到已浏览页。
为什么之前会闪动
旧方案只缓存了这些信息:
type SavedPostFeedPosition = {
currentPage: number;
restoreOnReturn: boolean;
savedAt: number;
scrollY: number;
};
也就是只记住:
- 当前加载到第几页
- 是否需要回退恢复
- 保存时间
- 滚动位置
没有缓存文章列表本身。
所以浏览器回退到首页时,服务端首屏只有第一页,代码需要重新请求第 2 页到已浏览页:
for (let page = initialPagination.currentPage + 1; page <= positionToRestore.currentPage; page += 1) {
const pageData = await fetchPostsPage(page, controller.signal);
}
这会带来两个问题:
- 回退时页面先显示第一页,再慢慢补数据,视觉上会闪。
- 如果用户之前滚得很深,回退时会重新打很多分页接口。
当前缓存了什么
现在缓存的是完整列表快照:
type SavedPostFeedState = {
pagination: PostsPagination;
posts: PostRecord[];
restoreOnReturn: boolean;
savedAt: number;
scrollY: number;
};
字段含义:
posts:当前已经加载出来的文章列表。pagination:当前分页状态,比如currentPage、hasMore、totalCount。scrollY:离开列表页时的滚动位置。restoreOnReturn:下次回到列表页时是否需要恢复。savedAt:保存时间,用来判断缓存是否过期。
缓存放在 sessionStorage 里:
const POST_FEED_POSITION_STORAGE_KEY = 'heyong-newvlog:post-feed-state:v2';
用 sessionStorage 的原因:
- 只影响当前浏览器标签页。
- 不跨标签页污染浏览位置。
- 关闭标签页后自然失效。
什么时候写缓存
点击文章详情或编辑前,会立即保存当前列表快照:
function saveCurrentPosition(restoreOnReturn: boolean) {
restoreOnReturnRef.current = restoreOnReturn;
writeSavedPostFeedState({
pagination,
posts,
restoreOnReturn,
scrollY: window.scrollY,
});
}
普通左键同标签页跳转才会触发:
function handlePostLinkClick(event: MouseEvent<HTMLAnchorElement>) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
saveCurrentPosition(true);
}
这样做是为了避免新标签页打开文章时,也把当前页面标记成“需要回退恢复”。
页面滚动时也会被动保存位置,防止刷新或 pagehide 时丢失当前状态。
回退恢复流程
恢复逻辑在 useLayoutEffect 里。
关键判断是:
const savedState = readSavedPostFeedState();
if (restoreRunRef.current || !savedState?.restoreOnReturn) {
return;
}
含义是:
- 如果这一轮组件生命周期里已经启动过恢复,就不要重复启动。
- 如果缓存里没有
restoreOnReturn: true,说明不是从详情页回退回来,不需要恢复。
真正恢复时,不再请求分页接口,而是直接用缓存的列表快照:
setPosts(stateToRestore.posts);
setPagination(stateToRestore.pagination);
setErrorMessage('');
setIsLoading(false);
这样回退时可以马上把之前已经加载过的文章放回页面,避免重新拉列表造成闪动。
为什么用 useLayoutEffect
这里用的是:
useLayoutEffect(() => {
// 恢复逻辑
}, []);
useLayoutEffect 会在浏览器绘制前同步执行,比普通 useEffect 更适合做滚动位置恢复。
如果用普通 useEffect,浏览器可能先画出第一页,再执行恢复逻辑,用户更容易看到闪动。
requestAnimationFrame 的作用
恢复列表后,需要等 React 把缓存文章真正渲染进 DOM,再恢复滚动位置:
firstFrameId = requestAnimationFrame(() => {
secondFrameId = requestAnimationFrame(() => {
window.scrollTo({
behavior: 'auto',
top: stateToRestore.scrollY,
});
});
});
这里不是循环,而是连续等两个浏览器渲染帧。
第一帧的作用:
给 React 一次机会,把 setPosts 后的新列表提交到 DOM
第二帧的作用:
等 DOM 高度和布局基本稳定后,再执行 window.scrollTo
如果立刻执行 scrollTo,页面可能还只有第一页高度。
比如用户之前滚到:
scrollY = 3200
但当前 DOM 还没有缓存列表撑开,高度不够,浏览器就滚不到目标位置,或者后续渲染又把位置顶开。
所以这里用两层 requestAnimationFrame 等页面布局稳定。
cleanup 为什么是清理流程
在 React 里,useEffect 或 useLayoutEffect 返回的函数就是 cleanup:
useLayoutEffect(() => {
// effect 主逻辑
return () => {
// cleanup 清理逻辑
};
}, []);
React 会在这些时机调用 cleanup:
- 组件卸载时,比如从首页跳到详情页。
- effect 下一次重新执行前。
- 开发环境 Strict Mode 下,React 可能会先执行 effect,再 cleanup,再重新执行 effect。
当前 cleanup 是:
return () => {
isCancelled = true;
cancelAnimationFrame(firstFrameId);
cancelAnimationFrame(secondFrameId);
if (!isRestoreCompleted) {
restoreRunRef.current = false;
restoreOnReturnRef.current = true;
}
isRestoringRef.current = false;
};
逐行含义:
isCancelled = true:标记当前这次恢复流程已经作废。cancelAnimationFrame(firstFrameId):取消第一帧任务,避免卸载后继续进入恢复逻辑。cancelAnimationFrame(secondFrameId):取消第二帧任务,避免卸载后继续scrollTo或setState。if (!isRestoreCompleted):如果恢复还没真正完成,就允许下一轮继续恢复。restoreRunRef.current = false:撤销“恢复已经启动过”的标记。restoreOnReturnRef.current = true:保留“下次还需要恢复”的标记。isRestoringRef.current = false:cleanup 后不再认为页面处于恢复中。
isCancelled 是干嘛的
isCancelled 是当前这次 effect 内部的局部取消标记:
let isCancelled = false;
在异步帧里会检查:
if (isCancelled) {
return;
}
cleanup 时会设置:
isCancelled = true;
意思是:
当前这次恢复流程已经失效
后面排队的 requestAnimationFrame 回调即使执行,也不要继续 scrollTo 或 setState
它只管当前这一轮 effect。
restoreOnReturn 是干嘛的
restoreOnReturn 表示:
下次回到首页文章列表时,要不要恢复缓存列表和滚动位置
点击详情页前会写成 true:
saveCurrentPosition(true);
恢复成功后会写回 false:
writeSavedPostFeedState({
pagination: stateToRestore.pagination,
posts: stateToRestore.posts,
restoreOnReturn: false,
scrollY: stateToRestore.scrollY,
});
这样普通刷新首页时,不会强行跳回旧位置。
为什么有 .current
这些变量来自 useRef:
const restoreRunRef = useRef(false);
const restoreOnReturnRef = useRef(false);
const isRestoringRef = useRef(false);
useRef(false) 返回的是对象,不是普通 boolean:
{
current: false
}
所以读写值必须用:
restoreRunRef.current
不用普通变量的原因是:React 组件每次渲染都会重新执行函数,普通变量会被重新创建。
useRef 的特点是:
.current可以跨 render 保留值。- 修改
.current不会触发重新渲染。 - 适合保存流程控制标记。
几个 ref 的职责
restoreRunRef.current:
当前组件生命周期里,恢复流程是否已经启动过
它用来防止重复启动恢复流程。
restoreOnReturnRef.current:
用户是不是从详情/编辑页回来后需要恢复
它用来控制保存缓存时,是否继续保留 restoreOnReturn。
isRestoringRef.current:
当前页面是否正在恢复列表和滚动位置
滚动监听里会用它避免恢复过程中把旧滚动位置覆盖掉。
为什么 cleanup 里要重置 restoreRunRef
恢复刚开始时会设置:
restoreRunRef.current = true;
表示这一轮恢复已经启动过。
但如果恢复还没执行到 scrollTo,effect 就被 cleanup 了,比如:
Strict Mode 触发 cleanup
或者用户快速离开页面
这时如果不重置:
restoreRunRef.current
下一轮 effect 会看到它还是 true,于是直接跳过恢复。
所以 cleanup 里有:
if (!isRestoreCompleted) {
restoreRunRef.current = false;
restoreOnReturnRef.current = true;
}
含义是:
如果恢复没完成,就不要把它当成已经恢复过
下一轮 effect 还应该继续恢复缓存列表和滚动位置
IntersectionObserver 和加载下一页
列表底部有一个 sentinelRef 元素。
IntersectionObserver 用来监听它是否接近视口:
const observer = new IntersectionObserver(
entries => {
const entry = entries[0];
if (entry?.isIntersecting) {
setIsLoading(true);
}
},
{
rootMargin: '320px 0px',
},
);
rootMargin: '320px 0px' 表示提前 320px 触发加载。
这样用户还没真正滚到底部时,就开始请求下一页,体验更顺。
恢复过程中会跳过监听:
if (isRestoringRef.current || isLoading || !pagination.hasMore || errorMessage) {
return;
}
目的是避免恢复滚动位置时,底部 sentinel 误触发新的分页请求。
AbortController 和请求取消
普通加载下一页时会创建:
const controller = new AbortController();
AbortController 是浏览器 Web API,用来取消请求。
请求时传入:
fetchPostsPage(nextPage, controller.signal);
cleanup 时调用:
controller.abort();
这样组件卸载、effect 重新执行、或者用户快速离开页面时,还没完成的请求会被取消,避免请求回来后继续 setState。
如果请求是主动取消的,不展示错误:
if (controller.signal.aborted) {
return;
}
当前设计小结
现在的回退恢复策略是:
离开列表页前缓存完整列表快照
回退时直接恢复 posts + pagination
等两帧让 DOM 高度稳定
再恢复 scrollY
恢复完成后把 restoreOnReturn 改回 false
如果恢复中途被 cleanup,允许下一轮重试
这个方案解决的是:
- 回退时不重新拉多页列表。
- 减少页面闪动。
- 保留用户刚才看到的列表状态。
- 避免 Strict Mode cleanup 导致恢复流程误跳过。
手动测试步骤
- 打开首页。
- 向下滚动,让列表自动加载到第 3 页以上。
- 点击某篇文章的“查看详情”。
- 点击浏览器回退。
- 确认首页回到刚才点击文章附近。
- 确认回退时没有明显从第一页重新补页的闪动。
- 确认继续向下滚动时,仍能正常自动加载更多文章。