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:
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user