Files
frontend/src/hooks/useDifferentialPosts.ts

352 lines
11 KiB
TypeScript
Raw Normal View History

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';
2026-04-13 01:30:37 +08:00
import { postSyncService } from '@/services/post';
import type { PostsState } from '@/services/post';
import { usePostListStore } from '../stores/post/postListStore';
import type { Post } from '../core/entities/Post';
export interface UseDifferentialPostsOptions<T extends PostIdentifier> {
diffConfig?: PostDiffConfig;
batcherOptions?: PostBatcherOptions;
enableDiff?: boolean;
enableBatching?: boolean;
maxPostCount?: number;
listKey?: string;
autoSubscribe?: boolean;
}
export interface DiffUpdatesInfo {
addedCount: number;
updatedCount: number;
deletedCount: number;
lastUpdateTime: number;
}
export interface UseDifferentialPostsResult<T extends PostIdentifier> {
posts: T[];
loading: boolean;
isInitialLoading: boolean;
isLoadingMore: 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;
}
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,
});
const calculatorRef = useRef<PostDiffCalculator<T> | null>(null);
const batcherRef = useRef<PostUpdateBatcher | null>(null);
const previousPostsRef = useRef<T[]>(initialPosts);
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(handleBatchUpdates);
return () => {
unsubscribe();
batcherRef.current?.destroy();
batcherRef.current = null;
};
}, [enableBatching, batcherOptions, handleBatchUpdates]);
const syncFromStore = useCallback((state: PostsState) => {
setLoading(state.isLoading);
setRefreshing(state.isRefreshing);
setError(state.error);
setHasMore(state.hasMore);
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 = [];
}
}, [maxPostCount]);
useEffect(() => {
if (!autoSubscribe) return;
const currentState = usePostListStore.getState().getPostsState(listKey);
syncFromStore(currentState);
// listKey 切换时,如果目标列表在 store 中还没有有效数据(首次访问),
// 立即进入 loading 态,避免先渲染空列表再切到 loading 造成闪烁。
// syncFromStore 会用 store 的 isLoading 覆盖,因此在其之后补设。
// 如果已有数据(曾访问过),则保留旧数据,由后续 fetch 静默刷新。
if (!currentState.posts || currentState.posts.length === 0) {
setLoading(true);
}
const unsubscribe = usePostListStore.subscribe(state => {
const postsState = state.postsStateMap.get(listKey);
if (postsState) syncFromStore(postsState);
});
return unsubscribe;
}, [autoSubscribe, listKey, syncFromStore]);
const refresh = useCallback(async () => {
setRefreshing(true);
setError(null);
try {
await postSyncService.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 postSyncService.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();
// 同步清空 store 中对应 listKey 的数据,避免 subscribe 回调
// 在切换 listKey 时把上一个 key 的残留数据重新同步回本地造成闪烁
usePostListStore.getState().updatePostsState(listKey, {
posts: [],
cursor: null,
currentPage: 1,
hasMore: true,
isLoading: false,
isRefreshing: false,
error: null,
lastParams: undefined,
});
setPosts([]);
setLoading(false);
setRefreshing(false);
setError(null);
setHasMore(true);
previousPostsRef.current = [];
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
}, [listKey]);
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,
isInitialLoading: loading && posts.length === 0 && !refreshing,
isLoadingMore: loading && posts.length > 0 && !refreshing,
refreshing,
error,
hasMore,
pendingUpdateCount,
isProcessing,
refresh,
loadMore,
flush,
reset,
forceUpdate,
getDiffStats,
diffUpdates,
};
}
export default useDifferentialPosts;