feat: SSR 请求级作用域(AsyncLocalStorage)+ homeStore 工厂化 + layoutCache 作用域适配

This commit is contained in:
2026-09-09 00:35:19 +08:00
parent 52d328ffc3
commit 86a317c862
6 changed files with 2649 additions and 93 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -38,24 +38,30 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^9.0.0", "diff": "^9.0.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"express": "^5.2.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"http-proxy-middleware": "^4.2.0",
"linkedom": "^0.18.13",
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.3.1",
"react-easy-crop": "^6.0.2", "react-easy-crop": "^6.0.2",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-router": "^8.3.1",
"react-streaming": "^0.4.20",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4", "turndown": "^7.2.4",
"vike": "^0.4.266",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -63,5 +69,8 @@
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.2" "vite": "^8.2.2"
},
"allowScripts": {
"esbuild@0.28.2": true
} }
} }

43
frontend/src/ssr/scope.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* AsyncLocalStorage 携带的请求作用域。
*
* node:async_hooks 仅允许出现在 SSR 打包里:`import.meta.env.SSR` 在客户端构建时
* 会被 Vite 静态替换为 false,整段 if 被消除,`process.getBuiltinModule` 不会执行。
* (process.getBuiltinModule 为 Node 22.3+ 同步 API,避免 top-level await。)
*/
import type { RequestScope } from './scopeTypes';
type AsyncLocalStorageLike = {
run<R>(store: RequestScope, callback: () => R): R;
getStore(): RequestScope | undefined;
};
let als: AsyncLocalStorageLike | null = null;
if (import.meta.env.SSR) {
type NodeProcess = {
getBuiltinModule?: (id: string) => unknown;
};
const mod = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks') as
| (new () => AsyncLocalStorageLike)
| undefined;
if (mod) {
als = new mod();
}
}
/** 在请求作用域内执行回调;无 ALS(客户端/降级)时直接执行 */
export function runWithScope<T>(scope: RequestScope, fn: () => T): T {
if (!als) return fn();
return als.run(scope, fn);
}
/** 当前请求作用域;客户端始终为 null */
export function getScope(): RequestScope | null {
return als?.getStore() ?? null;
}
/** 当前是否处于 SSR 渲染(有请求作用域) */
export function hasRequestScope(): boolean {
return !!als?.getStore();
}

View File

