feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复

- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined:
  抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫
- 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到
  各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记
- 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false,
  useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出,
  head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块)
- 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、
  探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image
- Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、
  自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生
  历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView +
  scroll-margin-top 适配 sticky 顶栏
This commit is contained in:
2026-09-09 20:53:28 +08:00
parent 3702e3d1f2
commit 2392768868
39 changed files with 468 additions and 1366 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
import {
useNavigate,
useOutletContext,
@@ -7,17 +7,22 @@ import {
useParams,
useNavigationType,
} from 'react-router';
import { Inbox, SearchX } from 'lucide-react';
import { notify } from '@/lib/notify';
import { Button } from '@/components/ui/button';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import StaticFeedList from '../components/StaticFeedList';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { InFlowSiteFooter } from '../components/SiteFooter';
import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import { parseSearchFromUrl, usePostSearch, dispatchOpenPostSearch } from '../hooks/usePostSearch';
import {
navigateFeed,
FEED_RESET_EVENT,
@@ -29,7 +34,6 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
import { openForumPost } from '../utils/openPost';
import { startTransition, doneTransition } from '../utils/spaTransition';
import { isHomeHydrating } from '../utils/homeHydrate';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
@@ -49,7 +53,6 @@ type FeedHydrate = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
loading: boolean;
};
@@ -69,11 +72,10 @@ function readHydrate(
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
};
}
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
return { posts: [], postTotal: 0, page: 1, loading: true };
}
export default function HomePage() {
@@ -83,6 +85,7 @@ export default function HomePage() {
const { id: boardRouteId } = useParams();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
const { user } = useAuth();
const { branding } = useSiteBranding();
const { limits, loading: limitsLoading } = useForumLimits();
const postSearch = usePostSearch(limits);
@@ -187,25 +190,14 @@ export default function HomePage() {
titleOnly,
});
const [listPending, setListPending] = useState(false);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
initial.posts.length > 0 ? initial.scrollTop : null,
);
/** 翻页 / 切筛选 / 强制刷新后置顶信号(由布局 effect 消费) */
const [listResetKey, setListResetKey] = useState(0);
/** hydrate 首帧用静态列表;完成后升级 VirtualPostList */
const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating());
useEffect(() => {
if (useVirtualList) return;
reactStartTransition(() => setUseVirtualList(true));
}, [useVirtualList]);
const scrollTopRef = useRef(initial.scrollTop);
const fetchSeqRef = useRef(0);
const pageRef = useRef(initial.page);
const cacheKeyRef = useRef(cacheKey);
const viewKeyRef = useRef(view.cacheKey);
const postsRef = useRef(posts);
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
const hydratedKeyRef = useRef<string | null>(null);
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
@@ -223,8 +215,6 @@ export default function HomePage() {
setPostTotal(next.postTotal);
setPage(next.page);
pageRef.current = next.page;
scrollTopRef.current = next.scrollTop;
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
setLoading(next.loading);
setListPending(false);
setView(meta);
@@ -254,10 +244,10 @@ export default function HomePage() {
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
const hasMore = page < totalPages;
// 首帧静态列表与 VirtualPostList 同规则:无更多页、无分页、有帖子且非加载中时显示结束提示
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const resetFeedView = useCallback(() => {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
}, []);
@@ -266,12 +256,12 @@ export default function HomePage() {
resetFeedView();
}, [resetFeedView]);
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
/** 把当前列表写入 Zustand */
const persistFeed = useCallback((
nextPosts: PostItem[],
nextTotal: number,
nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
opts?: { touchFetchTime?: boolean; key?: string },
) => {
const key = opts?.key ?? cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key);
@@ -279,7 +269,6 @@ export default function HomePage() {
posts: nextPosts,
postTotal: nextTotal,
page: nextPage,
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
lastFetchTime: opts?.touchFetchTime === false
? (prev?.lastFetchTime ?? Date.now())
: Date.now(),
@@ -309,10 +298,7 @@ export default function HomePage() {
const batch = Array.isArray(data.posts) ? data.posts : [];
const total = data.total ?? 0;
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
if (jumpTop) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
}
setPosts(batch);
@@ -320,7 +306,7 @@ export default function HomePage() {
setPage(p);
pageRef.current = p;
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
persistFeed(batch, total, p, { touchFetchTime: true, key: requestKey });
} catch (e: unknown) {
if (seq !== fetchSeqRef.current) return;
if (!silent) {
@@ -352,8 +338,6 @@ export default function HomePage() {
}, [fetchPage, postTotal, pageSize]);
const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
@@ -399,7 +383,6 @@ export default function HomePage() {
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
@@ -415,10 +398,6 @@ export default function HomePage() {
}
hydratedKeyRef.current = cacheKey;
if (postsRef.current.length === 0) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
}
loadFirst();
}, [
limitsLoading,
@@ -466,7 +445,6 @@ export default function HomePage() {
posts: warm.posts,
postTotal: warm.postTotal,
page: warm.page,
scrollTop: 0,
loading: false,
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
setListResetKey((k) => k + 1);
@@ -495,20 +473,21 @@ export default function HomePage() {
sort, boardId, keyword, tag, author, titleOnly,
]);
// 卸载时取消未执行的 scroll rAF
useEffect(() => () => {
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
}, []);
// 滚动位置完全交给浏览器原生历史恢复(feed 为文档滚动,见 .app-shell--doc-scroll):
// - F5 整页刷新:SSR 输出静态 DOM + 响应头 no-cache,浏览器按历史记录恢复窗口滚动;
// - SPA 前进/后退:列表 DOM 在 SSR/hydrate/客户端全程同构,浏览器同样自动恢复。
// 仅翻页 / 切筛选 / 强制刷新这类「换新内容」场景主动置顶。
useLayoutEffect(() => {
if (listResetKey <= 0) return;
window.scrollTo(0, 0);
}, [listResetKey]);
const handleScrollTopChange = useCallback((top: number) => {
scrollTopRef.current = top;
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
if (scrollRafRef.current) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = 0;
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
});
}, []);
// PUSH 进入新筛选(切板块 / 搜索 / 标签):文档滚动置顶;
// POP(F5 初始载入、后退前进)不干预,交给浏览器原生恢复。
useEffect(() => {
if (navType === 'POP') return;
window.scrollTo(0, 0);
}, [cacheKey, navType]);
const handleSortChange = (next: FeedSort) => {
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
@@ -527,6 +506,32 @@ export default function HomePage() {
const searchFilters = parseSearchFromUrl(location.pathname, params);
const isSearchActive = !!(view.keyword || view.author);
// 空态(加载中且无列表时已在上方 return null,此处只处理真空态)
const viewBoardName = ctx?.boards?.find(b => b.id === view.boardId)?.name || '';
const noBoards = !ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0;
const isSearchEmpty = posts.length === 0 && !loading
&& (isSearchActive || !!view.tag || !!(view.keyword || '').trim());
const composeTarget = view.boardId > 0 ? `/compose?board=${view.boardId}` : '/compose';
const isAdmin = user?.role === 'admin';
const searchSummaryParts: string[] = [];
const kw = (view.keyword || view.tag || '').trim();
if (kw) searchSummaryParts.push(`关键词「${kw}」`);
if (view.author?.trim()) searchSummaryParts.push(`作者「${view.author.trim()}」`);
if (view.titleOnly) searchSummaryParts.push('仅标题');
if (searchFilters.scopeBoardId) {
const bn = ctx?.boards?.find(b => b.id === searchFilters.scopeBoardId)?.name || '';
if (bn) searchSummaryParts.push(`板块「${bn}」`);
}
const emptyHint = noBoards
? (isAdmin
? '网站还没有任何板块。你可以创建第一个板块,给帖子们一个归属。'
: '网站刚刚创建,管理员还没有添加板块。稍后再来看看吧。')
: isSearchEmpty
? `${searchSummaryParts.join(' · ')}${searchSummaryParts.length ? '。' : ''}换个关键词试试,或清除当前筛选条件。`
: (view.boardId > 0
? `板块「${viewBoardName}」还没有帖子。看看其他板块,或发表这个板块的第一帖。`
: '成为第一个发帖的人,开始讨论吧。');
if (isInvalidBoardRoute || isMissingBoard) {
return (
<NotFoundPage
@@ -546,25 +551,13 @@ export default function HomePage() {
<div className="feed-panel">
<div className="feed-top">
<div className="feed-top__bar">
{!useVirtualList ? (
(view.keyword || view.tag || view.author) ? (
<h1 className="feed-header-title">
{view.tag
? `#${view.tag}`
: view.keyword
? `搜索:${view.keyword}`
: `作者:${view.author}`}
</h1>
) : null
) : (
<FeedHeader
keyword={view.keyword}
tag={view.tag}
author={view.author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
)}
<FeedHeader
keyword={view.keyword}
tag={view.tag}
author={view.author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
{showSortBar && (
<FeedSortBar
value={view.sort}
@@ -583,36 +576,79 @@ export default function HomePage() {
/>
)}
</div>
{!useVirtualList ? (
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
) : (
<VirtualPostList
posts={posts}
sort={view.sort}
loading={listPending ? false : (loading || limitsLoading)}
hasMore={hasMore}
showPagination={showPagination}
page={page}
totalPages={totalPages}
postTotal={postTotal}
onPageChange={goToPage}
onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={handleScrollTopChange}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={view.keyword || view.tag || view.author}
isSearchMode={!!(view.keyword || view.author)}
searchKeyword={view.keyword}
searchAuthor={view.author}
searchTitleOnly={view.titleOnly}
searchScopeBoardId={searchFilters.scopeBoardId}
onClearSearch={postSearch.clearSearch}
boardId={view.boardId}
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/>
)}
{/*
列表始终静态流式渲染:SSR 输出与 hydration 后 DOM 完全一致(不包 Suspense、
不切虚拟列表),使浏览器原生 scroll restoration 在 F5 后直接生效,
也避免「JS 执行后重渲染内容区」被 SEO 工具误判为 CSR。
*/}
<div className="post-list-scroll post-list-scroll--ssr">
{posts.length === 0 ? (
<div className="empty-feed" role="status">
{isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
<p>{noBoards ? '暂无板块' : isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
<p className="empty-feed-hint">{emptyHint}</p>
<div className="empty-feed-actions">
{noBoards ? (
isAdmin ? (
<Button variant="default" onClick={() => nav('/admin#boards')}>去创建板块</Button>
) : (
<Button variant="ghost" onClick={() => nav('/')}>刷新看看</Button>
)
) : isSearchEmpty ? (
<>
<Button variant="default" onClick={() => dispatchOpenPostSearch()}>修改搜索</Button>
<Button variant="ghost" onClick={postSearch.clearSearch}>清除筛选条件</Button>
<Button variant="ghost" onClick={() => nav('/')}>返回全部帖子</Button>
</>
) : view.boardId > 0 ? (
<>
<Button variant="ghost" onClick={() => nav('/')}>看看其他板块</Button>
{user ? (
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
) : (
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
)}
</>
) : user ? (
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
) : (
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
)}
</div>
</div>
) : (
<>
<div className="content-surface content-surface--ssr">
{posts.map((post) => (
<PostListItem
key={post.id}
post={post}
sort={view.sort}
boardId={view.boardId}
onSelect={handleSelectPost}
/>
))}
</div>
{showPagination && (
<div className="feed-list-footer feed-list-footer--pagination">
<FeedPagination
page={page}
totalPages={totalPages}
postTotal={postTotal}
loading={loading}
onPageChange={goToPage}
/>
</div>
)}
{showEnd && (
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
)}
<InFlowSiteFooter />
</>
)}
</div>
</div>
</div>
);

View File

@@ -54,7 +54,6 @@ import { loadMyCommentIds } from '../utils/guest';
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
import { loginPath } from '../utils/authRedirect';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
@@ -79,7 +78,6 @@ type PostDetailSnapshot = {
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
function postDetailCacheKey(id: number) {
@@ -145,24 +143,15 @@ export default function PostDetailPage() {
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
/** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => {
if (!initialSnap) return null;
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
return 0;
});
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const postRef = useRef<PostItem | null>(null);
const scrollTopRef = useRef(
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
);
postRef.current = post;
useGlobalWheelScroll(pageRef, !loading && !!post);
// 滚动位置完全交给浏览器原生历史恢复(详情页为文档滚动,见 .app-shell--doc-scroll),
// 会话快照只缓存数据让 POP 后退时内容即时渲染、文档即刻够高,原生恢复才有正确落点。
// SPA 内跳转时纠正非规范伪静态路径
useEffect(() => {
@@ -212,7 +201,7 @@ export default function PostDetailPage() {
}
setPostOutline({
headings,
scrollRoot: pageRef.current,
scrollRoot: null,
title: '文章目录',
author: post.user ?? null,
publishedAt: post.created_at,
@@ -238,15 +227,9 @@ export default function PostDetailPage() {
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
setComments(snap.comments);
if (opts?.restoreScroll) {
scrollTopRef.current = snap.scrollTop;
setRestoreScrollTop(snap.scrollTop);
} else {
scrollTopRef.current = 0;
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
const el = pageRef.current;
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
}
// POP(后退/前进)不干预,交给浏览器原生恢复;
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
if (!opts?.restoreScroll) window.scrollTo(0, 0);
}, []);
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
@@ -304,15 +287,9 @@ export default function PostDetailPage() {
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
setSessionSnapshot(postDetailCacheKey(postId), snap);
applySnapshot(snap, { restoreScroll: false });
if (mode === 'force') {
const el = pageRef.current;
if (el) el.scrollTop = 0;
scrollTopRef.current = 0;
}
void refresh();
} catch {
if (seq !== loadSeq.current) return;
@@ -342,23 +319,6 @@ export default function PostDetailPage() {
};
}, [postId, applySnapshot]);
useEffect(() => {
const el = pageRef.current;
if (!el || loading || !post) return;
const onScroll = () => {
scrollTopRef.current = el.scrollTop;
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [loading, post]);
useLayoutEffect(() => {
if (restoreScrollTop == null || !pageRef.current || loading || !post) return;
pageRef.current.scrollTop = restoreScrollTop;
scrollTopRef.current = restoreScrollTop;
setRestoreScrollTop(null);
}, [restoreScrollTop, loading, post]);
useEffect(() => {
if (!post || post.id !== postId || loading) return;
setSessionSnapshot(postDetailCacheKey(post.id), {
@@ -375,7 +335,6 @@ export default function PostDetailPage() {
bountyCanRefund,
bountyRefundBlockReason,
bountyEligibleReplyCount,
scrollTop: scrollTopRef.current,
});
}, [
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
@@ -383,14 +342,6 @@ export default function PostDetailPage() {
bountyEligibleReplyCount, postId, loading,
]);
useEffect(() => () => {
const p = postRef.current;
if (!p) return;
const prev = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(p.id));
if (!prev) return;
setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current });
}, []);
const reloadComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
@@ -460,102 +411,55 @@ export default function PostDetailPage() {
return () => root.removeEventListener('focusout', onFocusOut);
}, [isMobile, composerOpen]);
/**
* 详情页为窗口滚动:scroll-margin-top 预留 sticky 顶栏空间。
* 首屏图片/字体加载会持续改变布局,落点需短暂校正;用户手动滚动立即让位。
*/
const scrollAnchorIntoView = useCallback((el: HTMLElement) => {
el.scrollIntoView({ block: 'start' });
let interrupted = false;
const yieldToUser = () => { interrupted = true; };
window.addEventListener('wheel', yieldToUser, { passive: true });
window.addEventListener('touchmove', yieldToUser, { passive: true });
const start = Date.now();
(function correct() {
window.removeEventListener('wheel', yieldToUser);
window.removeEventListener('touchmove', yieldToUser);
if (interrupted || !el.isConnected || Date.now() - start > 1500) return;
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
if (Math.abs(el.getBoundingClientRect().top - margin) > 4) {
el.scrollIntoView({ block: 'start' });
}
window.addEventListener('wheel', yieldToUser, { passive: true });
window.addEventListener('touchmove', yieldToUser, { passive: true });
window.setTimeout(correct, 120);
})();
}, []);
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return false;
const root = pageRef.current;
if (!root) return false;
// 直接计算目标滚动位置
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const relativeTop = elRect.top - rootRect.top;
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
const targetTop = root.scrollTop + relativeTop - 24;
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
if (!isFinite(targetTop) || targetTop < -500) return false;
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
// 直接设置滚动位置(不使用平滑滚动,避免动画干扰)
root.scrollTop = clampedTop;
// 验证滚动结果,如果不准确则再次调整
// 注意:使用动态计算的 currentMaxScrollTop,因为评论可能在之后才渲染完成
const verifyAndFix = () => {
const currentElRect = el.getBoundingClientRect();
const currentRootRect = root.getBoundingClientRect();
const currentRelativeTop = currentElRect.top - currentRootRect.top;
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
// 如果元素在视口内但位置不准确,进行修正
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 24) > 10) {
const correction = currentRelativeTop - 24;
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
root.scrollTop = newTarget;
}
};
// 在多个时间点验证和调整
requestAnimationFrame(verifyAndFix);
setTimeout(verifyAndFix, 50);
setTimeout(verifyAndFix, 150);
scrollAnchorIntoView(el);
setHighlightFloor(floor);
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
return true;
}, []);
}, [scrollAnchorIntoView]);
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
/** 正文标题锚点:等正文进 DOM 后由调用方重试 */
const jumpToHeadingHash = useCallback((hash: string) => {
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
if (!id || /^floor-\d+$/.test(id)) return false;
const el = document.getElementById(id);
if (!el) return false;
const root = pageRef.current;
if (!root) return false;
// 直接计算目标滚动位置
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const relativeTop = elRect.top - rootRect.top;
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
const targetTop = root.scrollTop + relativeTop - 12;
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
if (!isFinite(targetTop) || targetTop < -500) return false;
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
// 直接设置滚动位置
root.scrollTop = clampedTop;
// 验证滚动结果,如果不准确则再次调整
const verifyAndFix = () => {
const currentElRect = el.getBoundingClientRect();
const currentRootRect = root.getBoundingClientRect();
const currentRelativeTop = currentElRect.top - currentRootRect.top;
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 12) > 10) {
const correction = currentRelativeTop - 12;
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
root.scrollTop = newTarget;
}
};
requestAnimationFrame(verifyAndFix);
if (smooth) {
setTimeout(verifyAndFix, 50);
setTimeout(verifyAndFix, 150);
}
scrollAnchorIntoView(el);
return true;
}, []);
}, [scrollAnchorIntoView]);
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
useEffect(() => {
@@ -597,7 +501,7 @@ export default function PostDetailPage() {
const tryJump = () => {
if (cancelled) return;
if (jumpToHeadingHash(hash, false)) return;
if (jumpToHeadingHash(hash)) return;
attempts += 1;
if (attempts < 30) {
timer = window.setTimeout(tryJump, 50);
@@ -946,7 +850,7 @@ export default function PostDetailPage() {
};
return (
<article className="page-wrap post-detail-page" ref={pageRef}>
<article className="page-wrap post-detail-page">
<div className="post-detail-header">
<div className="post-detail-nav">
<div className="post-detail-nav-left">
@@ -1083,7 +987,6 @@ export default function PostDetailPage() {
<summary>文章目录({headings.length})</summary>
<ArticleOutline
headings={headings}
scrollRoot={pageRef.current}
title="目录"
/>
</details>