refactor(core): introduce EventBus and refactor store infrastructure
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m46s
Frontend CI / ota-android (push) Successful in 10m34s
Frontend CI / build-android-apk (push) Successful in 1h15m8s

- Add EventBus for decoupled event-driven communication between services
- Add EventSubscriber component for centralized event handling
- Add requestDedupe utility for shared request deduplication
- Refactor api/wsService to use eventBus instead of direct router navigation
- Extract MessageMapper.toCachedMessages for consistent message mapping
- Remove deprecated BaseManager and CacheBus classes
- Improve @mention rendering with memberMap support in segment rendering
- Update hooks to use useRef instead of useState for fetch tracking
This commit is contained in:
lafay
2026-04-12 18:14:29 +08:00
parent 4b5ce1ba21
commit 6610d2f173
28 changed files with 378 additions and 512 deletions

View File

@@ -1,127 +0,0 @@
/**
* BaseManager - 通用 Manager 基类
*
* 提取自 postManager/groupManager/userManager 的重复逻辑:
* - 请求去重 (dedupe)
* - 缓存条目管理 (CacheEntry)
* - 过期检查 (isExpired)
*/
import { CacheBus, CacheEvent } from './cacheBus';
/** 缓存条目结构 */
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
/** 创建缓存条目 */
export function createCacheEntry<T>(data: T, ttl: number): CacheEntry<T> {
return { data, timestamp: Date.now(), ttl };
}
/** 检查缓存是否过期 */
export function isCacheExpired(entry: CacheEntry<any>): boolean {
return Date.now() - entry.timestamp > entry.ttl;
}
/**
* 通用 Manager 基类
*
* 子类只需关注业务逻辑,无需重复实现缓存和去重机制
*/
export abstract class BaseManager<
TSnapshot,
TEvent extends CacheEvent = CacheEvent
> extends CacheBus<TSnapshot, TEvent> {
/** 待处理请求 Map用于去重 */
protected pendingRequests = new Map<string, Promise<any>>();
/**
* 请求去重:相同 key 的并发请求会被合并为一个
* @param key 唯一标识
* @param fetcher 实际请求函数
*/
protected async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
if (pending) return pending;
const request = fetcher().finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, request);
return request;
}
/** 清除所有待处理请求 */
protected clearPendingRequests(): void {
this.pendingRequests.clear();
}
}
/**
* 带缓存的 Manager 基类
*
* 进一步封装内存缓存逻辑
*/
export abstract class CachedManager<
TSnapshot,
TEvent extends CacheEvent = CacheEvent
> extends BaseManager<TSnapshot, TEvent> {
/** 内存缓存 Map */
protected memoryCache = new Map<string, CacheEntry<any>>();
/**
* 获取缓存
* @param key 缓存 key
* @returns 缓存数据,过期或不存在返回 null
*/
protected getFromCache<T>(key: string): T | null {
const entry = this.memoryCache.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (isCacheExpired(entry)) {
this.memoryCache.delete(key);
return null;
}
return entry.data;
}
/**
* 设置缓存
* @param key 缓存 key
* @param data 数据
* @param ttl 过期时间(毫秒)
*/
protected setCache<T>(key: string, data: T, ttl: number): void {
this.memoryCache.set(key, createCacheEntry(data, ttl));
}
/**
* 检查缓存是否存在且未过期
*/
protected hasValidCache(key: string): boolean {
const entry = this.memoryCache.get(key);
if (!entry) return false;
return !isCacheExpired(entry);
}
/**
* 检查缓存是否存在但已过期
*/
protected hasExpiredCache(key: string): boolean {
const entry = this.memoryCache.get(key);
if (!entry) return false;
return isCacheExpired(entry);
}
/** 清除所有缓存 */
protected clearCache(): void {
this.memoryCache.clear();
}
/** 清除指定 key 的缓存 */
protected deleteCache(key: string): void {
this.memoryCache.delete(key);
}
}

View File

