返回首页
/post-feed-scroll-restore

PostFeed 列表缓存和回退滚动恢复说明

创建于 2026/6/28 07:39:29
PostFeed滚动恢复列表缓存React

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:当前分页状态,比如 currentPagehasMoretotalCount
  • 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 里,useEffectuseLayoutEffect 返回的函数就是 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):取消第二帧任务,避免卸载后继续 scrollTosetState
  • 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 导致恢复流程误跳过。

手动测试步骤

  1. 打开首页。
  2. 向下滚动,让列表自动加载到第 3 页以上。
  3. 点击某篇文章的“查看详情”。
  4. 点击浏览器回退。
  5. 确认首页回到刚才点击文章附近。
  6. 确认回退时没有明显从第一页重新补页的闪动。
  7. 确认继续向下滚动时,仍能正常自动加载更多文章。
游客模式