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:
2026-09-10 05:04:31 +08:00
parent 2392768868
commit 3c6d8cb891
13 changed files with 322 additions and 87 deletions

View File

@@ -1,3 +1,5 @@
import { getScope } from '../ssr/scope';
/** 构造带回跳的登录路径 */
export function loginPath(from?: string): string {
const path = sanitizeReturnPath(from ?? currentPath());
@@ -36,7 +38,10 @@ export function navigateAfterAuth(
}
function currentPath(): string {
if (typeof window === 'undefined') return '/';
if (typeof window === 'undefined') {
// SSR:hash 不随请求上送,取请求作用域中的 pathname + search
return getScope()?.url ?? '/';
}
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}

View File

@@ -15,6 +15,8 @@ import type {
SitePageSummary,
TagCount,
User,
UserActivityStats,
UserPublic,
} from '../api/types';
import { seedSiteBrandingCache } from '../hooks/useSiteBranding';
import { seedForumLimitsCache } from '../hooks/useForumLimits';
@@ -117,6 +119,9 @@ export type PostDetailSnapshot = {
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
/** 作者主页资料(右栏作者卡 SSR/预热首帧直接渲染,缺省由卡片自行拉取) */
authorProfile: UserPublic | null;
authorStats: UserActivityStats | null;
scrollTop: number;
};
@@ -125,7 +130,11 @@ export function postDetailCacheKey(id: number): string {
}
/** 由帖子详情 API 响应构建会话快照(客户端 prefetch 与服务端 +data 共用) */
export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Comment[]): PostDetailSnapshot {
export function buildPostDetailSnapshot(
detail: PostDetailResponse,
comments: Comment[],
author?: { user: UserPublic; stats?: UserActivityStats | null } | null,
): PostDetailSnapshot {
return {
post: detail.post,
comments: Array.isArray(comments) ? comments : [],
@@ -141,6 +150,8 @@ export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Co
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
authorProfile: author?.user ?? null,
authorStats: author?.stats ?? null,
scrollTop: 0,
};
}

View File

@@ -127,7 +127,11 @@ async function prefetchPost(id: number, force: boolean): Promise<void> {
api.post(id),
api.comments(id, myIds),
]);
const snap = buildPostDetailSnapshot(detail, comm.comments);
// 作者主页资料一并预热(右栏作者卡首帧直接渲染;失败降级为卡片自行拉取)
const author = detail.post.user_id
? await api.userProfile(detail.post.user_id).catch(() => null)
: null;
const snap = buildPostDetailSnapshot(detail, comm.comments, author);
setSessionSnapshot(key, snap);
}