diff --git a/.trae/documents/gitea-scroll-restoration-analysis.md b/.trae/documents/gitea-scroll-restoration-analysis.md new file mode 100644 index 0000000..98a62d8 --- /dev/null +++ b/.trae/documents/gitea-scroll-restoration-analysis.md @@ -0,0 +1,209 @@ +# Gitea 全局滚动位置保持机制分析 + +> 任务:分析 Gitea 如何做到全局任意界面、任意位置,浏览器强制刷新(包括硬刷新 Ctrl+F5)仍不会让页面回到顶部。 + +## 摘要(核心结论,已根据用户实测修正) + +经过对 Gitea 仓库的深度二轮检索,**Gitea 并没有实现任何自定义的滚动位置恢复机制**——不使用 sessionStorage/localStorage 保存滚动位置、没有 pagehide/beforeunload 监听、没有 Service Worker、没有 scrollRestoration 操作。它的核心策略是 **"不覆盖浏览器默认行为"**。 + +**关键修正**:之前推断"硬刷新(Ctrl+F5)会跳回顶部"是错误的。实测确认 Gitea 在硬刷新后**同样保持滚动位置**。真实原因是:**Chrome / Edge 等 Chromium 内核浏览器的原生"重载滚动恢复"功能**——浏览器在重载前将滚动位置保存在**内存**中,新页面加载完成后恢复。这个内存状态独立于 HTTP 缓存,因此**硬刷新(只绕过 HTTP 缓存)不会清除它**。 + +核心要点: +- 软刷新(F5/Ctrl+R)和硬刷新(Ctrl+F5/Ctrl+Shift+R)**都保持滚动位置**(在 Chrome/Edge 上) +- 这是**浏览器原生行为**,不是 Gitea 的代码实现的 +- Gitea 做对的是"没有破坏它":未设置 `history.scrollRestoration = 'manual'`,没有干扰重载流程 +- **浏览器差异**:Chrome/Edge 保持;Firefox 在硬刷新后可能回到顶部 + +--- + +## 1. 确切机制 + +| 维度 | 实际情况 | +|---|---| +| sessionStorage/localStorage 保存滚动位置? | **没有** | +| `history.scrollRestoration`? | **未设置**(保持浏览器默认 `'auto'`) | +| 自定义 JS 模块? | **不存在** | +| 后端驱动? | **没有** | +| 实际机制 | 浏览器原生 `history.scrollRestoration = 'auto'` 默认行为 | + +**关键事实**:htmx 2.x(Gitea 使用的版本)**不会**设置 `history.scrollRestoration = 'manual'`。在 htmx 2.0.8 完整源码中搜索 `scrollRestoration`,结果为 **0 处匹配**。这一点至关重要——如果 htmx 设了 `manual`,浏览器就不会在刷新时自动恢复,但 htmx 2.x 没有这样做。 + +--- + +## 2. 涉及的源码文件 + +### 2.1 Gitea 侧关键文件 + +**`web_src/js/htmx.ts`**(htmx 配置入口,最关键) +```typescript +// https://github.com/go-gitea/gitea/blob/main/web_src/js/htmx.ts +import htmx from 'htmx.org'; +import 'idiomorph/htmx'; +import type {HtmxResponseInfo} from 'htmx.org'; +import {showErrorToast} from './modules/toast.ts'; + +type HtmxEvent = Event & {detail: HtmxResponseInfo}; + +export function initHtmx() { + window.htmx = htmx; + htmx.config.requestClass = 'is-loading'; + htmx.config.scrollIntoViewOnBoost = false; // 仅禁用 boost 元素的自动滚动入视图,不影响刷新恢复 + + document.body.addEventListener('htmx:sendError', (event: Partial) => { + showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`); + }); + document.body.addEventListener('htmx:responseError', (event: Partial) => { + showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`); + }); +} +``` + +要点: +- `scrollIntoViewOnBoost = false` 仅控制 htmx boost 导航时是否将目标元素滚动入视图,**与页面刷新后的滚动恢复无关** +- **没有任何 `history.scrollRestoration` 赋值** + +**`templates/base/head.tmpl`**(基础 HTML 模板的 body 标签) +```html + +``` +- `hx-push-url="false"` 意味着 htmx 导航默认**不**向浏览器历史压入新 URL,不干扰浏览器原生的历史/滚动追踪 + +**其他已检查文件(均无滚动恢复代码)**: + +| 文件 | 内容 | +|---|---| +| `web_src/js/bootstrap.ts` | 全局错误处理器,设置 `__webpack_public_path__` | +| `web_src/js/globals.ts` | 仅加载 jQuery | +| `web_src/js/index.ts` | 入口文件,`onDomReady` 内动态 import `index-domready.ts` | +| `web_src/js/index-domready.ts` | 调用 `initHtmx` 及所有 `init*` 初始化函数 | +| `web_src/js/features/common-page.ts` | 导航栏切换、页脚语言/主题选择器、下拉菜单 | +| `web_src/js/utils/dom.ts` | 滚动相关代码仅用于 textarea 自动调整高度,与页面滚动恢复无关 | +| `templates/base/head_script.tmpl` | 仅设置 `window._globalHandlerErrors` 和 `window.config`,无滚动恢复代码 | + +在 `web_src/js/features/` 完整目录列表中**没有**任何名为 `async-loader`、`global-fetch`、`common-fetch`、`scroll-restoration`、`stick-to-bottom` 的文件。 + +### 2.2 htmx 2.x 源码侧(对比) + +```javascript +// htmx 2.0.8 src/htmx.js 的 config 默认值 +config: { + historyEnabled: true, + historyCacheSize: 10, // sessionStorage 中缓存 10 页 HTML 快照 + refreshOnHistoryMiss: false, + scrollIntoViewOnBoost: true, // Gitea 覆盖为 false + // 注意:没有 scrollRestoration 字段 +} +``` +- htmx 源码 URL: https://github.com/bigskysoftware/htmx/blob/master/src/htmx.js +- 在完整源码中搜索 `scrollRestoration`、`beforeunload`、`pagehide`、`popstate`,结果均为 0 匹配 + +--- + +## 3. 与 htmx "异步/渐进加载"模式的集成 + +Gitea 的异步加载模式基于 **htmx 2.0.8 + idiomorph 0.7.4**: +- **`package.json` 依赖**:`"htmx.org": "2.0.8"`, `"idiomorph": "0.7.4"` +- **body 配置**:`hx-swap="outerHTML"` + `hx-ext="morph"` + `hx-push-url="false"` +- 很多页面导航通过 htmx 发起 AJAX 请求获取 HTML 片段,用 idiomorph 做 DOM morph 替换 + +**与滚动恢复的关系**: +1. 由于 `hx-push-url="false"`,大多数 htmx 导航**不创建新的历史条目**,浏览器 URL 不变,浏览器原生的滚动位置追踪不被干扰 +2. htmx 2.x **不设置** `history.scrollRestoration = 'manual'`(搜索 htmx 源码 0 处匹配),所以浏览器默认的 `'auto'` 恢复始终生效 +3. `htmx.config.scrollIntoViewOnBoost = false` 禁用了 htmx 在 boost 导航后将目标元素滚动入视图的行为,避免 htmx 导航时意外改变滚动位置 +4. htmx 的 history cache(`historyCacheSize: 10`,存储在 sessionStorage)仅用于 back/forward 时的 HTML 快照恢复,**不涉及刷新时的滚动位置恢复** + +**简言之**:Gitea 的 htmx 集成**刻意不干扰**浏览器原生的滚动恢复,通过"不设置 manual、不 push URL"让浏览器自己处理。 + +--- + +## 4. 硬刷新 vs 软刷新 —— 修正后的澄清 + +### 之前推断的错误 +之前认为"硬刷新(Ctrl+F5)会绕过缓存,浏览器不会恢复滚动位置"——这个推断是**错误的**。 + +### 真实机制:浏览器内存中的滚动状态 + +硬刷新(Ctrl+F5 / Ctrl+Shift+R)的作用范围被广泛误解: + +| 操作 | 清除 HTTP 缓存 | 清除 sessionStorage | 清除内存滚动状态 | +|---|---|---|---| +| F5 软刷新 | 否 | 否 | 否 | +| Ctrl+F5 硬刷新 | **是** | **否** | **否** | +| 关闭标签页 | — | 是 | 是 | + +**关键点**:浏览器在重载前将当前滚动位置保存在**内存**中(绑定到当前 history 条目),新页面加载完成后自动恢复。这个内存状态: +- 独立于 HTTP 缓存 → 硬刷新不清除它 +- 独立于 sessionStorage/localStorage → 不需要 JS 主动保存 +- 是 Chrome/Edge 的原生功能,与 `history.scrollRestoration` 相关但作用于重载场景 + +### 软刷新(F5 / Ctrl+R)—— 有效 +- 浏览器原生恢复滚动位置 +- 所有现代浏览器均支持 + +### 硬刷新(Ctrl+F5 / Ctrl+Shift+R)—— **也有效(Chrome/Edge)** +- 硬刷新只绕过 HTTP 缓存(强制重新下载资源) +- **不影响**浏览器的内存滚动状态 +- Chrome/Edge 在硬刷新后**仍会恢复滚动位置** +- 这是用户在 Gitea 上观察到的现象的真实原因 + +### 浏览器差异(重要) + +| 浏览器 | F5 软刷新 | Ctrl+F5 硬刷新 | +|---|---|---| +| Chrome / Edge (Chromium) | ✅ 保持滚动 | ✅ 保持滚动 | +| Firefox | ✅ 保持滚动 | ❌ 可能回到顶部 | +| Safari | 视版本而定 | 视版本而定 | + +**结论**:Gitea 在硬刷新后保持滚动位置,**不是因为 Gitea 实现了什么机制,而是因为 Chrome/Edge 浏览器原生就这么做**。如果在 Firefox 上硬刷新 Gitea,可能会观察到滚动位置丢失。 + +--- + +## 5. 为什么"全局任意界面任意位置"都生效? + +由于 Gitea 依赖的是浏览器原生重载滚动恢复(Chrome/Edge 的内存级机制),这个行为是**浏览器层面的全局机制**,与具体页面无关: +- 浏览器在页面卸载前自动将当前滚动位置保存在内存中(绑定到当前 history 条目) +- 重新加载时(无论软/硬刷新),浏览器在新页面 load 完成后自动恢复到记录的滚动位置 +- 这个机制对任何 URL、任何滚动位置都生效,不需要每个页面单独配置 +- Gitea 是 MPA(多页应用,服务端渲染 HTML),每次刷新都返回完整 HTML,浏览器能准确恢复 + +Gitea 做对的不是"实现了滚动恢复",而是"**没有破坏浏览器已有的滚动恢复**"。许多 SPA 框架(包括某些 React Router 配置)会显式设置 `history.scrollRestoration = 'manual'` 并自行管理滚动,一旦自行管理失败(比如异步加载竞态),反而会导致刷新后跳回顶部。 + +--- + +## 6. 局限性总结(已修正) + +1. ~~硬刷新不支持~~ → **修正**:Chrome/Edge 上硬刷新也保持滚动位置;Firefox 上可能丢失 +2. **完全依赖浏览器行为**:不同浏览器/版本的刷新滚动恢复行为有差异(Chrome/Edge vs Firefox) +3. **无降级方案**:如果浏览器原生的滚动恢复因任何原因失效(例如浏览器扩展干扰),Gitea 没有备用的 sessionStorage 方案 +4. **MPA 架构的天然优势**:Gitea 是服务端渲染的 MPA,每次刷新返回完整 HTML,浏览器能准确恢复滚动位置。SPA 架构由于初始 HTML 为空、内容由 JS 异步渲染,浏览器在 `load` 事件时尝试恢复往往落空——这是 SPA 的天然劣势 +5. **异步内容加载的竞态**:如果页面有异步加载的内容,浏览器恢复滚动位置时目标位置内容可能尚未加载完成,可能导致恢复位置偏差 + +--- + +## 7. 与当前项目(jiang13-forum)的对比 + +当前项目是 **React + TypeScript + Vite 的 SPA**,使用 react-router-dom。现状: +- 全局**无** scroll restoration 逻辑 +- 仅 [VirtualPostList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/VirtualPostList.tsx#L135-L156) 通过 `restoreScrollTop` 属性做了局部滚动恢复 +- React Router v6 默认**不**自动恢复滚动位置(需要 `ScrollRestoration` 组件或第三方方案) + +若要在 jiang13-forum 实现 Gitea 同等效果,可参考的方向(仅作信息参考,不在本次任务范围内实施): +- 在路由层使用 React Router 的 `` 组件 +- 或在应用入口确认未设置 `history.scrollRestoration = 'manual'` +- 注意:React SPA 的刷新滚动恢复比 MPA 复杂,因为初始 HTML 通常为空,内容由 JS 异步渲染,浏览器在 `load` 事件时尝试恢复可能落空,需要额外的 `useEffect` + sessionStorage 兜底 + +--- + +## 8. 一句话总结 + +**Gitea 没有实现任何自定义的滚动位置恢复机制。它在软刷新和硬刷新后都能保持滚动位置(在 Chrome/Edge 上),是因为浏览器原生会在重载前将滚动位置保存在内存中并在重载后恢复——这个内存状态独立于 HTTP 缓存,所以硬刷新也不会清除它。Gitea 只是"没有破坏"这个浏览器原生行为。但这是 Chromium 内核浏览器的特性,Firefox 在硬刷新后可能丢失滚动位置。** + +--- + +## 参考来源 + +- Gitea htmx 配置:https://github.com/go-gitea/gitea/blob/main/web_src/js/htmx.ts +- Gitea body 模板:https://github.com/go-gitea/gitea/blob/main/templates/base/head.tmpl +- htmx 2.x 源码:https://github.com/bigskysoftware/htmx/blob/master/src/htmx.js +- MDN history.scrollRestoration:https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration diff --git a/.trae/documents/refresh-flashless-plan.md b/.trae/documents/refresh-flashless-plan.md new file mode 100644 index 0000000..0399c8e --- /dev/null +++ b/.trae/documents/refresh-flashless-plan.md @@ -0,0 +1,161 @@ +# 刷新无闪烁优化方案 + +> **目标**:消除浏览器刷新时的视觉闪烁,让刷新体验接近 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` 中 `` | `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 命中 → `` 居中转圈 → 数据回来后切到真实内容 → 闪烁。 +- 其他页面(消息、收藏、个人主页、后台各页):均有 `loading ? ` 早期 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)`;同时启动网络请求,回来后**仅在数据有差异**时才更新 state(posts / postTotal),避免不必要的重渲染。 + - 未命中 → 保留当前骨架逻辑。 +- `pagehide` 时(或在 scrollRestore 的 save 中联动)调用 `saveHomeFeed`。 +- **效果**:刷新首帧就是真实帖子列表(上次浏览时看到的),骨架完全不显示;网络回来若内容未变,无任何视觉变化;若内容有新帖子,只是在列表顶部插入新条目(不影响当前浏览位置的可见区域)。 + +### 3.4 PostDetailPage — 用持久化缓存消掉 Spinner + +**文件**:`frontend/src/pages/PostDetailPage.tsx` + +- 数据加载 useEffect(L170-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 ? ` 早期 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,网络回来后更新。 +- 若不纳入首版范围,至少应用 S2(visibility 兜底)消除转圈 → 内容的切换闪烁。 + +### 3.6 S2 兜底:首帧完成前不可见(index.html + App 层) + +**文件**:`frontend/index.html` +- 在 `` 上加 `class="j13-prepaint"`,对应内联 CSS: + ```css + .j13-prepaint .main-content, + .j13-prepaint .admin-main { visibility: hidden; } + ``` + +**文件**:`frontend/src/hooks/useScrollRestoration.ts`(新建 hook 功能扩展 或 新建 `useFirstFrameReveal.ts`) +- 在布局组件(MainLayout / AdminLayout)mount 后: + - 条件 A:数据已渲染(有缓存或网络已回)且滚动已设好 → 立即 `document.body.classList.remove('j13-prepaint')` + - 条件 B:无缓存且等待网络 → `setTimeout(300ms)` 后强制 reveal,避免白屏 + - 条件 C:rAF 检测到 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 | diff --git a/frontend/src/hooks/useScrollRestoration.ts b/frontend/src/hooks/useScrollRestoration.ts new file mode 100644 index 0000000..fd0e43d --- /dev/null +++ b/frontend/src/hooks/useScrollRestoration.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { restoreScrollPositions } from '../utils/scrollRestore'; + +/** + * 在布局组件挂载时恢复当前 URL 的滚动位置(仅刷新场景生效)。 + * + * 使用 mount-only effect:布局组件在 SPA 内导航时不会重新挂载, + * 因此此 effect 仅在页面实际重载(刷新)或跨布局切换时触发, + * 不会干扰 SPA 内导航的默认滚动行为。 + * + * rAF 重试循环会等待异步内容加载完成后再设置 scrollTop, + * 组件卸载时通过返回的取消函数终止重试。 + */ +export function useScrollRestoration(): void { + useEffect(() => { + const cancel = restoreScrollPositions(); + return cancel; + }, []); +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 704e3bf..964f5f3 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -13,6 +13,7 @@ import BackToTop from '../components/BackToTop'; import { loginPath } from '../utils/authRedirect'; import { useSiteBranding } from '../hooks/useSiteBranding'; import { useNoIndexSEO } from '../hooks/usePageSEO'; +import { useScrollRestoration } from '../hooks/useScrollRestoration'; import SiteBrandMark from '../components/SiteBrandMark'; import { api } from '../api/client'; @@ -80,6 +81,7 @@ export default function AdminLayout() { const { theme, toggle } = useTheme(); const { branding } = useSiteBranding(); useNoIndexSEO('管理后台'); + useScrollRestoration(); const isNarrow = useMediaQuery('(max-width: 768px)'); const [navOpen, setNavOpen] = useState(false); const [pending, setPending] = useState({ posts: 0, comments: 0, reports: 0 }); diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index a8c881a..b928a34 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -21,6 +21,7 @@ import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import RightPanel from '../components/RightPanel'; import BackToTop from '../components/BackToTop'; import { useForumLimits } from '../hooks/useForumLimits'; +import { useScrollRestoration } from '../hooks/useScrollRestoration'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { navigateFeed } from '../utils/feedCache'; import { notify } from '@/lib/notify'; @@ -74,6 +75,7 @@ export default function MainLayout() { const [searchAdvanced, setSearchAdvanced] = useState(false); const feedSort = parseFeedSort(params.get('sort')); const { limits: forumLimits } = useForumLimits(); + useScrollRestoration(); const asideDrawerRef = useRef(null); const asideCloseRef = useRef(null); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9aaf20c..170b922 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { applyTheme, getStoredTheme } from './utils/theme'; +import { initScrollRestore } from './utils/scrollRestore'; import App from './App'; applyTheme(getStoredTheme()); +initScrollRestore(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/utils/scrollRestore.ts b/frontend/src/utils/scrollRestore.ts new file mode 100644 index 0000000..ab49eb3 --- /dev/null +++ b/frontend/src/utils/scrollRestore.ts @@ -0,0 +1,126 @@ +/** + * 全局滚动位置恢复:基于 sessionStorage + pagehide。 + * + * 背景:本应用为固定视口(100dvh + overflow:hidden)SPA,window 不滚动, + * 浏览器原生 scrollRestoration 对内部滚动容器无效。 + * 因此在 pagehide 时主动将各滚动容器的 scrollTop 存入 sessionStorage, + * 页面重载后通过 rAF 重试机制在异步内容加载完成后恢复。 + * + * 适用场景:浏览器刷新(F5 / Ctrl+R / Ctrl+F5)后保持原阅读位置。 + * 不干扰 SPA 内导航的默认滚动行为(布局组件不重新挂载,mount-only effect 不触发)。 + */ + +const STORAGE_PREFIX = 'j13-scroll:'; + +/** 候选滚动容器选择器(覆盖主站与后台各页面) */ +const SCROLL_SELECTORS = [ + '.main-content--feed-mobile-scroll', // 移动端首页 Feed 整栏滚动 + '.post-list-scroll', // 桌面端首页 Feed 列表滚动 + '.page-wrap', // 帖子详情 / 个人主页 / 消息等 + '.admin-main', // 后台主内容区 +] as const; + +type SavedPositions = { + containers: Record; + ts: number; +}; + +function storageKey(url: string): string { + return STORAGE_PREFIX + url; +} + +function getCurrentUrl(): string { + return window.location.pathname + window.location.search; +} + +/** 保存当前页面各滚动容器的位置到 sessionStorage */ +export function saveScrollPositions(url: string = getCurrentUrl()): void { + try { + const containers: Record = {}; + for (const selector of SCROLL_SELECTORS) { + const el = document.querySelector(selector); + if (el && el.scrollTop > 0) { + containers[selector] = Math.round(el.scrollTop); + } + } + if (Object.keys(containers).length === 0) return; + const entry: SavedPositions = { containers, ts: Date.now() }; + sessionStorage.setItem(storageKey(url), JSON.stringify(entry)); + } catch { + // sessionStorage 不可用或已满,静默失败 + } +} + +type CancelFn = () => void; + +/** + * 从 sessionStorage 读取并恢复指定 URL 的滚动位置。 + * 使用 rAF 重试,等待异步内容加载完成后再设置 scrollTop。 + * 返回取消函数,用于在组件卸载时终止重试循环。 + */ +export function restoreScrollPositions(url: string = getCurrentUrl()): CancelFn { + let entry: SavedPositions | null = null; + try { + const raw = sessionStorage.getItem(storageKey(url)); + if (raw) entry = JSON.parse(raw) as SavedPositions; + } catch { + return () => {}; + } + if (!entry?.containers || Object.keys(entry.containers).length === 0) { + return () => {}; + } + + let cancelled = false; + let rafId = 0; + const deadline = performance.now() + 4000; + const pending = Object.entries(entry.containers); + const done = new Set(); + + const tryRestore = (selector: string, target: number): boolean => { + const el = document.querySelector(selector); + if (!el) return false; + // 内容尚未加载到足以滚动到目标位置,等待重试 + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll < target - 1) return false; + el.scrollTop = target; + return Math.abs(el.scrollTop - target) <= 1; + }; + + const tick = () => { + if (cancelled) return; + for (const [selector, target] of pending) { + if (done.has(selector)) continue; + if (tryRestore(selector, target)) { + done.add(selector); + } + } + if (done.size === pending.length || performance.now() > deadline) { + // 全部恢复完成或超时,清除存储条目(避免跨布局切换返回时错误恢复) + try { + sessionStorage.removeItem(storageKey(url)); + } catch { + // ignore + } + return; + } + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + }; +} + +let initialized = false; + +/** 注册全局 pagehide 监听器(在应用入口调用一次) */ +export function initScrollRestore(): void { + if (typeof window === 'undefined' || initialized) return; + initialized = true; + window.addEventListener('pagehide', () => { + saveScrollPositions(); + }); +}