Files
frontend/src/hooks/useDifferentialPosts.ts

533 lines
16 KiB
TypeScript
Raw Normal View History

/**
* Hook
*
* ProcessPostUseCase
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import {
PostIdentifier,
PostUpdate,
PostUpdateType,
PostDiffConfig,
} from '../infrastructure/diff/postTypes';
import {
PostDiffCalculator,
createPostDiffCalculator,
} from '../infrastructure/diff/PostDiffCalculator';
import {
PostUpdateBatcher,
PostBatcherOptions,
createPostUpdateBatcher,
} from '../infrastructure/diff/PostUpdateBatcher';
import { processPostUseCase } from '../core/usecases/ProcessPostUseCase';
import type { PostUseCaseEvent, PostsState } from '../core/usecases/ProcessPostUseCase';
import type { Post } from '../core/entities/Post';
// ==================== 类型定义 ====================
/**
* Hook
*/
export interface UseDifferentialPostsOptions<T extends PostIdentifier> {
/** 差异计算配置 */
diffConfig?: PostDiffConfig;
/** 批量处理配置 */
batcherOptions?: PostBatcherOptions;
/** 是否启用差异计算,默认 true */
enableDiff?: boolean;
/** 是否启用批量处理,默认 true */
enableBatching?: boolean;
/** 最大帖子数量限制,超出时触发重置 */
maxPostCount?: number;
/** 变化比例阈值0-1超过此值触发重置 */
changeRatioThreshold?: number;
/** 列表键(用于区分不同的帖子列表) */
listKey?: string;
/** 是否自动订阅 UseCase 状态变化,默认 true */
autoSubscribe?: boolean;
}
/**
*
*/
export interface DiffUpdatesInfo {
/** 新增数量 */
addedCount: number;
/** 更新数量 */
updatedCount: number;
/** 删除数量 */
deletedCount: number;
/** 上次更新时间 */
lastUpdateTime: number;
}
/**
* Hook
*/
export interface UseDifferentialPostsResult<T extends PostIdentifier> {
/** 优化后的帖子列表 */
posts: T[];
/** 是否正在加载 */
loading: boolean;
/** 是否正在刷新 */
refreshing: boolean;
/** 错误信息 */
error: string | null;
/** 是否有更多数据 */
hasMore: boolean;
/** 待处理更新数量 */
pendingUpdateCount: number;
/** 是否正在处理更新 */
isProcessing: boolean;
/** 刷新方法 */
refresh: () => Promise<void>;
/** 加载更多方法 */
loadMore: () => Promise<void>;
/** 手动刷新批量处理队列 */
flush: () => void;
/** 重置方法 */
reset: () => void;
/** 强制更新(跳过批量处理) */
forceUpdate: (posts: T[]) => void;
/** 获取差异统计信息 */
getDiffStats: () => {
totalBatches: number;
totalUpdates: number;
averageBatchSize: number;
};
/** 差异更新信息 */
diffUpdates: DiffUpdatesInfo;
}
// ==================== Hook 实现 ====================
/**
* Hook
* @param initialPosts
* @param options
* @returns
*/
export function useDifferentialPosts<T extends PostIdentifier = Post>(
initialPosts: T[] = [],
options: UseDifferentialPostsOptions<T> = {}
): UseDifferentialPostsResult<T> {
const {
diffConfig,
batcherOptions,
enableDiff = true,
enableBatching = true,
maxPostCount = 10000,
listKey = 'default',
autoSubscribe = true,
} = options;
// ==================== 状态 ====================
const [posts, setPosts] = useState<T[]>(initialPosts);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
const [diffUpdates, setDiffUpdates] = useState<DiffUpdatesInfo>({
addedCount: 0,
updatedCount: 0,
deletedCount: 0,
lastUpdateTime: 0,
});
// ==================== Refs ====================
const calculatorRef = useRef<PostDiffCalculator<T> | null>(null);
const batcherRef = useRef<PostUpdateBatcher | null>(null);
const previousPostsRef = useRef<T[]>(initialPosts);
const unsubscribeRef = useRef<(() => void) | null>(null);
// ==================== 批量更新处理(须在订阅 batcher 的 useEffect 之前定义)====================
/**
*
*/
const handleBatchUpdates = useCallback((updates: PostUpdate[]) => {
setIsProcessing(true);
try {
setPosts((currentPosts) => {
let newPosts = [...currentPosts];
let addedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
for (const update of updates) {
switch (update.type) {
case PostUpdateType.ADD: {
const addUpdate = update as any;
const index = addUpdate.index ?? newPosts.length;
newPosts.splice(index, 0, addUpdate.post as T);
addedCount++;
break;
}
case PostUpdateType.BATCH_ADD: {
const batchAddUpdate = update as any;
const postsToAdd = batchAddUpdate.posts || [];
const startIndex = batchAddUpdate.startIndex ?? newPosts.length;
newPosts.splice(startIndex, 0, ...postsToAdd as T[]);
addedCount += postsToAdd.length;
break;
}
case PostUpdateType.UPDATE: {
const updatePost = update as any;
const index = newPosts.findIndex(p => p.id === updatePost.postId);
if (index !== -1) {
newPosts[index] = { ...newPosts[index], ...updatePost.updates };
updatedCount++;
}
break;
}
case PostUpdateType.BATCH_UPDATE: {
const batchUpdate = update as any;
const updatesList = batchUpdate.updates || [];
for (const { postId, changes } of updatesList) {
const index = newPosts.findIndex(p => p.id === postId);
if (index !== -1) {
newPosts[index] = { ...newPosts[index], ...changes };
updatedCount++;
}
}
break;
}
case PostUpdateType.DELETE: {
const deleteUpdate = update as any;
const prevLength = newPosts.length;
newPosts = newPosts.filter(p => p.id !== deleteUpdate.postId);
if (newPosts.length < prevLength) {
deletedCount++;
}
break;
}
case PostUpdateType.BATCH_DELETE: {
const batchDelete = update as any;
const idsToDelete = new Set(batchDelete.postIds || []);
const prevLength = newPosts.length;
newPosts = newPosts.filter(p => !idsToDelete.has(p.id));
deletedCount += prevLength - newPosts.length;
break;
}
case PostUpdateType.MOVE: {
const moveUpdate = update as any;
const fromIndex = newPosts.findIndex(p => p.id === moveUpdate.postId);
if (fromIndex !== -1) {
const [movedPost] = newPosts.splice(fromIndex, 1);
newPosts.splice(moveUpdate.toIndex, 0, movedPost);
}
break;
}
case PostUpdateType.RESET: {
const resetUpdate = update as any;
newPosts = resetUpdate.posts || [];
break;
}
}
}
// 更新差异统计
if (addedCount > 0 || updatedCount > 0 || deletedCount > 0) {
setDiffUpdates(prev => ({
addedCount: prev.addedCount + addedCount,
updatedCount: prev.updatedCount + updatedCount,
deletedCount: prev.deletedCount + deletedCount,
lastUpdateTime: Date.now(),
}));
}
return newPosts;
});
} finally {
setIsProcessing(false);
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
}
}, []);
// ==================== 初始化 ====================
// 初始化差异计算器
useEffect(() => {
if (enableDiff) {
calculatorRef.current = createPostDiffCalculator<T>(diffConfig);
}
return () => {
calculatorRef.current = null;
};
}, [enableDiff, diffConfig]);
// 初始化批量处理器
useEffect(() => {
if (!enableBatching) return;
batcherRef.current = createPostUpdateBatcher(batcherOptions);
const unsubscribe = batcherRef.current.subscribe((updates) => {
handleBatchUpdates(updates);
});
return () => {
unsubscribe();
batcherRef.current?.destroy();
batcherRef.current = null;
};
}, [enableBatching, batcherOptions, handleBatchUpdates]);
// ==================== UseCase 订阅 ====================
/**
* UseCase
*/
const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => {
switch (event.type) {
case 'state_changed': {
const { key: eventKey, state } = event.payload as { key: string; state: PostsState };
// 全局订阅:只应用当前列表 key避免其他 Tab/列表的 state 覆盖本列表(如 createPost 会更新所有 key
if (eventKey !== listKey) {
break;
}
if (state) {
setLoading(state.isLoading);
setRefreshing(state.isRefreshing);
setError(state.error);
setHasMore(state.hasMore);
// ProcessPostUseCase 的 state.posts 已是合并后的完整列表。
// 不再走差异计算 + PostUpdateBatcher批量应用晚于 previousPostsRef 更新时,
// BATCH_ADD 会在过短的 currentPosts 上 splice导致连续游标加载「请求成功但列表不增长」。
batcherRef.current?.clearPending();
calculatorRef.current?.reset();
if (state.posts && state.posts.length > 0) {
const next = state.posts as unknown as T[];
if (next.length > maxPostCount) {
console.warn(`[useDifferentialPosts] Post count (${next.length}) exceeds limit (${maxPostCount})`);
}
setPosts(next);
previousPostsRef.current = next;
} else {
setPosts([]);
previousPostsRef.current = [];
}
}
break;
}
case 'posts_loaded': {
// fetchPosts 在 updatePostsState 时已发出 state_changed含合并后 posts此处不再应用避免与其它 key 竞态或重复计算
break;
}
case 'post_created': {
const { post } = event.payload;
if (post) {
// 新帖子添加到列表开头
setPosts(prev => [post as T, ...prev]);
setDiffUpdates(prev => ({
...prev,
addedCount: prev.addedCount + 1,
lastUpdateTime: Date.now(),
}));
}
break;
}
case 'post_updated': {
const { post } = event.payload;
if (post) {
setPosts(prev => prev.map(p => p.id === post.id ? { ...p, ...post } as T : p));
setDiffUpdates(prev => ({
...prev,
updatedCount: prev.updatedCount + 1,
lastUpdateTime: Date.now(),
}));
}
break;
}
case 'post_deleted': {
const { postId } = event.payload;
if (postId) {
setPosts(prev => prev.filter(p => p.id !== postId));
setDiffUpdates(prev => ({
...prev,
deletedCount: prev.deletedCount + 1,
lastUpdateTime: Date.now(),
}));
}
break;
}
case 'loading_changed': {
const { isLoading } = event.payload;
setLoading(isLoading);
break;
}
case 'error_occurred': {
const payload = event.payload as { key?: string; error?: string };
if (payload.key !== undefined && payload.key !== listKey) {
break;
}
if (payload.error !== undefined) {
setError(payload.error);
}
break;
}
}
}, [listKey, maxPostCount]);
// 订阅 UseCase 状态变化
useEffect(() => {
if (autoSubscribe) {
unsubscribeRef.current = processPostUseCase.subscribe(handleUseCaseEvent);
// 初始化时获取当前状态
const currentState = processPostUseCase.getPostsState(listKey);
if (currentState.posts.length > 0) {
setPosts(currentState.posts as unknown as T[]);
previousPostsRef.current = currentState.posts as unknown as T[];
}
setLoading(currentState.isLoading);
setRefreshing(currentState.isRefreshing);
setError(currentState.error);
setHasMore(currentState.hasMore);
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}
}, [autoSubscribe, listKey, handleUseCaseEvent]);
// ==================== 操作方法 ====================
/**
*
*/
const refresh = useCallback(async () => {
setRefreshing(true);
setError(null);
try {
await processPostUseCase.refreshPosts(listKey);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '刷新失败';
setError(errorMsg);
} finally {
setRefreshing(false);
}
}, [listKey]);
/**
*
*/
const loadMore = useCallback(async () => {
if (hasMore && !loading) {
setLoading(true);
setError(null);
try {
await processPostUseCase.loadMorePosts(listKey);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '加载更多失败';
setError(errorMsg);
} finally {
setLoading(false);
}
}
}, [listKey, hasMore, loading]);
/**
*
*/
const flush = useCallback(() => {
batcherRef.current?.flush();
}, []);
/**
*
*/
const reset = useCallback(() => {
calculatorRef.current?.reset();
batcherRef.current?.clearPending();
setPosts([]);
setLoading(false);
setRefreshing(false);
setError(null);
setHasMore(true);
previousPostsRef.current = [];
setDiffUpdates({
addedCount: 0,
updatedCount: 0,
deletedCount: 0,
lastUpdateTime: 0,
});
}, []);
/**
*
*/
const forceUpdate = useCallback((newPosts: T[]) => {
setPosts(newPosts);
previousPostsRef.current = newPosts;
}, []);
/**
*
*/
const getDiffStats = useCallback(() => {
const batcherStats = batcherRef.current?.getStats();
return {
totalBatches: batcherStats?.totalBatches ?? 0,
totalUpdates: batcherStats?.totalUpdates ?? 0,
averageBatchSize: batcherStats?.averageBatchSize ?? 0,
};
}, []);
// ==================== 定期更新待处理数量 ====================
useEffect(() => {
if (!enableBatching || !batcherRef.current) return;
const interval = setInterval(() => {
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
}, 50);
return () => clearInterval(interval);
}, [enableBatching]);
// ==================== 返回值 ====================
return {
posts,
loading,
refreshing,
error,
hasMore,
pendingUpdateCount,
isProcessing,
refresh,
loadMore,
flush,
reset,
forceUpdate,
getDiffStats,
diffUpdates,
};
}
export default useDifferentialPosts;