perf(web): optimize TokenRoutes rendering and add infinite scroll

Rendering optimization:
- Extract stable routePatterns derived value to avoid expensive useMemo
  recomputation on enable/disable toggle (only recompute when patterns change)
- Stabilize all RouteCard callback props with useCallback+useRef pattern
- Pre-compute missingTokenSiteItems as useMemo index instead of per-render call
- Add EMPTY_MISSING_ITEMS constant for stable empty array reference

Infinite scroll:
- Replace manual "load more" button with IntersectionObserver sentinel
- Auto-load next chunk when scrolling within 200px of bottom
This commit is contained in:
Hureru
2026-03-11 14:06:23 +08:00
parent 47064ba7af
commit a0e474c561
+119 -58
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
@@ -57,6 +57,7 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
accountOptions: [], accountOptions: [],
tokenOptionsByAccountId: {}, tokenOptionsByAccountId: {},
}; };
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [ const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' }, { value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
]; ];
@@ -348,6 +349,13 @@ export default function TokenRoutes() {
} }
}; };
// Stable derived value: only changes when route patterns change (not on enabled toggle)
const routePatterns = useMemo(
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[routeSummaries.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
);
const routeBrandById = useMemo(() => { const routeBrandById = useMemo(() => {
const next = new Map<number, BrandInfo | null>(); const next = new Map<number, BrandInfo | null>();
for (const route of routeSummaries) { for (const route of routeSummaries) {
@@ -416,7 +424,7 @@ export default function TokenRoutes() {
const routeEndpointTypesByRouteId = useMemo(() => { const routeEndpointTypesByRouteId = useMemo(() => {
const index: Record<number, Set<string>> = {}; const index: Record<number, Set<string>> = {};
const entries = Object.entries(endpointTypesByModel || {}); const entries = Object.entries(endpointTypesByModel || {});
for (const route of routeSummaries) { for (const route of routePatterns) {
const pattern = (route.modelPattern || '').trim(); const pattern = (route.modelPattern || '').trim();
if (!pattern) { if (!pattern) {
index[route.id] = new Set<string>(); index[route.id] = new Set<string>();
@@ -436,7 +444,7 @@ export default function TokenRoutes() {
index[route.id] = endpointTypes; index[route.id] = endpointTypes;
} }
return index; return index;
}, [routeSummaries, endpointTypesByModel]); }, [routePatterns, endpointTypesByModel]);
const endpointTypeList = useMemo(() => { const endpointTypeList = useMemo(() => {
const grouped = new Map<string, number>(); const grouped = new Map<string, number>();
@@ -563,9 +571,22 @@ export default function TokenRoutes() {
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
}, [filteredRoutes.length]); }, [filteredRoutes.length]);
const handleLoadMoreRoutes = () => { const handleLoadMoreRoutes = useCallback(() => {
setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK)); setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK));
}; }, [filteredRoutes.length]);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = loadMoreSentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => { if (entries[0]?.isIntersecting) handleLoadMoreRoutes(); },
{ rootMargin: '200px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [handleLoadMoreRoutes]);
const visibleRoutes = useMemo( const visibleRoutes = useMemo(
() => filteredRoutes.slice(0, visibleRouteCount), () => filteredRoutes.slice(0, visibleRouteCount),
@@ -573,13 +594,13 @@ export default function TokenRoutes() {
); );
const routeModelCandidateIndex = useMemo( const routeModelCandidateIndex = useMemo(
() => buildRouteModelCandidatesIndex(routeSummaries, modelCandidates, matchesModelPattern), () => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern),
[routeSummaries, modelCandidates], [routePatterns, modelCandidates],
); );
const routeMissingTokenIndex = useMemo( const routeMissingTokenIndex = useMemo(
() => buildRouteMissingTokenIndex(routeSummaries, missingTokenModelsByName, matchesModelPattern), () => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
[routeSummaries, missingTokenModelsByName], [routePatterns, missingTokenModelsByName],
); );
const getRouteCandidateView = (routeId: number): RouteCandidateView => { const getRouteCandidateView = (routeId: number): RouteCandidateView => {
@@ -698,30 +719,81 @@ export default function TokenRoutes() {
} }
}; };
const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => { const missingTokenSiteItemsByRouteId = useMemo(() => {
const missingTokenHints = routeMissingTokenIndex[routeId] || []; const result: Record<number, MissingTokenRouteSiteActionItem[]> = {};
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>(); for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) {
for (const hint of missingTokenHints) { const missingTokenHints = routeMissingTokenIndex[routeId] || [];
for (const account of hint.accounts) { const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; for (const hint of missingTokenHints) {
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; for (const account of hint.accounts) {
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
const accountLabel = account.username || `account-${account.accountId}`; const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
const existing = siteMap.get(key); const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
if (!existing) { const accountLabel = account.username || `account-${account.accountId}`;
siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel }); const existing = siteMap.get(key);
continue; if (!existing) {
} siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel });
if (account.accountId < existing.accountId) { continue;
existing.accountId = account.accountId; }
existing.accountLabel = accountLabel; if (account.accountId < existing.accountId) {
existing.accountId = account.accountId;
existing.accountLabel = accountLabel;
}
} }
} }
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
));
} }
return Array.from(siteMap.values()).sort((a, b) => ( return result;
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) }, [routeMissingTokenIndex]);
));
}; // Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
const toggleExpandRef = useRef(toggleExpand);
toggleExpandRef.current = toggleExpand;
const stableToggleExpand = useCallback((routeId: number) => toggleExpandRef.current(routeId), []);
const handleEditRouteRef = useRef(handleEditRoute);
handleEditRouteRef.current = handleEditRoute;
const stableEditRoute = useCallback((route: RouteSummaryRow) => handleEditRouteRef.current(route), []);
const handleDeleteRouteRef = useRef(handleDeleteRoute);
handleDeleteRouteRef.current = handleDeleteRoute;
const stableDeleteRoute = useCallback((routeId: number) => { handleDeleteRouteRef.current(routeId); }, []);
const handleToggleEnabledRef = useRef(handleToggleRouteEnabled);
handleToggleEnabledRef.current = handleToggleRouteEnabled;
const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []);
const stableTokenDraftChange = useCallback(
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
[],
);
const stableAddChannel = useCallback((routeId: number) => setAddChannelModalRouteId(routeId), []);
const stableToggleSourceGroup = useCallback(
(groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })),
[],
);
const handleChannelTokenSaveRef = useRef(handleChannelTokenSave);
handleChannelTokenSaveRef.current = handleChannelTokenSave;
const stableChannelTokenSave = useCallback(
(routeId: number, channelId: number, accountId: number) => handleChannelTokenSaveRef.current(routeId, channelId, accountId),
[],
);
const handleDeleteChannelRef = useRef(handleDeleteChannel);
handleDeleteChannelRef.current = handleDeleteChannel;
const stableDeleteChannel = useCallback(
(channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId),
[],
);
const handleChannelDragEndRef = useRef(handleChannelDragEnd);
handleChannelDragEndRef.current = handleChannelDragEnd;
const stableChannelDragEnd = useCallback(
(routeId: number, event: DragEndEvent) => handleChannelDragEndRef.current(routeId, event),
[],
);
const handleCreateTokenRef = useRef(handleCreateTokenForMissingAccount);
handleCreateTokenRef.current = handleCreateTokenForMissingAccount;
const stableCreateTokenForMissing = useCallback(
(accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName),
[],
);
const addChannelModalRoute = addChannelModalRouteId const addChannelModalRoute = addChannelModalRouteId
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null ? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
@@ -944,10 +1016,10 @@ export default function TokenRoutes() {
route={route} route={route}
brand={routeBrandById.get(route.id) || null} brand={routeBrandById.get(route.id) || null}
expanded={isExpanded} expanded={isExpanded}
onToggleExpand={() => toggleExpand(route.id)} onToggleExpand={stableToggleExpand}
onEdit={() => handleEditRoute(route)} onEdit={stableEditRoute}
onDelete={() => handleDeleteRoute(route.id)} onDelete={stableDeleteRoute}
onToggleEnabled={() => handleToggleRouteEnabled(route)} onToggleEnabled={stableToggleEnabled}
channels={channelsByRouteId[route.id]} channels={channelsByRouteId[route.id]}
loadingChannels={!!loadingChannelsByRouteId[route.id]} loadingChannels={!!loadingChannelsByRouteId[route.id]}
routeDecision={decisionByRoute[route.id] || null} routeDecision={decisionByRoute[route.id] || null}
@@ -956,37 +1028,26 @@ export default function TokenRoutes() {
channelTokenDraft={channelTokenDraft} channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel} updatingChannel={updatingChannel}
savingPriority={!!savingPriorityByRoute[route.id]} savingPriority={!!savingPriorityByRoute[route.id]}
onTokenDraftChange={(channelId, tokenId) => onTokenDraftChange={stableTokenDraftChange}
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })) onSaveToken={stableChannelTokenSave}
} onDeleteChannel={stableDeleteChannel}
onSaveToken={(channelId, accountId) => handleChannelTokenSave(route.id, channelId, accountId)} onChannelDragEnd={stableChannelDragEnd}
onDeleteChannel={(channelId) => handleDeleteChannel(channelId, route.id)} missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
onChannelDragEnd={(event) => handleChannelDragEnd(route.id, event)} onCreateTokenForMissing={stableCreateTokenForMissing}
missingTokenSiteItems={getMissingTokenSiteItems(route.id)} onAddChannel={stableAddChannel}
onCreateTokenForMissing={handleCreateTokenForMissingAccount}
onAddChannel={() => setAddChannelModalRouteId(route.id)}
expandedSourceGroupMap={expandedSourceGroupMap} expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={(groupKey) => onToggleSourceGroup={stableToggleSourceGroup}
setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] }))
}
/> />
); );
})} })}
</div> </div>
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
<div style={{ textAlign: 'center', padding: '8px 0 14px', display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}> <div
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}> ref={loadMoreSentinelRef}
{tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length} style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
</div> >
<button {tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '7px 14px', fontSize: 12 }}
onClick={handleLoadMoreRoutes}
>
{tr('加载更多路由')}
</button>
</div> </div>
)} )}