From 8ebffa4fee6afbac59cdd9f5ea2012bb69ec8f0c Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 01:19:29 +0800 Subject: [PATCH] perf(web): warm frequent routes during idle time --- .../apps/web/src/v2/layout/AppShell.tsx | 6 +- .../apps/web/src/v2/pages/StatisticsPage.tsx | 14 +++- .../apps/web/src/v2/pages/TrackPage.tsx | 12 +++- .../web/src/v2/routing/routeModules.test.ts | 44 +++++++++++- .../apps/web/src/v2/routing/routeModules.ts | 70 ++++++++++++++++++- 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index d561759c..67d72e00 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -11,10 +11,10 @@ import { IconUser, IconExit } from '@douyinfe/semi-icons'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { usePlatformSession } from '../auth/AuthGate'; -import { preloadRoute } from '../routing/routeModules'; +import { preloadRoute, scheduleIdleRoutePreloads } from '../routing/routeModules'; const navigation = [ { to: '/monitor', label: '全局监控', icon: IconHome }, @@ -39,9 +39,11 @@ const pageNames: Record = { export function AppShell() { const location = useLocation(); + const [initialPathname] = useState(() => location.pathname); const section = location.pathname.split('/')[1] || 'monitor'; const { session, logout } = usePlatformSession(); const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role]; + useEffect(() => scheduleIdleRoutePreloads({ activePathname: initialPathname }), [initialPathname]); return (
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index 64872b67..a6853296 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -1,6 +1,6 @@ import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { useQuery } from '@tanstack/react-query'; -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types'; @@ -127,7 +127,17 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha const [search, setSearch] = useState(''); const [debounced, setDebounced] = useState(''); const [open, setOpen] = useState(false); + const closeTimerRef = useRef(); useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]); + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []); + const openPicker = () => { + window.clearTimeout(closeTimerRef.current); + setOpen(true); + }; + const closePicker = () => { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = window.setTimeout(() => setOpen(false), 120); + }; const candidateParams = useMemo(() => { const params = new URLSearchParams({ limit: '12', offset: '0' }); if (debounced) params.set('keyword', debounced); @@ -157,7 +167,7 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha {value.map((vehicle) => )} - setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 120)} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" /> + { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
{open ?
车牌候选{value.length}/{MAX_SELECTED_VEHICLES} 已选
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index 242e534e..369c4424 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -75,7 +75,17 @@ function eventTone(type: string) { function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) { const [open, setOpen] = useState(false); const [debounced, setDebounced] = useState(value.trim()); + const closeTimerRef = useRef(); useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]); + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []); + const openPicker = () => { + window.clearTimeout(closeTimerRef.current); + setOpen(true); + }; + const closePicker = () => { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = window.setTimeout(() => setOpen(false), 140); + }; const params = useMemo(() => { const next = new URLSearchParams({ limit: '10', offset: '0' }); if (debounced) next.set('keyword', debounced); @@ -87,7 +97,7 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)} + value={value} onFocus={openPicker} onBlur={closePicker} onChange={(event) => { onChange(event.target.value); setOpen(true); }} /> {value ? : null} diff --git a/vehicle-data-platform/apps/web/src/v2/routing/routeModules.test.ts b/vehicle-data-platform/apps/web/src/v2/routing/routeModules.test.ts index 2d86af4b..f8c697df 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/routeModules.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/routing/routeModules.test.ts @@ -1,11 +1,51 @@ -import { describe, expect, it } from 'vitest'; -import { routeSectionForPath } from './routeModules'; +import { describe, expect, it, vi } from 'vitest'; +import { routeSectionForPath, scheduleIdleRoutePreloads, shouldPreloadRoutes } from './routeModules'; describe('route module preloading', () => { + async function flushPreloadQueue() { + for (let index = 0; index < 6; index += 1) await Promise.resolve(); + } + it('maps nested and parameterized URLs to the correct lazy module', () => { expect(routeSectionForPath('/monitor')).toBe('monitor'); expect(routeSectionForPath('/vehicles/LTEST001?tab=telemetry')).toBe('vehicles'); expect(routeSectionForPath('/alerts/rules#editor')).toBe('alerts'); expect(routeSectionForPath('/not-a-platform-route')).toBeUndefined(); }); + + it('skips background route preloads on save-data and 2G connections', () => { + expect(shouldPreloadRoutes({ saveData: true, effectiveType: '4g' })).toBe(false); + expect(shouldPreloadRoutes({ effectiveType: 'slow-2g' })).toBe(false); + expect(shouldPreloadRoutes({ effectiveType: '2g' })).toBe(false); + expect(shouldPreloadRoutes({ effectiveType: '3g' })).toBe(true); + expect(shouldPreloadRoutes({ effectiveType: '4g' })).toBe(true); + }); + + it('warms inactive routes one idle period at a time and stops after cleanup', async () => { + const callbacks: Array<() => void> = []; + const cancelled: number[] = []; + const schedule = vi.fn((callback: () => void) => { + const index = callbacks.push(callback) - 1; + return () => { cancelled.push(index); }; + }); + const preload = vi.fn(async () => undefined); + const cleanup = scheduleIdleRoutePreloads({ + activePathname: '/monitor', + connection: { effectiveType: '4g' }, + preload, + schedule + }); + + expect(schedule).toHaveBeenCalledTimes(1); + callbacks[0](); + await flushPreloadQueue(); + expect(preload).toHaveBeenCalledWith('/tracks'); + expect(schedule).toHaveBeenCalledTimes(2); + + callbacks[1](); + await flushPreloadQueue(); + expect(preload).toHaveBeenLastCalledWith('/history'); + cleanup(); + expect(cancelled).toContain(2); + }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts b/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts index 1c73934f..d216380a 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts +++ b/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts @@ -2,6 +2,8 @@ import { lazy, type ComponentType } from 'react'; export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations'; type RouteModule = { default: ComponentType }; +type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string }; +type RoutePreloadScheduler = (callback: () => void) => () => void; const importers: Record Promise> = { monitor: () => import('../pages/MonitorPage'), @@ -15,6 +17,9 @@ const importers: Record Promise> = { }; const pendingModules = new Map>(); +const backgroundPreloadOrder: RouteSection[] = [ + 'monitor', 'tracks', 'history', 'statistics' +]; function loadRoute(section: RouteSection) { const pending = pendingModules.get(section); @@ -30,9 +35,70 @@ export function routeSectionForPath(pathname: string): RouteSection | undefined return section && section in importers ? section : undefined; } -export function preloadRoute(pathname: string) { +export async function preloadRoute(pathname: string) { const section = routeSectionForPath(pathname); - return section ? loadRoute(section) : undefined; + if (!section) return undefined; + try { + return await loadRoute(section); + } catch { + // Intent/background preloads are speculative. loadRoute already evicts the + // failed Promise so a later navigation can retry through the route boundary. + return undefined; + } +} + +function browserConnection(): RoutePreloadConnection | undefined { + return (navigator as Navigator & { connection?: RoutePreloadConnection }).connection; +} + +export function shouldPreloadRoutes(connection = browserConnection()) { + if (connection?.saveData) return false; + return !String(connection?.effectiveType ?? '').toLowerCase().includes('2g'); +} + +function scheduleWhenIdle(callback: () => void) { + if (typeof window.requestIdleCallback === 'function') { + const id = window.requestIdleCallback(callback, { timeout: 2_000 }); + return () => window.cancelIdleCallback(id); + } + const id = window.setTimeout(callback, 900); + return () => window.clearTimeout(id); +} + +export function scheduleIdleRoutePreloads({ + activePathname, + connection = browserConnection(), + preload = preloadRoute, + schedule = scheduleWhenIdle +}: { + activePathname: string; + connection?: RoutePreloadConnection; + preload?: (pathname: string) => Promise | undefined; + schedule?: RoutePreloadScheduler; +}) { + if (!shouldPreloadRoutes(connection)) return () => undefined; + const activeSection = routeSectionForPath(activePathname); + const queue = backgroundPreloadOrder.filter((section) => section !== activeSection); + let cancelled = false; + let cancelScheduled: () => void = () => undefined; + + const warmNext = () => { + if (cancelled) return; + const section = queue.shift(); + if (!section) return; + void Promise.resolve() + .then(() => preload(`/${section}`)) + .catch(() => undefined) + .finally(() => { + if (!cancelled) cancelScheduled = schedule(warmNext); + }); + }; + + cancelScheduled = schedule(warmNext); + return () => { + cancelled = true; + cancelScheduled(); + }; } export const RoutePages = {