@@ -0,0 +1,41 @@
/**
* SSR 请求级作用域:服务器渲染单个请求期间共享的可变缓存。
*
* 客户端单例(module-level 缓存)在 SSR 多请求并发下会串数据,
* 所有 seed/读取入口必须先经过 getScope() 判断:
* - 服务端:写入/读取 AsyncLocalStorage 携带的 RequestScope
* - 客户端:scope 为 null,回落到原有模块单例行为
*/
import type { User, SiteBranding, ForumLimitsPublic, SitePageSummary } from '../api/types';
import type { CheckInStatus } from '../api/types';
import type { HomeStoreState } from '../store/homeStore';
export type RequestScope = {
/** layoutCache / sessionPageCache / 签到等统一 KV(键与 sessionStorage 键一致) */
kv: Map<string, unknown>;
/** 本请求独立的 feed store(zustand 实例) */
homeStore: HomeStoreState;
/** 鉴权种子(seedAuthFromHomeBoot / peekAuthSeed) */
auth: { hasSeed: boolean; user: User | null; unread: number };
/** useSiteBranding / useForumLimits / useSitePages 的模块缓存对应物 */
branding: SiteBranding | null;
limits: ForumLimitsPublic | null;
pages: SitePageSummary[] | null;
/** 签到快照按 uid 存放 */
checkIns: Map<number, CheckInStatus>;
/** 主题(由 +data 从 cookie 解析) */
theme: 'light' | 'dark';
};
export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | 'dark' = 'light'): RequestScope {
return {
kv: new Map(),
homeStore,
auth: { hasSeed: false, user: null, unread: 0 },
branding: null,
limits: null,
pages: null,
checkIns: new Map(),
theme,
};
}

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create, type StoreApi, type UseBoundStore } from 'zustand';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar'; import type { FeedSort } from '../components/FeedSortBar';
import { getScope } from '../ssr/scope';
/** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */ /** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */
export const FEED_STALE_MS = 5 * 60 * 1000; export const FEED_STALE_MS = 5 * 60 * 1000;
@@ -37,23 +38,23 @@ export function feedCacheKey(parts: FeedKeyParts): string {
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`; return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
} }
type HomeStoreState = { /** zustand store 实例形状(工厂产物与客户端单例一致) */
/** 按筛选键存放多份列表,首页 / 板块 / 搜索互不覆盖 */ export type HomeStoreState = UseBoundStore<StoreApi<HomeStoreApiState>>;
type HomeStoreApiState = {
feeds: Record<string, FeedCacheEntry>; feeds: Record<string, FeedCacheEntry>;
getFeed: (key: string) => FeedCacheEntry | null; getFeed: (key: string) => FeedCacheEntry | null;
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void; setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
/** 滚动时只更新 scrollTop,不改动列表数据 */
patchScroll: (key: string, scrollTop: number) => void; patchScroll: (key: string, scrollTop: number) => void;
isStale: (key: string, now?: number) => boolean; isStale: (key: string, now?: number) => boolean;
clearFeed: (key: string) => void; clearFeed: (key: string) => void;
clearAll: () => void; clearAll: () => void;
}; };
export const useHomeStore = create<HomeStoreState>((set, get) => ({ function createInitialState(set: (fn: (state: HomeStoreApiState) => Partial<HomeStoreApiState>) => void, get: () => HomeStoreApiState): HomeStoreApiState {
return {
feeds: {}, feeds: {},
getFeed: (key) => get().feeds[key] ?? null, getFeed: (key) => get().feeds[key] ?? null,
setFeed: (key, data) => { setFeed: (key, data) => {
set((state) => ({ set((state) => ({
feeds: { feeds: {
@@ -68,7 +69,6 @@ export const useHomeStore = create<HomeStoreState>((set, get) => ({
}, },
})); }));
}, },
patchScroll: (key, scrollTop) => { patchScroll: (key, scrollTop) => {
const prev = get().feeds[key]; const prev = get().feeds[key];
if (!prev) return; if (!prev) return;
@@ -81,13 +81,11 @@ export const useHomeStore = create<HomeStoreState>((set, get) => ({
}, },
})); }));
}, },
isStale: (key, now = Date.now()) => { isStale: (key, now = Date.now()) => {
const entry = get().feeds[key]; const entry = get().feeds[key];
if (!entry) return true; if (!entry) return true;
return now - entry.lastFetchTime > FEED_STALE_MS; return now - entry.lastFetchTime > FEED_STALE_MS;
}, },
clearFeed: (key) => { clearFeed: (key) => {
set((state) => { set((state) => {
if (!(key in state.feeds)) return state; if (!(key in state.feeds)) return state;
@@ -96,11 +94,19 @@ export const useHomeStore = create<HomeStoreState>((set, get) => ({
return { feeds: next }; return { feeds: next };
}); });
}, },
clearAll: () => set(() => ({ feeds: {} })),
clearAll: () => set({ feeds: {} }), };
})); }
/** 非 React 路径(如 feedCache 封装)直接读写 */ /** 每请求新建一个 feed store(SSR 用);客户端在模块加载时建单例 */
export function getHomeStoreState() { export function createHomeStore(): HomeStoreState {
return useHomeStore.getState(); return create<HomeStoreApiState>()((set, get) => createInitialState(set, get));
}
const clientStore: HomeStoreState = createHomeStore();
/** 非 React 路径(feedCache / homeBoot / prefetchRoute / SSR)统一入口:
* SSR 请求期间返回请求级实例,客户端返回模块单例 */
export function getHomeStoreState(): HomeStoreState {
return getScope()?.homeStore ?? clientStore;
} }

View File

@@ -1,4 +1,5 @@
import type { Board, ForumStats, RecentComment, RecentUser, TagCount } from '../api/types'; import type { Board, ForumStats, RecentComment, RecentUser, TagCount } from '../api/types';
import { getScope } from '../ssr/scope';
const BOARDS_KEY = 'j13-cache-boards'; const BOARDS_KEY = 'j13-cache-boards';
const STATS_KEY = 'j13-cache-stats'; const STATS_KEY = 'j13-cache-stats';
@@ -6,7 +7,12 @@ const RECENT_COMMENTS_KEY = 'j13-cache-recent-comments';
const RECENT_USERS_KEY = 'j13-cache-recent-users'; const RECENT_USERS_KEY = 'j13-cache-recent-users';
const TAGS_KEY = 'j13-cache-tags'; const TAGS_KEY = 'j13-cache-tags';
/** SSR 请求期写入 RequestScope.kv;客户端写 sessionStorage */
function readJson<T>(key: string): T | null { function readJson<T>(key: string): T | null {
const scope = getScope();
if (scope) {
return (scope.kv.get(key) as T | undefined) ?? null;
}
try { try {
const raw = sessionStorage.getItem(key); const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null; return raw ? (JSON.parse(raw) as T) : null;
@@ -16,6 +22,11 @@ function readJson<T>(key: string): T | null {
} }
function writeJson(key: string, value: unknown) { function writeJson(key: string, value: unknown) {
const scope = getScope();
if (scope) {
scope.kv.set(key, value);
return;
}
try { try {
sessionStorage.setItem(key, JSON.stringify(value)); sessionStorage.setItem(key, JSON.stringify(value));
} catch { } catch {
@@ -53,6 +64,12 @@ export function getCachedTags(): TagCount[] {
/** 右栏是否已有可展示的 session 缓存(含空列表) */ /** 右栏是否已有可展示的 session 缓存(含空列表) */
export function hasCachedAside(): boolean { export function hasCachedAside(): boolean {
const scope = getScope();
if (scope) {
return scope.kv.has(RECENT_COMMENTS_KEY)
|| scope.kv.has(RECENT_USERS_KEY)
|| scope.kv.has(TAGS_KEY);
}
try { try {
return sessionStorage.getItem(RECENT_COMMENTS_KEY) != null return sessionStorage.getItem(RECENT_COMMENTS_KEY) != null
|| sessionStorage.getItem(RECENT_USERS_KEY) != null || sessionStorage.getItem(RECENT_USERS_KEY) != null