feat(ssr): 帖子页全量 SSR——作者卡/目录首帧服务端渲染,消除冗余首屏请求
- 帖子快照携带作者主页资料与统计(authorProfile/authorStats), SSR 端与客户端预热均取 /api/users/:id;PostAuthorCard 首帧直接渲染, 不再挂载后请求,统计数字不再显示 — 占位 - MainLayout 首帧从已 seed 的帖子快照派生右栏 outline(作者卡+文章目录), 修复 outline 仅靠 effect 上报导致 SSR 不渲染右栏的问题 - 壳层首屏请求门控:useAuth 有种子跳过 /api/me、branding 缓存命中不重拉、 侧栏三个区块首屏用 seed 缓存、未读数首屏跳过即时请求(轮询/事件刷新保留) - serverDom(linkedom) 修复:DOMParser 片段包裹、属性顺序、SVG 自闭合展开 - 验证:首页与帖子页 hydration 后仅剩 pageview 上报,0 hydration 错误
This commit is contained in:
@@ -33,7 +33,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types';
|
||||
import type { PostItem, Comment, PollView, PostLotteryView, ReportReason } from '../api/types';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
@@ -48,7 +48,7 @@ import ArticleOutline from '../components/ArticleOutline';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { collectCommentSubtreeIds } from '../utils/comment';
|
||||
import { loadMyCommentIds } from '../utils/guest';
|
||||
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||
@@ -56,6 +56,7 @@ import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
import { buildPostDetailSnapshot, postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
|
||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
@@ -64,26 +65,6 @@ import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
type PostDetailSnapshot = {
|
||||
post: PostItem;
|
||||
comments: Comment[];
|
||||
poll: PollView | null;
|
||||
lottery: PostLotteryView | null;
|
||||
liked: boolean;
|
||||
favorited: boolean;
|
||||
canEdit: boolean;
|
||||
isEdited: boolean;
|
||||
editBlockReason: string;
|
||||
editWindowHours: number;
|
||||
bountyCanRefund: boolean;
|
||||
bountyRefundBlockReason: string;
|
||||
bountyEligibleReplyCount: number;
|
||||
};
|
||||
|
||||
function postDetailCacheKey(id: number) {
|
||||
return `post:${id}`;
|
||||
}
|
||||
|
||||
/** 格式化剩余可编辑时间 */
|
||||
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
if (windowHours <= 0) return '';
|
||||
@@ -143,6 +124,8 @@ 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 [authorProfile, setAuthorProfile] = useState<PostDetailSnapshot['authorProfile']>(initialSnap?.authorProfile ?? null);
|
||||
const [authorStats, setAuthorStats] = useState<PostDetailSnapshot['authorStats']>(initialSnap?.authorStats ?? null);
|
||||
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
const commentBoxRef = useRef<HTMLDivElement>(null);
|
||||
@@ -206,9 +189,11 @@ export default function PostDetailPage() {
|
||||
author: post.user ?? null,
|
||||
publishedAt: post.created_at,
|
||||
viewCount: post.view_count,
|
||||
authorProfile,
|
||||
authorStats,
|
||||
});
|
||||
return () => setPostOutline(null);
|
||||
}, [headings, loading, post, setPostOutline]);
|
||||
}, [headings, loading, post, authorProfile, authorStats, setPostOutline]);
|
||||
|
||||
const loadSeq = useRef(0);
|
||||
const detailPath = postPath(postId, limits);
|
||||
@@ -226,6 +211,8 @@ export default function PostDetailPage() {
|
||||
setBountyCanRefund(snap.bountyCanRefund);
|
||||
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
||||
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
||||
setAuthorProfile(snap.authorProfile ?? null);
|
||||
setAuthorStats(snap.authorStats ?? null);
|
||||
setComments(snap.comments);
|
||||
// POP(后退/前进)不干预,交给浏览器原生恢复;
|
||||
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
|
||||
@@ -273,21 +260,11 @@ export default function PostDetailPage() {
|
||||
]);
|
||||
if (seq !== loadSeq.current) return;
|
||||
const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
|
||||
const snap: PostDetailSnapshot = {
|
||||
post: detail.post,
|
||||
comments: commentsList,
|
||||
poll: detail.poll ?? null,
|
||||
lottery: detail.lottery ?? null,
|
||||
liked: detail.liked,
|
||||
favorited: detail.favorited,
|
||||
canEdit: detail.can_edit ?? false,
|
||||
isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
|
||||
editBlockReason: detail.edit_block_reason ?? '',
|
||||
editWindowHours: detail.post_edit_window_hours ?? 0,
|
||||
bountyCanRefund: detail.bounty_can_refund ?? true,
|
||||
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
||||
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
||||
};
|
||||
// 作者主页资料一并拉取(右栏作者卡首帧完整;失败降级为卡片自行拉取)
|
||||
const authorResp = detail.post.user_id
|
||||
? await api.userProfile(detail.post.user_id).catch(() => null)
|
||||
: null;
|
||||
const snap = buildPostDetailSnapshot(detail, commentsList, authorResp);
|
||||
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
||||
applySnapshot(snap, { restoreScroll: false });
|
||||
void refresh();
|
||||
@@ -335,11 +312,14 @@ export default function PostDetailPage() {
|
||||
bountyCanRefund,
|
||||
bountyRefundBlockReason,
|
||||
bountyEligibleReplyCount,
|
||||
authorProfile,
|
||||
authorStats,
|
||||
scrollTop: 0,
|
||||
});
|
||||
}, [
|
||||
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
||||
editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason,
|
||||
bountyEligibleReplyCount, postId, loading,
|
||||
bountyEligibleReplyCount, authorProfile, authorStats, postId, loading,
|
||||
]);
|
||||
|
||||
const reloadComments = useCallback(async () => {
|
||||
|
||||
Reference in New Issue
Block a user