feat: SSR 请求级作用域(AsyncLocalStorage)+ homeStore 工厂化 + layoutCache 作用域适配
This commit is contained in:
43
frontend/src/ssr/scope.ts
Normal file
43
frontend/src/ssr/scope.ts
Normal 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();
|
||||
}
|
||||
41
frontend/src/ssr/scopeTypes.ts
Normal file
41
frontend/src/ssr/scopeTypes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
import { getScope } from '../ssr/scope';
|
||||
|
||||
/** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */
|
||||
export const FEED_STALE_MS = 5 * 60 * 1000;
|
||||
@@ -37,70 +38,75 @@ export function feedCacheKey(parts: FeedKeyParts): string {
|
||||
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>;
|
||||
getFeed: (key: string) => FeedCacheEntry | null;
|
||||
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
|
||||
/** 滚动时只更新 scrollTop,不改动列表数据 */
|
||||
patchScroll: (key: string, scrollTop: number) => void;
|
||||
isStale: (key: string, now?: number) => boolean;
|
||||
clearFeed: (key: string) => void;
|
||||
clearAll: () => void;
|
||||
};
|
||||
|
||||
export const useHomeStore = create<HomeStoreState>((set, get) => ({
|
||||
feeds: {},
|
||||
|
||||
getFeed: (key) => get().feeds[key] ?? null,
|
||||
|
||||
setFeed: (key, data) => {
|
||||
set((state) => ({
|
||||
feeds: {
|
||||
...state.feeds,
|
||||
[key]: {
|
||||
posts: data.posts,
|
||||
postTotal: data.postTotal,
|
||||
page: data.page,
|
||||
scrollTop: data.scrollTop,
|
||||
lastFetchTime: data.lastFetchTime ?? Date.now(),
|
||||
function createInitialState(set: (fn: (state: HomeStoreApiState) => Partial<HomeStoreApiState>) => void, get: () => HomeStoreApiState): HomeStoreApiState {
|
||||
return {
|
||||
feeds: {},
|
||||
getFeed: (key) => get().feeds[key] ?? null,
|
||||
setFeed: (key, data) => {
|
||||
set((state) => ({
|
||||
feeds: {
|
||||
...state.feeds,
|
||||
[key]: {
|
||||
posts: data.posts,
|
||||
postTotal: data.postTotal,
|
||||
page: data.page,
|
||||
scrollTop: data.scrollTop,
|
||||
lastFetchTime: data.lastFetchTime ?? Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
patchScroll: (key, scrollTop) => {
|
||||
const prev = get().feeds[key];
|
||||
if (!prev) return;
|
||||
// 数值未变则跳过,减少无意义的 store 更新
|
||||
if (prev.scrollTop === scrollTop) return;
|
||||
set((state) => ({
|
||||
feeds: {
|
||||
...state.feeds,
|
||||
[key]: { ...prev, scrollTop },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
isStale: (key, now = Date.now()) => {
|
||||
const entry = get().feeds[key];
|
||||
if (!entry) return true;
|
||||
return now - entry.lastFetchTime > FEED_STALE_MS;
|
||||
},
|
||||
|
||||
clearFeed: (key) => {
|
||||
set((state) => {
|
||||
if (!(key in state.feeds)) return state;
|
||||
const next = { ...state.feeds };
|
||||
delete next[key];
|
||||
return { feeds: next };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => set({ feeds: {} }),
|
||||
}));
|
||||
|
||||
/** 非 React 路径(如 feedCache 封装)直接读写 */
|
||||
export function getHomeStoreState() {
|
||||
return useHomeStore.getState();
|
||||
}));
|
||||
},
|
||||
patchScroll: (key, scrollTop) => {
|
||||
const prev = get().feeds[key];
|
||||
if (!prev) return;
|
||||
// 数值未变则跳过,减少无意义的 store 更新
|
||||
if (prev.scrollTop === scrollTop) return;
|
||||
set((state) => ({
|
||||
feeds: {
|
||||
...state.feeds,
|
||||
[key]: { ...prev, scrollTop },
|
||||
},
|
||||
}));
|
||||
},
|
||||
isStale: (key, now = Date.now()) => {
|
||||
const entry = get().feeds[key];
|
||||
if (!entry) return true;
|
||||
return now - entry.lastFetchTime > FEED_STALE_MS;
|
||||
},
|
||||
clearFeed: (key) => {
|
||||
set((state) => {
|
||||
if (!(key in state.feeds)) return state;
|
||||
const next = { ...state.feeds };
|
||||
delete next[key];
|
||||
return { feeds: next };
|
||||
});
|
||||
},
|
||||
clearAll: () => set(() => ({ feeds: {} })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 每请求新建一个 feed store(SSR 用);客户端在模块加载时建单例 */
|
||||
export function createHomeStore(): HomeStoreState {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Board, ForumStats, RecentComment, RecentUser, TagCount } from '../api/types';
|
||||
import { getScope } from '../ssr/scope';
|
||||
|
||||
const BOARDS_KEY = 'j13-cache-boards';
|
||||
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 TAGS_KEY = 'j13-cache-tags';
|
||||
|
||||
/** SSR 请求期写入 RequestScope.kv;客户端写 sessionStorage */
|
||||
function readJson<T>(key: string): T | null {
|
||||
const scope = getScope();
|
||||
if (scope) {
|
||||
return (scope.kv.get(key) as T | undefined) ?? null;
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
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) {
|
||||
const scope = getScope();
|
||||
if (scope) {
|
||||
scope.kv.set(key, value);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
@@ -53,6 +64,12 @@ export function getCachedTags(): TagCount[] {
|
||||
|
||||
/** 右栏是否已有可展示的 session 缓存(含空列表) */
|
||||
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 {
|
||||
return sessionStorage.getItem(RECENT_COMMENTS_KEY) != null
|
||||
|| sessionStorage.getItem(RECENT_USERS_KEY) != null
|
||||
|
||||
Reference in New Issue
Block a user