Files
jiang13-forum/.trae/documents/refresh-flashless-plan.md
freefire 4042395e3e feat: 实现基础滚动位置恢复功能
1. 新增 useScrollRestoration hook 用于在布局组件挂载时恢复滚动位置
2. 新增 scrollRestore 工具模块实现基于 sessionStorage 的滚动位置保存与恢复
3. 在 MainLayout 和 AdminLayout 中接入滚动恢复hook
4. 在应用入口初始化全局滚动保存监听
2026-08-07 21:46:58 +08:00

162 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 刷新无闪烁优化方案
> **目标**:消除浏览器刷新时的视觉闪烁,让刷新体验接近 Gitea内容未变化时肉眼完全感知不到重绘
---
## 一、根因分析
### Gitea 为什么「无闪烁」
Gitea 是 **服务端渲染SSR的 MPA**,浏览器收到的 HTML 响应中**已经包含完整渲染的内容**(帖子、列表、评论)。刷新时:
1. 浏览器从服务器取回**已填好内容**的 HTML非空壳
2. 一次性解析 → 布局 → 绘制,内容直接到位
3. 浏览器原生 scrollRestoration 在同一帧恢复滚动位置
4. **没有任何骨架 → 内容的切换**,整页只发生**一次** paint
### jiang13-forum 当前刷新的视觉路径
作为 **React SPA**,刷新时会经历以下可见的阶段(全部可被肉眼察觉):
| 阶段 | 触发者 | 视觉 | 持续时间 |
|---|---|---|---|
| ① 空白 → 顶栏布局出现 | `index.html` 内联 CSS + React 挂载顶栏 | 三栏骨架先出现,但中间主内容区为空 | ~50-150ms |
| ② 路由 Suspense fallback | `lazyWithRetry` + `App.tsx``<Suspense>` | `FeedPageSkeleton` 骨架(首页)或 `PageLoader` 转圈(其他页)进入主内容区 | ~50-100ms |
| ③ 数据 loading 骨架 | 各页面组件 `setLoading(true)` → 早期 return 骨架/转圈 | 鱼骨骨架 / Spinner 可见 | ~100-400ms网络请求 |
| ④ 骨架 → 真实内容切换 | 数据返回后 `setLoading(false)` | 骨架消失,真实内容瞬间出现,**出现明显跳变** | 瞬时 |
| ⑤ 滚动位置恢复(延迟) | `useScrollRestoration` → rAF 循环等内容高度足够 | 先看到内容在 scrollTop=0然后才跳到目标位置**可见的先顶后跳** | 内容渲染后 ~0-4 帧 |
用户所说的"虽然不会回到顶部再恢复滚动的情况,但基本全都有刷新",指的是阶段 ① ~ ④ 的可见变化:骨架出现、骨架消失、内容到位,这是**多次 paint 的切换闪烁**;而阶段 ⑤ 先顶后跳可能被掩盖但仍存在。
### 核心问题
- 首页 `HomePage`:刷新后内存 `feedCache` 丢失,`posts.length === 0`L239 条件命中 → `FeedPageSkeleton` 骨架可见 → 数据回来后切到真实内容 → 闪烁。
- 帖子详情 `PostDetailPage``loading=true` 时 L446 命中 → `<Spinner size="lg" />` 居中转圈 → 数据回来后切到真实内容 → 闪烁。
- 其他页面(消息、收藏、个人主页、后台各页):均有 `loading ? <Spinner ... />` 早期 return → 闪烁。
- 滚动恢复晚于内容首 paint内容先在顶部画出来下一帧才跳回目标位置 → 先顶后跳的闪烁(虽然比之前回顶再跳好很多,但仍可感知)。
---
## 二、总体策略
采用 **三层防御** 分层消除闪烁:
1. **S0 层 — 首屏内容预缓存sessionStorage 级)**
- 仿照 `feedCache.ts` 的结构,将**上次渲染的真实内容**posts 列表、post 详情 + 评论、其他轻量列表数据)在 `pagehide` 时持久化到 sessionStorage。
- 刷新首帧用 sessionStorage 中的旧数据**直接渲染真实内容**,完全绕过骨架/转圈。
- 后台异步重新拉取数据,若与缓存相同则**静默无任何变化**(像 Gitea 一样肉眼无感);若不同则**最小化 diff 更新**,不改滚动位置。
2. **S1 层 — 滚动恢复提前到首 paint 之前**
- 不再依赖 mount 后 rAF 循环。改用 `pageshow` 事件 + `useLayoutEffect`(在浏览器 paint 前执行),在首帧前就把内容区域的 `scrollTop` 设好。
- 配合 S0因为有旧内容撑高了 `scrollHeight`,首 paint 前就能设到目标位置,用户第一眼看到的就是正确位置。
3. **S2 层 — 首帧渲染完成前延迟可见性(兜底)**
- 对没有 sessionStorage 缓存的页面如首次访问、无痕窗口、sessionStorage 被清),无法走 S0。在容器上加 `visibility: hidden` → 骨架/内容全部就绪且滚动位置设置后再 `visibility: visible`。用户看到的第一帧就是"正确内容 + 正确位置",避免看到中间过渡。
- 配合超时300ms兜底极端慢网情况下强行显示避免白屏。
---
## 三、具体改动计划
### 3.1 新建 sessionStorage 持久化缓存工具
**文件**`frontend/src/utils/pageContentCache.ts`
- 定义持久化缓存结构:
```ts
type CachedHomeFeed = { posts, postTotal, page, ts };
type CachedPostDetail = { post, comments, liked, favorited, canEdit, ts };
type CachedGenericList = { items: unknown[], ts }; // 用于消息/收藏/个人等
```
- `saveHomeFeed(boardId, keyword, tag, author, titleOnly, sort, data)` → `sessionStorage.setItem('j13-cache:feed:...', JSON.stringify(...))`
- `loadHomeFeed(...)` → 读取 + 有效性校验posts 数组非空)
- `savePostDetail(postId, data)` / `loadPostDetail(postId)`
- `saveGeneric(key, data)` / `loadGeneric(key)`(用于其他页面)
- 所有 save 在 `pagehide` 或 `useEffect cleanup` 时触发;加载时若 `ts` 超过 10 分钟,视为过时而不加载(防止用户次日访问出现完全过期内容)。
### 3.2 扩展 scrollRestore.ts提供提前恢复能力
**文件**`frontend/src/utils/scrollRestore.ts`
- 新增 `readSavedScrollTop(containerSelector): number | null`:纯读取不等待,不触发 rAF。
- 新增 `pageshow` 监听(在 `initScrollRestore` 中注册):`pageshow` 时若首帧尚未 paint立即尝试设置各容器的 `scrollTop`。
- 保留现有的 rAF 重试循环作为兜底S0 未命中或内容还不够高时)。
### 3.3 HomePage — 用持久化缓存消掉骨架
**文件**`frontend/src/pages/HomePage.tsx`
- 初始化阶段(原 L139-L177 的 useEffect
- 若 `getFeedCache(...)`(内存 Map无数据**先查 `loadHomeFeed(...)`sessionStorage 持久化)**。
- 命中 → 直接用旧数据渲染,`loading=false``setRestoreScrollTop(cached.scrollTop)`;同时启动网络请求,回来后**仅在数据有差异**时才更新 stateposts / postTotal避免不必要的重渲染。
- 未命中 → 保留当前骨架逻辑。
- `pagehide` 时(或在 scrollRestore 的 save 中联动)调用 `saveHomeFeed`。
- **效果**:刷新首帧就是真实帖子列表(上次浏览时看到的),骨架完全不显示;网络回来若内容未变,无任何视觉变化;若内容有新帖子,只是在列表顶部插入新条目(不影响当前浏览位置的可见区域)。
### 3.4 PostDetailPage — 用持久化缓存消掉 Spinner
**文件**`frontend/src/pages/PostDetailPage.tsx`
- 数据加载 useEffectL170-L208
- 启动网络请求**之前**先查 `loadPostDetail(postId)`。
- 命中 → `setPost(cached.post)`, `setComments(cached.comments)`, `setLiked(cached.liked)`, `setFavorited(cached.favorited)`, `setCanEdit(cached.canEdit)`, `setLoading(false)`;网络请求回来后 diff 更新。
- 未命中 → 保留当前 Spinner 逻辑。
- `pagehide` 时(或 effect cleanup调用 `savePostDetail`。
- 删除 L446 命中 Spinner 时的 `post-detail-loading` 空白页渲染(持久化命中时不走此路)。
### 3.5 其他有 `loading ? <Spinner>` 早期 return 的页面(可选但建议)
涉及:`FavoritesPage.tsx`、`MessagesPage.tsx`、`ProfilePage.tsx`、`UserProfilePage.tsx`、`AdminBadgesPage.tsx`、`AdminMediaPage.tsx`、`AdminCommentsPage.tsx`、`AdminPostsPage.tsx`、`AdminUsersPage.tsx`、`BoardsManagePage.tsx` 等。
- 使用 `saveGeneric` / `loadGeneric` 包装:在 `loading=true` 但 sessionStorage 有缓存时,先渲染旧数据不显示 Spinner网络回来后更新。
- 若不纳入首版范围,至少应用 S2visibility 兜底)消除转圈 → 内容的切换闪烁。
### 3.6 S2 兜底首帧完成前不可见index.html + App 层)
**文件**`frontend/index.html`
- 在 `<body>` 上加 `class="j13-prepaint"`,对应内联 CSS
```css
.j13-prepaint .main-content,
.j13-prepaint .admin-main { visibility: hidden; }
```
**文件**`frontend/src/hooks/useScrollRestoration.ts`(新建 hook 功能扩展 或 新建 `useFirstFrameReveal.ts`
- 在布局组件MainLayout / AdminLayoutmount 后:
- 条件 A数据已渲染有缓存或网络已回且滚动已设好 → 立即 `document.body.classList.remove('j13-prepaint')`
- 条件 B无缓存且等待网络 → `setTimeout(300ms)` 后强制 reveal避免白屏
- 条件 CrAF 检测到 paint 已发生过(`document.visibilityState` + 首次 paint marker→ reveal
### 3.7 验证点
- 首页有帖子时刷新:首帧直接看到上次的真实内容 + 正确滚动位置,无骨架闪过;若网络回来帖子相同,肉眼完全无变化(达到 Gitea 级别)。
- 帖子详情页有长文时刷新:同上,无 Spinner内容+位置直接到位。
- 首次访问 / 无缓存页面:最多有 300ms 以内白屏(或 reveal 后骨架短暂出现),不出现"骨架→内容"切换闪烁。
- SPA 内导航(点链接)行为完全不受影响,`useScrollRestoration` 的 mount-only 特性保证只在刷新/跨布局切换时触发。
- sessionStorage 条目数量上限:按 URL+参数维度,最坏几十条,占用 KB 级内存,无存储压力。
---
## 四、风险与权衡
| 风险 | 影响 | 应对 |
|---|---|---|
| sessionStorage 数据过时(刷新后看到旧帖子/旧内容) | 短暂,但用户担心读到过期数据 | **已考虑**ts 超过 10min 不加载;且网络请求仍在后台进行,回来后立即更新。与 Gitea 的"先画旧内容,后续若有新帖自然出现"体验一致。 |
| 旧内容的渲染高度与新内容不一致 → 滚动恢复偏差 | 轻微偏差(±几帖高度) | rAF 兜底循环会等新内容高度到位后再精调。S0 的目标是"首帧不闪烁",精细恢复由 S1 兜底完成。 |
| S2 的 visibility:hidden 导致极端慢网下短暂白屏 | 少数极端网络 | 设置 300ms 超时强制 reveal与"先骨架再切内容"的闪烁相比,短暂白屏观感更优(与 Gitea 的整页重绘等价)。 |
| S0 存储的数据量10 篇帖子 + 详情页全文) | KB 级,可忽略 | 不存图片的 base64只存 API 返回的 JSON。 |
| 后台管理页的数据也做 S0 是否有意义? | 管理页刷新频率低 | 首版只对 HomePage + PostDetailPage高频刷新页面做 S0其他页做 S2 兜底即可。 |
---
## 五、改动文件清单(确认版)
| 文件 | 操作 | 所属层级 |
|---|---|---|
| `frontend/src/utils/pageContentCache.ts` | 新建 | S0 |
| `frontend/src/utils/scrollRestore.ts` | 修改(加 readSavedScrollTop / pageshow 恢复) | S1 |
| `frontend/src/pages/HomePage.tsx` | 修改(持久化缓存命中逻辑 + 后台静默更新) | S0 |
| `frontend/src/pages/PostDetailPage.tsx` | 修改(持久化缓存命中逻辑 + 后台静默更新) | S0 |
| `frontend/index.html` | 修改(加 j13-prepaint 类 + CSS | S2 |
| `frontend/src/layouts/MainLayout.tsx` | 修改(调用 reveal 逻辑) | S2 |
| `frontend/src/layouts/AdminLayout.tsx` | 修改(调用 reveal 逻辑) | S2 |