fix: 修复首次加载重复请求问题 + Stage 3&4 性能优化

修复问题:
- MarketOverviewBanner/EventStatsContext: 首次加载时防抖 effect 会导致重复请求
- 添加 isInitialMount ref 标记跳过首次加载

Stage 3 优化 (数据对比/防抖/依赖优化):
- EventDailyStats: 数据指纹对比,相同数据不触发重渲染
- MarketOverviewBanner: 日期变化 300ms 防抖
- CombinedCalendar: useRef 优化 handleDateClick 依赖

Stage 4 优化 (跨组件数据共享):
- 新增 EventStatsContext 用于 EventDailyStats/MarketOverviewBanner 数据共享(预留)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2026-01-15 18:11:48 +08:00
parent afdc94049c
commit 75fd9924bc
4 changed files with 208 additions and 46 deletions

View File

@@ -2,7 +2,7 @@
* EventDailyStats - 事件 TOP 排行面板
* 展示当日事件的表现排行
*/
import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
import React, { useState, useEffect, useCallback, useMemo, memo, useRef } from "react";
import {
Box,
Text,
@@ -111,6 +111,9 @@ const EventDailyStats = () => {
const [isPaused, setIsPaused] = useState(false);
const controls = useAnimationControls();
// 用于数据对比,避免相同数据触发重渲染
const prevDataHashRef = useRef(null);
const fetchStats = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
@@ -126,7 +129,18 @@ const EventDailyStats = () => {
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
// 生成数据指纹用于对比
const topPerformers = data.data?.topPerformers || [];
const dataHash = topPerformers
.slice(0, 10)
.map((e) => `${e.id}-${e.avgChg}`)
.join("|");
// 仅当数据真正变化时才更新状态
if (dataHash !== prevDataHashRef.current) {
prevDataHashRef.current = dataHash;
setStats(data.data);
}
} else {
throw new Error(data.message || "数据格式错误");
}

View File

@@ -45,6 +45,8 @@ const CombinedCalendar = memo(({ DetailModal }) => {
// 月份数据缓存(避免切换月份后再切回时重复请求)
const monthCacheRef = useRef({});
// 涨停详情缓存 ref用于 handleDateClick 避免依赖 ztDailyDetails 状态)
const ztDailyDetailsRef = useRef({});
// 加载日历综合数据(带缓存)
useEffect(() => {
@@ -86,9 +88,8 @@ const CombinedCalendar = memo(({ DetailModal }) => {
loadCalendarCombinedData();
}, [currentMonth]);
// 处理日期点击 - 打开弹窗
const handleDateClick = useCallback(
async (date) => {
// 处理日期点击 - 打开弹窗(使用 ref 避免依赖 ztDailyDetails 状态)
const handleDateClick = useCallback(async (date) => {
setSelectedDate(date);
setModalOpen(true);
setDetailLoading(true);
@@ -96,8 +97,8 @@ const CombinedCalendar = memo(({ DetailModal }) => {
const ztDateStr = formatDateStr(date);
const eventDateStr = dayjs(date).format("YYYY-MM-DD");
// 加载涨停详情
const detail = ztDailyDetails[ztDateStr];
// 加载涨停详情(使用 ref 访问缓存)
const detail = ztDailyDetailsRef.current[ztDateStr];
if (detail?.fullData) {
setSelectedZtDetail(detail.fullData);
} else {
@@ -106,6 +107,11 @@ const CombinedCalendar = memo(({ DetailModal }) => {
if (response.ok) {
const data = await response.json();
setSelectedZtDetail(data);
// 同时更新 ref 和 state
ztDailyDetailsRef.current[ztDateStr] = {
...ztDailyDetailsRef.current[ztDateStr],
fullData: data,
};
setZtDailyDetails((prev) => ({
...prev,
[ztDateStr]: { ...prev[ztDateStr], fullData: data },
@@ -133,9 +139,7 @@ const CombinedCalendar = memo(({ DetailModal }) => {
}
setDetailLoading(false);
},
[ztDailyDetails]
);
}, []);
// 月份变化回调
const handleMonthChange = useCallback((year, month) => {

View File

@@ -67,16 +67,27 @@ const MarketOverviewBanner = () => {
}
}, []);
// 首次加载标记
const isInitialMount = useRef(true);
// 首次加载显示 loading
useEffect(() => {
fetchStats(selectedDate, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 日期变化时静默刷新
// 日期变化时静默刷新(带防抖,避免快速切换时多次请求)
// 跳过首次加载,避免重复请求
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
if (selectedDate) {
const timer = setTimeout(() => {
fetchStats(selectedDate, false);
}, 300);
return () => clearTimeout(timer);
}
}, [fetchStats, selectedDate]);

View File

@@ -0,0 +1,133 @@
/**
* EventStatsContext - 事件统计数据共享 Context
* 让 EventDailyStats 和 MarketOverviewBanner 共享同一份 API 数据
* 避免重复请求 /api/v1/events/effectiveness-stats
*/
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import { getApiBase } from "@utils/apiConfig";
const EventStatsContext = createContext(null);
/**
* EventStatsProvider - 事件统计数据提供者
*/
export const EventStatsProvider = ({ children }) => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split("T")[0]
);
// 数据指纹用于对比,避免相同数据触发重渲染
const prevDataHashRef = useRef(null);
// 防抖定时器
const debounceTimerRef = useRef(null);
// 首次加载标记
const isInitialMount = useRef(true);
const fetchStats = useCallback(async (dateStr = "", showLoading = false) => {
if (showLoading) {
setLoading(true);
}
setError(null);
try {
const apiBase = getApiBase();
const dateParam = dateStr ? `&date=${dateStr}` : "";
const response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}`
);
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
// 生成数据指纹用于对比
const topPerformers = data.data?.topPerformers || [];
const dataHash = topPerformers
.slice(0, 10)
.map((e) => `${e.id}-${e.avgChg}`)
.join("|");
// 仅当数据真正变化时才更新状态
if (dataHash !== prevDataHashRef.current) {
prevDataHashRef.current = dataHash;
setStats(data.data);
}
} else {
throw new Error(data.message || "数据格式错误");
}
} catch (err) {
console.error("获取事件统计失败:", err);
setError(err.message);
} finally {
setLoading(false);
}
}, []);
// 首次加载
useEffect(() => {
fetchStats(selectedDate, true);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 日期变化时刷新(带防抖)
// 跳过首次加载,避免重复请求
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
fetchStats(selectedDate, false);
}, 300);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [selectedDate, fetchStats]);
// 自动刷新每60秒
useEffect(() => {
const interval = setInterval(() => fetchStats(selectedDate, false), 60000);
return () => clearInterval(interval);
}, [selectedDate, fetchStats]);
const value = {
loading,
stats,
error,
selectedDate,
setSelectedDate,
refresh: () => fetchStats(selectedDate, false),
};
return (
<EventStatsContext.Provider value={value}>
{children}
</EventStatsContext.Provider>
);
};
/**
* useEventStats - 使用事件统计数据的 Hook
*/
export const useEventStats = () => {
const context = useContext(EventStatsContext);
if (!context) {
throw new Error("useEventStats must be used within EventStatsProvider");
}
return context;
};
export default EventStatsContext;