@@ -1,52 +0,0 @@
export type CacheEventType =
| 'list_updated'
| 'detail_updated'
| 'cache_state_updated'
| 'error';
export interface CacheEvent<TPayload = any> {
type: CacheEventType;
payload: TPayload;
timestamp: number;
}
export type CacheSubscriber<TEvent extends CacheEvent = CacheEvent> = (event: TEvent) => void;
/**
* 统一缓存总线基座:
* - 单一订阅入口
* - 统一事件通知
* - 新订阅立即收到快照
*/
export abstract class CacheBus<TSnapshot, TEvent extends CacheEvent = CacheEvent> {
private subscribers = new Set<CacheSubscriber<TEvent>>();
protected abstract getSnapshot(): TSnapshot;
protected notify(event: TEvent): void {
this.subscribers.forEach((subscriber) => {
try {
subscriber(event);
} catch (error) {
console.error('[CacheBus] subscriber error:', error);
}
});
}
subscribe(subscriber: CacheSubscriber<TEvent>): () => void {
this.subscribers.add(subscriber);
this.emitInitialSnapshot(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
}
protected emitInitialSnapshot(subscriber: CacheSubscriber<TEvent>): void {
subscriber({
type: 'cache_state_updated',
payload: this.getSnapshot(),
timestamp: Date.now(),
} as TEvent);
}
}

View File

@@ -8,6 +8,7 @@ import { groupService } from '../../services/groupService';
import { groupCacheRepository, userCacheRepository } from '@/database';
import { useGroupManagerStore } from './groupStore';
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
import { createDedupe } from '../utils/requestDedupe';
import {
IGroupListPagedSource,
IGroupMemberListPagedSource,
@@ -18,6 +19,8 @@ import {
GROUP_MEMBER_LIST_PAGE_SIZE,
} from '../groupListSources';
const dedupe = createDedupe(() => useGroupManagerStore);
function buildMembersKey(groupId: string, page: number, pageSize: number): string {
return `members:${groupId}:${page}:${pageSize}`;
}
@@ -74,7 +77,7 @@ class GroupManager {
}
}
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => {
return dedupe(`groups:list:${page}:${pageSize}`, async () => {
const store = useGroupManagerStore.getState();
const response = await groupService.getGroups(page, pageSize);
const groups = response.list || [];
@@ -87,7 +90,7 @@ class GroupManager {
}
private refreshGroupsInBackground(page = 1, pageSize = GROUP_LIST_PAGE_SIZE): void {
this.dedupe(`groups:bg:list:${page}:${pageSize}`, async () => {
dedupe(`groups:bg:list:${page}:${pageSize}`, async () => {
const store = useGroupManagerStore.getState();
const response = await groupService.getGroups(page, pageSize);
const groups = response.list || [];
@@ -130,7 +133,7 @@ class GroupManager {
}
}
return this.dedupe(`groups:detail:${groupId}`, async () => {
return dedupe(`groups:detail:${groupId}`, async () => {
const store = useGroupManagerStore.getState();
const group = await groupService.getGroup(groupId);
store.setGroup(groupId, group);
@@ -140,7 +143,7 @@ class GroupManager {
}
private refreshGroupInBackground(groupId: string): void {
this.dedupe(`groups:detail:bg:${groupId}`, async () => {
dedupe(`groups:detail:bg:${groupId}`, async () => {
const store = useGroupManagerStore.getState();
const group = await groupService.getGroup(groupId);
store.setGroup(groupId, group);
@@ -179,7 +182,7 @@ class GroupManager {
}
}
return this.dedupe(`groups:members:${key}`, async () => {
return dedupe(`groups:members:${key}`, async () => {
const store = useGroupManagerStore.getState();
const response = await groupService.getMembers(groupId, page, pageSize);
const members = response.list || [];
@@ -202,7 +205,7 @@ class GroupManager {
pageSize = GROUP_MEMBER_LIST_PAGE_SIZE
): void {
const key = buildMembersKey(groupId, page, pageSize);
this.dedupe(`groups:members:bg:${key}`, async () => {
dedupe(`groups:members:bg:${key}`, async () => {
const store = useGroupManagerStore.getState();
const response = await groupService.getMembers(groupId, page, pageSize);
const members = response.list || [];
@@ -240,18 +243,6 @@ class GroupManager {
clear(): void {
useGroupManagerStore.getState().reset();
}
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const store = useGroupManagerStore.getState();
const pending = store.getPendingRequest<T>(key);
if (pending) return pending;
const request = fetcher().finally(() => {
useGroupManagerStore.getState().deletePendingRequest(key);
});
store.setPendingRequest(key, request);
return request;
}
}
export const groupManager = new GroupManager();

View File

@@ -1,38 +1,29 @@
/**
* 群组相关 React Hooks
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useGroupManagerStore } from './groupStore';
import { groupManager } from './GroupManager';
import type { GroupResponse, GroupMemberResponse } from '../../types/dto';
import { GROUP_LIST_PAGE_SIZE, GROUP_MEMBER_LIST_PAGE_SIZE } from '../groupListSources';
// ==================== useGroups ====================
export interface UseGroupsResult {
groups: GroupResponse[];
isLoading: boolean;
refresh: () => Promise<void>;
}
/**
* 获取群组列表 hook
*/
export function useGroups(): UseGroupsResult {
const groups = useGroupManagerStore(state => state.groupsListEntry?.data ?? []);
const isLoading = useGroupManagerStore(state => state.isLoadingGroups);
const [hasFetched, setHasFetched] = useState(false);
const hasFetchedRef = useRef(false);
useEffect(() => {
if (!hasFetched) {
setHasFetched(true);
if (!hasFetchedRef.current) {
hasFetchedRef.current = true;
useGroupManagerStore.getState().setLoadingGroups(true);
groupManager.getGroups(1, GROUP_LIST_PAGE_SIZE).finally(() => {
useGroupManagerStore.getState().setLoadingGroups(false);
});
}
}, [hasFetched]);
}, []);
const refresh = useCallback(async () => {
useGroupManagerStore.getState().setLoadingGroups(true);
@@ -46,17 +37,12 @@ export function useGroups(): UseGroupsResult {
return { groups, isLoading, refresh };
}
// ==================== useGroup ====================
export interface UseGroupResult {
group: GroupResponse | null;
isLoading: boolean;
refresh: () => Promise<GroupResponse | null>;
}
/**
* 获取单个群组 hook
*/
export function useGroup(groupId: string | undefined | null): UseGroupResult {
const group = useGroupManagerStore(state =>
groupId ? (state.groupsMap.get(groupId)?.data ?? null) : null
@@ -64,18 +50,18 @@ export function useGroup(groupId: string | undefined | null): UseGroupResult {
const isLoading = useGroupManagerStore(state =>
groupId ? state.loadingGroupIds.has(groupId) : false
);
const [hasFetched, setHasFetched] = useState(false);
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
if (!groupId) return;
if (!hasFetched) {
setHasFetched(true);
if (lastFetchedRef.current !== groupId) {
lastFetchedRef.current = groupId;
useGroupManagerStore.getState().setLoadingGroup(groupId, true);
groupManager.getGroup(groupId).finally(() => {
useGroupManagerStore.getState().setLoadingGroup(groupId, false);
});
}
}, [groupId, hasFetched]);
}, [groupId]);
const refresh = useCallback(async () => {
if (!groupId) return null;
@@ -90,17 +76,12 @@ export function useGroup(groupId: string | undefined | null): UseGroupResult {
return { group, isLoading, refresh };
}
// ==================== useGroupMembers ====================
export interface UseGroupMembersResult {
members: GroupMemberResponse[];
isLoading: boolean;
refresh: () => Promise<void>;
}
/**
* 获取群组成员 hook
*/
export function useGroupMembers(
groupId: string | undefined | null,
page = 1,
@@ -113,18 +94,19 @@ export function useGroupMembers(
const isLoading = useGroupManagerStore(state =>
membersKey ? state.loadingMemberKeys.has(membersKey) : false
);
const [hasFetched, setHasFetched] = useState(false);
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
if (!groupId) return;
if (!hasFetched) {
setHasFetched(true);
const key = `${groupId}:${page}:${pageSize}`;
if (lastFetchedRef.current !== key) {
lastFetchedRef.current = key;
useGroupManagerStore.getState().setLoadingMembers(groupId, true, page, pageSize);
groupManager.getMembers(groupId, page, pageSize).finally(() => {
useGroupManagerStore.getState().setLoadingMembers(groupId, false, page, pageSize);
});
}
}, [groupId, page, pageSize, hasFetched]);
}, [groupId, page, pageSize]);
const refresh = useCallback(async () => {
if (!groupId) return;
@@ -137,4 +119,4 @@ export function useGroupMembers(
}, [groupId, page, pageSize]);
return { members, isLoading, refresh };
}
}

View File

@@ -10,6 +10,7 @@
import type { ConversationResponse, MessageResponse } from '../../../types/dto';
import { messageService } from '../../../services/messageService';
import { messageRepository, conversationRepository } from '@/database';
import { MessageMapper } from '@/data/mappers';
import {
type IConversationListPagedSource,
SqliteConversationListPagedSource,
@@ -242,19 +243,9 @@ export class MessageSyncService implements IMessageSyncService {
const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages);
store.setMessages(conversationId, mergedSnapshot);
// 持久化到本地
messageRepository.saveMessagesBatch(snapshotMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
content: m.content,
type: m.type || 'text',
isRead: m.is_read || false,
createdAt: m.created_at,
seq: m.seq,
status: m.status || 'normal',
segments: m.segments,
}))).catch(error => {
messageRepository.saveMessagesBatch(
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
).catch(error => {
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
});
}
@@ -270,18 +261,9 @@ export class MessageSyncService implements IMessageSyncService {
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
store.setMessages(conversationId, mergedMessages);
messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
content: m.content,
type: m.type || 'text',
isRead: m.is_read || false,
createdAt: m.created_at,
seq: m.seq,
status: m.status || 'normal',
segments: m.segments,
}))).catch(error => {
messageRepository.saveMessagesBatch(
MessageMapper.toCachedMessages(newMessages, conversationId)
).catch(error => {
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
});
}
@@ -300,18 +282,9 @@ export class MessageSyncService implements IMessageSyncService {
store.setMessages(conversationId, mergedMessages);
messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
content: m.content,
type: m.type || 'text',
isRead: m.is_read || false,
createdAt: m.created_at,
seq: m.seq,
status: m.status || 'normal',
segments: m.segments,
}))).catch(error => {
messageRepository.saveMessagesBatch(
MessageMapper.toCachedMessages(newMessages, conversationId)
).catch(error => {
console.error('[MessageSyncService] 保存消息到本地失败:', error);
});
}
@@ -368,18 +341,9 @@ export class MessageSyncService implements IMessageSyncService {
if (response?.messages && response.messages.length > 0) {
const serverMessages = response.messages;
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
content: m.content,
type: m.type || 'text',
isRead: m.is_read || false,
createdAt: m.created_at,
seq: m.seq,
status: m.status || 'normal',
segments: m.segments,
})));
await messageRepository.saveMessagesBatch(
MessageMapper.toCachedMessages(serverMessages, conversationId)
);
const existingMessages = store.getMessages(conversationId);
const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages);

View File

@@ -18,7 +18,7 @@ import type {
WSGroupNoticeMessage,
} from '../../../services/wsService';
import { wsService, GroupNoticeType } from '../../../services/wsService';
import { vibrateOnMessage } from '../../../services/messageVibrationService';
import { eventBus } from '@/core/events/EventBus';
import { messageRepository } from '@/database';
import type { MessageResponse, ConversationResponse } from '../../../types/dto';
import type {
@@ -75,29 +75,24 @@ export class WSMessageHandler implements IWSMessageHandler {
this.sseUnsubscribe();
}
const store = useMessageStore.getState();
// 监听私聊消息
wsService.on('chat', (message: WSChatMessage) => {
if (!store.getState().isInitialized || this.isBootstrapping) {
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
this.bufferedChatMessages.push(message);
return;
}
this.handleNewMessage(message);
});
// 监听群聊消息
wsService.on('group_message', (message: WSGroupChatMessage) => {
if (!store.getState().isInitialized || this.isBootstrapping) {
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
this.bufferedChatMessages.push(message);
return;
}
this.handleNewMessage(message);
});
// 监听私聊已读回执
wsService.on('read', (message: WSReadMessage) => {
if (!store.getState().isInitialized || this.isBootstrapping) {
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
this.bufferedReadReceipts.push(message);
return;
}
@@ -106,7 +101,7 @@ export class WSMessageHandler implements IWSMessageHandler {
// 监听群聊已读回执
wsService.on('group_read', (message: WSGroupReadMessage) => {
if (!store.getState().isInitialized || this.isBootstrapping) {
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
this.bufferedReadReceipts.push(message);
return;
}
@@ -135,13 +130,13 @@ export class WSMessageHandler implements IWSMessageHandler {
// 监听连接状态
wsService.onConnect(() => {
store.setSSEConnected(true);
useMessageStore.getState().setSSEConnected(true);
// 冷启动/重连兜底
const now = Date.now();
if (now - this.lastReconnectSyncAt > 1500) {
this.lastReconnectSyncAt = now;
const activeConversation = store.getActiveConversation();
const activeConversation = useMessageStore.getState().getActiveConversation();
if (!activeConversation) {
this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => {
@@ -162,7 +157,7 @@ export class WSMessageHandler implements IWSMessageHandler {
});
wsService.onDisconnect(() => {
store.setSSEConnected(false);
useMessageStore.getState().setSSEConnected(false);
});
}
@@ -305,8 +300,8 @@ export class WSMessageHandler implements IWSMessageHandler {
if (shouldIncrementUnread) {
if (!suppressVibration) {
const vibrationType = message.type === 'group_message' ? 'group_message' : 'chat';
vibrateOnMessage(vibrationType).catch(() => {});
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
}
this.incrementUnreadCount(normalizedConversationId);
}

View File

@@ -96,7 +96,7 @@ export interface ReadStateRecord {
/** 已上报的已读序号(用于去重) */
lastReadSeq: number;
/** 清除保护的定时器ID */
clearTimer?: NodeJS.Timeout;
clearTimer?: ReturnType<typeof setTimeout>;
}
// ==================== 依赖注入接口 ====================

View File

@@ -1,14 +1,15 @@
/**
* PostManager - 撣硋<EFBFBD>蝞∠<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>?
* PostManager - 帖子管理核心模块重构版<EFBFBD>?
*
* 雿輻鍂 Zustand store 餈𥡝<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>?
* 靽脲<EFBFBD>銝擧唂 API <20><><EFBFBD><EFBFBD>𤾸<EFBFBD>?
* 使用 Zustand store 进行状态管<EFBFBD>?
* 保持与旧 API 的向后兼<E5908E>?
*/
import type { Post } from '../../types';
import { postService } from '../../services/postService';
import { usePostManagerStore } from './postStore';
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
import { createDedupe } from '../utils/requestDedupe';
import {
IPostListPagedSource,
PostListTab,
@@ -17,13 +18,13 @@ import {
POST_LIST_PAGE_SIZE,
} from '../postListSources';
// ==================== 颲<>𨭌<EFBFBD>賣㺭 ====================
function buildListKey(type: string, page: number, pageSize: number): string {
return `list:${type}:${page}:${pageSize}`;
}
// ==================== PostManager 蝐?====================
const dedupe = createDedupe(() => usePostManagerStore);
// ==================== PostManager <20>?====================
class PostManager {
// ==================== 列表相关 ====================
@@ -41,19 +42,19 @@ class PostManager {
const store = usePostManagerStore.getState();
const entry = store.listsMap.get(key);
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>?
// 检查有效缓<EFBFBD>?
if (!forceRefresh && entry && !isCacheExpired(entry)) {
return entry.data;
}
// <EFBFBD><EFBFBD>蝻枏<EFBFBD>嚗𡁜<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>蝡见朖餈𥪜<EFBFBD><EFBFBD>扳㺭<EFBFBD>?
// 过期缓存:后台刷新,立即返回旧数<EFBFBD>?
if (!forceRefresh && entry && isCacheExpired(entry)) {
this.refreshPostsInBackground(key, type, page, pageSize);
return entry.data;
}
// 无缓存:发起请求
return this.dedupe(`posts:${key}`, async () => {
return dedupe(`posts:${key}`, async () => {
const store = usePostManagerStore.getState();
const response = await postService.getPosts(page, pageSize, type);
const posts = response.list || [];
@@ -68,7 +69,7 @@ class PostManager {
page: number,
pageSize: number
): void {
this.dedupe(`posts:bg:${key}`, async () => {
dedupe(`posts:bg:${key}`, async () => {
const store = usePostManagerStore.getState();
const response = await postService.getPosts(page, pageSize, type);
const posts = response.list || [];
@@ -80,7 +81,7 @@ class PostManager {
}
/**
* <EFBFBD>𥕦遣撣硋<EFBFBD><EFBFBD>𡑒”<EFBFBD>唳旿皞琜<EFBFBD><EFBFBD><EFBFBD> Sources <EFBFBD>?
* 创建帖子列表数据源(用于 Sources 模式<EFBFBD>?
*/
createPostListSource(
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {},
@@ -103,14 +104,14 @@ class PostManager {
return entry.data;
}
// <EFBFBD><EFBFBD>蝻枏<EFBFBD>嚗𡁜<EFBFBD><EFBFBD><EFBFBD><EFBFBD>?
// 过期缓存:后台刷<EFBFBD>?
if (!forceRefresh && entry && isCacheExpired(entry) && entry.data) {
this.refreshPostDetailInBackground(postId);
return entry.data;
}
// 无缓存:发起请求
return this.dedupe(`posts:detail:${postId}`, async () => {
return dedupe(`posts:detail:${postId}`, async () => {
const store = usePostManagerStore.getState();
const post = await postService.getPost(postId);
store.setPostDetail(postId, post);
@@ -119,7 +120,7 @@ class PostManager {
}
private refreshPostDetailInBackground(postId: string): void {
this.dedupe(`posts:detail:bg:${postId}`, async () => {
dedupe(`posts:detail:bg:${postId}`, async () => {
const store = usePostManagerStore.getState();
const post = await postService.getPost(postId);
store.setPostDetail(postId, post);
@@ -132,7 +133,7 @@ class PostManager {
// ==================== 缓存管理 ====================
/**
* 雿輻<EFBFBD>摮睃仃<EFBFBD>?
* 使缓存失<EFBFBD>?
*/
invalidate(postId?: string): void {
if (postId) {
@@ -143,32 +144,15 @@ class PostManager {
}
/**
* <EFBFBD><EFBFBD><EFBFBD><EFBFBD>㗇㺭<EFBFBD>?
* 清除所有数<EFBFBD>?
*/
clear(): void {
usePostManagerStore.getState().reset();
}
// ==================== 霂瑟<E99C82><E7919F><EFBFBD> ====================
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const store = usePostManagerStore.getState();
const pending = store.getPendingRequest<T>(key);
if (pending) return pending;
const request = fetcher().finally(() => {
usePostManagerStore.getState().deletePendingRequest(key);
});
store.setPendingRequest(key, request);
return request;
}
}
// ==================== <20><EFBFBD>撖澆枂 ====================
export const postManager = new PostManager();
// 撖澆枂蝐颱誑<E9A2B1><EFBFBD>蝐餃<E89D90><EFBFBD><E79289><EFBFBD>瘚贝<E7989A>
export { PostManager };
export default postManager;

View File

@@ -1,25 +1,16 @@
/**
* 帖子相关 React Hooks
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { usePostManagerStore } from './postStore';
import { postManager } from './PostManager';
import type { Post } from '../../types';
import { POST_LIST_PAGE_SIZE } from '../postListSources';
import type { PostListTab } from '../postListSources';
// ==================== usePostList ====================
export interface UsePostListResult {
posts: Post[];
isLoading: boolean;
refresh: () => Promise<void>;
}
/**
* 获取帖子列表 hook
*/
export function usePostList(
type: PostListTab = 'hot',
page = 1,
@@ -33,17 +24,18 @@ export function usePostList(
const key = `list:${type}:${page}:${pageSize}`;
return state.loadingListKeys.has(key);
});
const [hasFetched, setHasFetched] = useState(false);
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
if (!hasFetched) {
setHasFetched(true);
const key = `${type}:${page}:${pageSize}`;
if (lastFetchedRef.current !== key) {
lastFetchedRef.current = key;
usePostManagerStore.getState().setLoadingList(type, page, pageSize, true);
postManager.getPosts(type, page, pageSize).finally(() => {
usePostManagerStore.getState().setLoadingList(type, page, pageSize, false);
});
}
}, [type, page, pageSize, hasFetched]);
}, [type, page, pageSize]);
const refresh = useCallback(async () => {
usePostManagerStore.getState().setLoadingList(type, page, pageSize, true);
@@ -57,17 +49,12 @@ export function usePostList(
return { posts, isLoading, refresh };
}
// ==================== usePostDetail ====================
export interface UsePostDetailResult {
post: Post | null;
isLoading: boolean;
refresh: () => Promise<Post | null>;
}
/**
* 获取帖子详情 hook
*/
export function usePostDetail(postId: string | undefined | null): UsePostDetailResult {
const post = usePostManagerStore(state =>
postId ? (state.detailsMap.get(postId)?.data ?? null) : null
@@ -75,18 +62,18 @@ export function usePostDetail(postId: string | undefined | null): UsePostDetailR
const isLoading = usePostManagerStore(state =>
postId ? state.loadingDetailIds.has(postId) : false
);
const [hasFetched, setHasFetched] = useState(false);
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
if (!postId) return;
if (!hasFetched) {
setHasFetched(true);
if (lastFetchedRef.current !== postId) {
lastFetchedRef.current = postId;
usePostManagerStore.getState().setLoadingDetail(postId, true);
postManager.getPostDetail(postId).finally(() => {
usePostManagerStore.getState().setLoadingDetail(postId, false);
});
}
}, [postId, hasFetched]);
}, [postId]);
const refresh = useCallback(async () => {
if (!postId) return null;
@@ -99,4 +86,4 @@ export function usePostDetail(postId: string | undefined | null): UsePostDetailR
}, [postId]);
return { post, isLoading, refresh };
}
}

View File

@@ -10,6 +10,9 @@ import { authService } from '../../services/authService';
import { userCacheRepository } from '@/database';
import { useUserManagerStore } from './userStore';
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
import { createDedupe } from '../utils/requestDedupe';
const dedupe = createDedupe(() => useUserManagerStore);
// ==================== UserManager 类 ====================
@@ -37,7 +40,7 @@ class UserManager {
}
}
return this.dedupe('users:current', async () => {
return dedupe('users:current', async () => {
const user = await authService.fetchCurrentUserFromAPI();
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
store.setCurrentUserEntry(newEntry);
@@ -51,7 +54,7 @@ class UserManager {
}
private refreshCurrentUserInBackground(): void {
this.dedupe('users:current:bg', async () => {
dedupe('users:current:bg', async () => {
const store = useUserManagerStore.getState();
const user = await authService.fetchCurrentUserFromAPI();
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
@@ -90,7 +93,7 @@ class UserManager {
}
}
return this.dedupe(`users:detail:${userId}`, async () => {
return dedupe(`users:detail:${userId}`, async () => {
const store = useUserManagerStore.getState();
const user = await authService.fetchUserByIdFromAPI(userId);
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
@@ -104,7 +107,7 @@ class UserManager {
}
private refreshUserByIdInBackground(userId: string): void {
this.dedupe(`users:detail:bg:${userId}`, async () => {
dedupe(`users:detail:bg:${userId}`, async () => {
const store = useUserManagerStore.getState();
const user = await authService.fetchUserByIdFromAPI(userId);
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
@@ -122,18 +125,6 @@ class UserManager {
invalidateUsers(): void {
useUserManagerStore.getState().invalidateAll();
}
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const store = useUserManagerStore.getState();
const pending = store.getPendingRequest<T>(key);
if (pending) return pending;
const request = fetcher().finally(() => {
useUserManagerStore.getState().deletePendingRequest(key);
});
store.setPendingRequest(key, request);
return request;
}
}
export const userManager = new UserManager();

View File

@@ -1,13 +1,8 @@
/**
* 用户相关 React Hooks
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useUserManagerStore } from './userStore';
import { userManager } from './UserManager';
import type { UserDTO } from '../../types/dto';
// ==================== useCurrentUser ====================
import { useAuthStore } from '../authStore';
export interface UseCurrentUserResult {
user: UserDTO | null;
@@ -15,24 +10,30 @@ export interface UseCurrentUserResult {
refresh: () => Promise<UserDTO | null>;
}
/**
* 获å<C2B7>当å‰<C3A5>用户 hook
* 自动加载,支æŒ<C3A6>缓存åŒå<C592>Žå<C5BD>°åˆ·æ°
*/
export function useCurrentUser(): UseCurrentUserResult {
const user = useUserManagerStore(state => state.currentUser);
const isLoading = useUserManagerStore(state => state.isLoadingCurrentUser);
const [hasFetched, setHasFetched] = useState(false);
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
if (!hasFetched) {
setHasFetched(true);
const authUser = useAuthStore.getState().currentUser;
const userId = authUser?.id;
if (userId && lastFetchedRef.current !== userId) {
lastFetchedRef.current = userId;
useUserManagerStore.getState().setLoadingCurrentUser(true);
userManager.getCurrentUser().finally(() => {
useUserManagerStore.getState().setLoadingCurrentUser(false);
});
}
}, [hasFetched]);
}, []);
useEffect(() => {
return useAuthStore.subscribe((state, prev) => {
if (!state.currentUser && prev.currentUser) {
lastFetchedRef.current = null;
}
});
}, []);
const refresh = useCallback(async () => {
useUserManagerStore.getState().setLoadingCurrentUser(true);
@@ -46,18 +47,12 @@ export function useCurrentUser(): UseCurrentUserResult {
return { user, isLoading, refresh };
}
// ==================== useUser ====================
export interface UseUserResult {
user: UserDTO | null;
isLoading: boolean;
refresh: () => Promise<UserDTO | null>;
}
/**
* æ ¹æ<C2B9>® ID 获å<C2B7>用户 hook
* 自动加载,支æŒ<C3A6>缓存åŒå<C592>Žå<C5BD>°åˆ·æ°
*/
export function useUser(userId: string | undefined | null): UseUserResult {
const user = useUserManagerStore(state =>
userId ? (state.usersMap.get(userId)?.data ?? null) : null
@@ -65,18 +60,18 @@ export function useUser(userId: string | undefined | null): UseUserResult {
const isLoading = useUserManagerStore(state =>
userId ? state.loadingUserIds.has(userId) : false
);
const [hasFetched, setHasFetched] = useState(false);
const lastFetchedRef = useRef<string | null>(null);
useEffect(() => {
if (!userId) return;
if (!hasFetched) {
setHasFetched(true);
if (lastFetchedRef.current !== userId) {
lastFetchedRef.current = userId;
useUserManagerStore.getState().setLoadingUser(userId, true);
userManager.getUserById(userId).finally(() => {
useUserManagerStore.getState().setLoadingUser(userId, false);
});
}
}, [userId, hasFetched]);
}, [userId]);
const refresh = useCallback(async () => {
if (!userId) return null;
@@ -91,33 +86,27 @@ export function useUser(userId: string | undefined | null): UseUserResult {
return { user, isLoading, refresh };
}
// ==================== useUsers ====================
export interface UseUsersResult {
getUser: (id: string) => UserDTO | null;
refreshUser: (id: string) => Promise<UserDTO | null>;
}
/**
* 多用户获å<C2B7>?hook
* 用于批é‡<C3A9>获å<C2B7>多个用户信æ<C2A1>¯
*/
export function useUsers(userIds: string[]): UseUsersResult {
const usersMap = useUserManagerStore(state => state.usersMap);
const loadingUserIds = useUserManagerStore(state => state.loadingUserIds);
const [fetchedIds, setFetchedIds] = useState<Set<string>>(new Set());
const fetchedIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
userIds.forEach(id => {
if (!fetchedIds.has(id) && !loadingUserIds.has(id)) {
setFetchedIds(prev => new Set(prev).add(id));
if (!fetchedIdsRef.current.has(id) && !loadingUserIds.has(id)) {
fetchedIdsRef.current = new Set(fetchedIdsRef.current).add(id);
useUserManagerStore.getState().setLoadingUser(id, true);
userManager.getUserById(id).finally(() => {
useUserManagerStore.getState().setLoadingUser(id, false);
});
}
});
}, [userIds, fetchedIds, loadingUserIds]);
}, [userIds, loadingUserIds]);
const getUser = useCallback((id: string) => {
return usersMap.get(id)?.data ?? null;
@@ -133,4 +122,4 @@ export function useUsers(userIds: string[]): UseUsersResult {
}, []);
return { getUser, refreshUser };
}
}

View File

@@ -0,0 +1,58 @@
import type { StoreApi } from 'zustand';
export interface PendingRequestsSlice {
pendingRequests: Map<string, Promise<any>>;
getPendingRequest: <T>(key: string) => Promise<T> | undefined;
setPendingRequest: <T>(key: string, request: Promise<T>) => void;
deletePendingRequest: (key: string) => void;
clearPendingRequests: () => void;
}
export function createPendingRequestsSlice(
set: (partial: any) => void,
get: () => any
): PendingRequestsSlice {
return {
pendingRequests: new Map(),
getPendingRequest: <T>(key: string) => {
return get().pendingRequests.get(key) as Promise<T> | undefined;
},
setPendingRequest: <T>(key: string, request: Promise<T>) => {
set((state: { pendingRequests: Map<string, Promise<any>> }) => {
const newPendingRequests = new Map(state.pendingRequests);
newPendingRequests.set(key, request);
return { pendingRequests: newPendingRequests };
});
},
deletePendingRequest: (key: string) => {
set((state: { pendingRequests: Map<string, Promise<any>> }) => {
const newPendingRequests = new Map(state.pendingRequests);
newPendingRequests.delete(key);
return { pendingRequests: newPendingRequests };
});
},
clearPendingRequests: () => {
set({ pendingRequests: new Map() });
},
};
}
export function createDedupe<TStore extends PendingRequestsSlice>(
getStore: () => StoreApi<TStore>
) {
return async function dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const store = getStore().getState();
const pending = store.getPendingRequest<T>(key);
if (pending) return pending;
const request = fetcher().finally(() => {
getStore().getState().deletePendingRequest(key);
});
store.setPendingRequest(key, request);
return request;
};
}