perf(Company): 优化渲染性能和 API 请求

- StockQuoteCard: 添加 memo 包装减少重渲染
- Company/index: componentProps 使用 useMemo 缓存
- useCompanyEvents: 页面浏览事件只触发一次,避免重复追踪
- useCompanyData: 自选股状态改用单股票查询接口,减少数据传输
- CompanyHeader: inputCode 状态下移到 SearchActions,减少父组件重渲染
- CompanyHeader: 移除重复环境光效果,由全局 AmbientGlow 统一处理

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-19 10:14:07 +08:00
parent c979e775a5
commit 51721ce9bf
5 changed files with 96 additions and 94 deletions

View File

@@ -137,25 +137,29 @@ const StockInfoDisplay = memo<{
StockInfoDisplay.displayName = 'StockInfoDisplay'; StockInfoDisplay.displayName = 'StockInfoDisplay';
/** /**
* 搜索操作区组件 * 搜索操作区组件(状态自管理,减少父组件重渲染)
*/ */
const SearchActions = memo<{ const SearchActions = memo<{
inputCode: string; stockCode: string;
onInputChange: (value: string) => void; onStockChange: (value: string) => void;
onSearch: () => void;
onSelect: (value: string) => void;
isInWatchlist: boolean; isInWatchlist: boolean;
watchlistLoading: boolean; watchlistLoading: boolean;
onWatchlistToggle: () => void; onWatchlistToggle: () => void;
}>(({ }>(({
inputCode, stockCode,
onInputChange, onStockChange,
onSearch,
onSelect,
isInWatchlist, isInWatchlist,
watchlistLoading, watchlistLoading,
onWatchlistToggle, onWatchlistToggle,
}) => { }) => {
// 输入状态自管理(避免父组件重渲染)
const [inputCode, setInputCode] = useState(stockCode);
// 同步外部 stockCode 变化
React.useEffect(() => {
setInputCode(stockCode);
}, [stockCode]);
// 股票搜索 Hook // 股票搜索 Hook
const searchHook = useStockSearch({ const searchHook = useStockSearch({
limit: 10, limit: 10,
@@ -190,18 +194,28 @@ const SearchActions = memo<{
})); }));
}, [searchResults]); }, [searchResults]);
// 处理搜索按钮点击
const handleSearch = useCallback(() => {
if (inputCode && inputCode !== stockCode) {
onStockChange(inputCode);
}
}, [inputCode, stockCode, onStockChange]);
// 选中股票 // 选中股票
const handleSelect = useCallback((value: string) => { const handleSelect = useCallback((value: string) => {
clearSearch(); clearSearch();
onSelect(value); setInputCode(value);
}, [clearSearch, onSelect]); if (value !== stockCode) {
onStockChange(value);
}
}, [clearSearch, stockCode, onStockChange]);
// 键盘事件 // 键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSearch(); handleSearch();
} }
}, [onSearch]); }, [handleSearch]);
return ( return (
<HStack spacing={3}> <HStack spacing={3}>
@@ -241,7 +255,7 @@ const SearchActions = memo<{
options={stockOptions} options={stockOptions}
onSearch={doSearch} onSearch={doSearch}
onSelect={handleSelect} onSelect={handleSelect}
onChange={onInputChange} onChange={setInputCode}
placeholder="输入代码、名称或拼音" placeholder="输入代码、名称或拼音"
style={{ width: 240 }} style={{ width: 240 }}
dropdownStyle={{ dropdownStyle={{
@@ -271,7 +285,7 @@ const SearchActions = memo<{
size="md" size="md"
h="42px" h="42px"
px={5} px={5}
onClick={onSearch} onClick={handleSearch}
leftIcon={<Icon as={Search} boxSize={4} />} leftIcon={<Icon as={Search} boxSize={4} />}
fontWeight="bold" fontWeight="bold"
borderRadius="10px" borderRadius="10px"
@@ -335,28 +349,6 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
onStockChange, onStockChange,
onWatchlistToggle, onWatchlistToggle,
}) => { }) => {
const [inputCode, setInputCode] = useState(stockCode);
// 处理搜索
const handleSearch = useCallback(() => {
if (inputCode && inputCode !== stockCode) {
onStockChange(inputCode);
}
}, [inputCode, stockCode, onStockChange]);
// 处理选中
const handleSelect = useCallback((value: string) => {
setInputCode(value);
if (value !== stockCode) {
onStockChange(value);
}
}, [stockCode, onStockChange]);
// 同步 stockCode 变化
React.useEffect(() => {
setInputCode(stockCode);
}, [stockCode]);
return ( return (
<Box <Box
position="relative" position="relative"
@@ -368,20 +360,7 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
backdropFilter={FUI_GLASS.blur.md} backdropFilter={FUI_GLASS.blur.md}
overflow="hidden" overflow="hidden"
> >
{/* 环境光效果 - James Turrell 风格 */} {/* 顶部发光线(环境光效果由全局 AmbientGlow 提供) */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
pointerEvents="none"
bg={`radial-gradient(ellipse 80% 50% at 20% 40%, ${FUI_COLORS.ambient.warm}, transparent),
radial-gradient(ellipse 60% 40% at 80% 60%, ${FUI_COLORS.ambient.cool}, transparent)`}
opacity={0.6}
/>
{/* 顶部发光线 */}
<Box <Box
position="absolute" position="absolute"
top={0} top={0}
@@ -413,10 +392,8 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
{/* 右侧:搜索和操作 */} {/* 右侧:搜索和操作 */}
<SearchActions <SearchActions
inputCode={inputCode} stockCode={stockCode}
onInputChange={setInputCode} onStockChange={onStockChange}
onSearch={handleSearch}
onSelect={handleSelect}
isInWatchlist={isInWatchlist} isInWatchlist={isInWatchlist}
watchlistLoading={watchlistLoading} watchlistLoading={watchlistLoading}
onWatchlistToggle={onWatchlistToggle} onWatchlistToggle={onWatchlistToggle}

View File

@@ -15,7 +15,7 @@
* - 公司信息(成立、注册资本、所在地、官网、简介) * - 公司信息(成立、注册资本、所在地、官网、简介)
*/ */
import React from 'react'; import React, { memo } from 'react';
import { import {
Box, Box,
Flex, Flex,
@@ -540,4 +540,4 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
); );
}; };
export default StockQuoteCard; export default memo(StockQuoteCard);

View File

@@ -76,26 +76,42 @@ export const useCompanyData = ({
}, [stockCode]); }, [stockCode]);
/** /**
* 加载自选股状态 * 加载自选股状态(优化:只检查单个股票,避免加载整个列表)
*/ */
const loadWatchlistStatus = useCallback(async () => { const loadWatchlistStatus = useCallback(async () => {
if (!isAuthenticated) { if (!isAuthenticated || !stockCode) {
setIsInWatchlist(false); setIsInWatchlist(false);
return; return;
} }
try { try {
const { data } = await axios.get<ApiResponse<WatchlistItem[]>>( const { data } = await axios.get<ApiResponse<{ is_in_watchlist: boolean }>>(
'/api/account/watchlist' `/api/account/watchlist/check/${stockCode}`
); );
if (data.success && Array.isArray(data.data)) { if (data.success && data.data) {
const codes = new Set(data.data.map((item) => item.stock_code)); setIsInWatchlist(data.data.is_in_watchlist);
setIsInWatchlist(codes.has(stockCode)); } else {
setIsInWatchlist(false);
} }
} catch (error: any) { } catch (error: any) {
logger.error('useCompanyData', 'loadWatchlistStatus', error); // 接口不存在时降级到原方案
setIsInWatchlist(false); if (error.response?.status === 404) {
try {
const { data: listData } = await axios.get<ApiResponse<WatchlistItem[]>>(
'/api/account/watchlist'
);
if (listData.success && Array.isArray(listData.data)) {
const codes = new Set(listData.data.map((item) => item.stock_code));
setIsInWatchlist(codes.has(stockCode));
}
} catch {
setIsInWatchlist(false);
}
} else {
logger.error('useCompanyData', 'loadWatchlistStatus', error);
setIsInWatchlist(false);
}
} }
}, [stockCode, isAuthenticated]); }, [stockCode, isAuthenticated]);

View File

@@ -1,7 +1,7 @@
// src/views/Company/hooks/useCompanyEvents.js // src/views/Company/hooks/useCompanyEvents.js
// 公司详情页面事件追踪 Hook // 公司详情页面事件追踪 Hook
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants'; import { RETENTION_EVENTS } from '../../../lib/constants';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
@@ -14,9 +14,13 @@ import { logger } from '../../../utils/logger';
*/ */
export const useCompanyEvents = ({ stockCode } = {}) => { export const useCompanyEvents = ({ stockCode } = {}) => {
const { track } = usePostHogTrack(); const { track } = usePostHogTrack();
const hasTrackedPageView = useRef(false);
// 🎯 页面浏览事件 - 页面加载时触发 // 🎯 页面浏览事件 - 页面首次加载时触发一次
useEffect(() => { useEffect(() => {
if (hasTrackedPageView.current) return;
hasTrackedPageView.current = true;
track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, { track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
stock_code: stockCode || null, stock_code: stockCode || null,

View File

@@ -9,7 +9,7 @@
* - HeroUI 现代组件风格 * - HeroUI 现代组件风格
*/ */
import React, { memo, useCallback, useRef, useEffect } from 'react'; import React, { memo, useCallback, useRef, useEffect, useMemo } from 'react';
// FUI 动画样式 // FUI 动画样式
import './theme/fui-animations.css'; import './theme/fui-animations.css';
@@ -36,37 +36,42 @@ interface CompanyContentProps {
onTabChange: (index: number, tabKey: string) => void; onTabChange: (index: number, tabKey: string) => void;
} }
const CompanyContent = memo<CompanyContentProps>(({ const CompanyContent: React.FC<CompanyContentProps> = memo(({
stockCode, stockCode,
isInWatchlist, isInWatchlist,
watchlistLoading, watchlistLoading,
onWatchlistToggle, onWatchlistToggle,
onTabChange, onTabChange,
}) => ( }) => {
<Box maxW="container.xl" mx="auto" px={4} py={6}> // 缓存 componentProps避免每次渲染创建新对象
{/* 股票行情卡片 - 放在 Tab 切换器上方,始终可见 */} const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
<Box mb={6}>
<StockQuoteCard
stockCode={stockCode}
isInWatchlist={isInWatchlist}
isWatchlistLoading={watchlistLoading}
onWatchlistToggle={onWatchlistToggle}
/>
</Box>
{/* Tab 内容区 - 使用 FuiContainer */} return (
<FuiContainer variant="default"> <Box maxW="container.xl" mx="auto" px={4} py={6}>
<SubTabContainer {/* 股票行情卡片 - 放在 Tab 切换器上方,始终可见 */}
tabs={TAB_CONFIG} <Box mb={6}>
componentProps={{ stockCode }} <StockQuoteCard
onTabChange={onTabChange} stockCode={stockCode}
themePreset="blackGold" isInWatchlist={isInWatchlist}
contentPadding={0} isWatchlistLoading={watchlistLoading}
isLazy={true} onWatchlistToggle={onWatchlistToggle}
/> />
</FuiContainer> </Box>
</Box>
)); {/* Tab 内容区 - 使用 FuiContainer */}
<FuiContainer variant="default">
<SubTabContainer
tabs={TAB_CONFIG}
componentProps={memoizedComponentProps}
onTabChange={onTabChange}
themePreset="blackGold"
contentPadding={0}
isLazy={true}
/>
</FuiContainer>
</Box>
);
});
CompanyContent.displayName = 'CompanyContent'; CompanyContent.displayName = 'CompanyContent';