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 { arrayMove } from '@dnd-kit/sortable';
import type { DragEndEvent } from '@dnd-kit/core';
@@ -57,6 +57,7 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
accountOptions: [],
tokenOptionsByAccountId: {},
};
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
{ 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 next = new Map<number, BrandInfo | null>();
for (const route of routeSummaries) {
@@ -416,7 +424,7 @@ export default function TokenRoutes() {
const routeEndpointTypesByRouteId = useMemo(() => {
const index: Record<number, Set<string>> = {};
const entries = Object.entries(endpointTypesByModel || {});
for (const route of routeSummaries) {
for (const route of routePatterns) {
const pattern = (route.modelPattern || '').trim();
if (!pattern) {
index[route.id] = new Set<string>();
@@ -436,7 +444,7 @@ export default function TokenRoutes() {
index[route.id] = endpointTypes;
}
return index;
}, [routeSummaries, endpointTypesByModel]);
}, [routePatterns, endpointTypesByModel]);
const endpointTypeList = useMemo(() => {
const grouped = new Map<string, number>();
@@ -563,9 +571,22 @@ export default function TokenRoutes() {
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
}, [filteredRoutes.length]);
const handleLoadMoreRoutes = () => {
const handleLoadMoreRoutes = useCallback(() => {
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(
() => filteredRoutes.slice(0, visibleRouteCount),
@@ -573,13 +594,13 @@ export default function TokenRoutes() {
);
const routeModelCandidateIndex = useMemo(
() => buildRouteModelCandidatesIndex(routeSummaries, modelCandidates, matchesModelPattern),
[routeSummaries, modelCandidates],
() => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern),
[routePatterns, modelCandidates],
);
const routeMissingTokenIndex = useMemo(
() => buildRouteMissingTokenIndex(routeSummaries, missingTokenModelsByName, matchesModelPattern),
[routeSummaries, missingTokenModelsByName],
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
[routePatterns, missingTokenModelsByName],
);
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
@@ -698,30 +719,81 @@ export default function TokenRoutes() {
}
};
const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
for (const hint of missingTokenHints) {
for (const account of hint.accounts) {
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
const accountLabel = account.username || `account-${account.accountId}`;
const existing = siteMap.get(key);
if (!existing) {
siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel });
continue;
}
if (account.accountId < existing.accountId) {
existing.accountId = account.accountId;
existing.accountLabel = accountLabel;
const missingTokenSiteItemsByRouteId = useMemo(() => {
const result: Record<number, MissingTokenRouteSiteActionItem[]> = {};
for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) {
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
for (const hint of missingTokenHints) {
for (const account of hint.accounts) {
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
const accountLabel = account.username || `account-${account.accountId}`;
const existing = siteMap.get(key);
if (!existing) {
siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel });
continue;
}
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) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
));
};
return result;
}, [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
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
@@ -944,10 +1016,10 @@ export default function TokenRoutes() {
route={route}
brand={routeBrandById.get(route.id) || null}
expanded={isExpanded}
onToggleExpand={() => toggleExpand(route.id)}
onEdit={() => handleEditRoute(route)}
onDelete={() => handleDeleteRoute(route.id)}
onToggleEnabled={() => handleToggleRouteEnabled(route)}
onToggleExpand={stableToggleExpand}
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
onToggleEnabled={stableToggleEnabled}
channels={channelsByRouteId[route.id]}
loadingChannels={!!loadingChannelsByRouteId[route.id]}
routeDecision={decisionByRoute[route.id] || null}
@@ -956,37 +1028,26 @@ export default function TokenRoutes() {
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
savingPriority={!!savingPriorityByRoute[route.id]}
onTokenDraftChange={(channelId, tokenId) =>
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
}
onSaveToken={(channelId, accountId) => handleChannelTokenSave(route.id, channelId, accountId)}
onDeleteChannel={(channelId) => handleDeleteChannel(channelId, route.id)}
onChannelDragEnd={(event) => handleChannelDragEnd(route.id, event)}
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
onCreateTokenForMissing={handleCreateTokenForMissingAccount}
onAddChannel={() => setAddChannelModalRouteId(route.id)}
onTokenDraftChange={stableTokenDraftChange}
onSaveToken={stableChannelTokenSave}
onDeleteChannel={stableDeleteChannel}
onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel}
expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={(groupKey) =>
setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] }))
}
onToggleSourceGroup={stableToggleSourceGroup}
/>
);
})}
</div>
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
<div style={{ textAlign: 'center', padding: '8px 0 14px', display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
</div>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '7px 14px', fontSize: 12 }}
onClick={handleLoadMoreRoutes}
>
{tr('加载更多路由')}
</button>
<div
ref={loadMoreSentinelRef}
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
>
{tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
</div>
)}