perf(web): warm frequent routes during idle time
This commit is contained in:
@@ -11,10 +11,10 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconExit
|
IconExit
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { usePlatformSession } from '../auth/AuthGate';
|
import { usePlatformSession } from '../auth/AuthGate';
|
||||||
import { preloadRoute } from '../routing/routeModules';
|
import { preloadRoute, scheduleIdleRoutePreloads } from '../routing/routeModules';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ to: '/monitor', label: '全局监控', icon: IconHome },
|
{ to: '/monitor', label: '全局监控', icon: IconHome },
|
||||||
@@ -39,9 +39,11 @@ const pageNames: Record<string, string> = {
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [initialPathname] = useState(() => location.pathname);
|
||||||
const section = location.pathname.split('/')[1] || 'monitor';
|
const section = location.pathname.split('/')[1] || 'monitor';
|
||||||
const { session, logout } = usePlatformSession();
|
const { session, logout } = usePlatformSession();
|
||||||
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
|
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
|
||||||
|
useEffect(() => scheduleIdleRoutePreloads({ activePathname: initialPathname }), [initialPathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="v2-shell">
|
<div className="v2-shell">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
|
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
|
||||||
@@ -127,7 +127,17 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const closeTimerRef = useRef<number>();
|
||||||
useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]);
|
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 candidateParams = useMemo(() => {
|
||||||
const params = new URLSearchParams({ limit: '12', offset: '0' });
|
const params = new URLSearchParams({ limit: '12', offset: '0' });
|
||||||
if (debounced) params.set('keyword', debounced);
|
if (debounced) params.set('keyword', debounced);
|
||||||
@@ -157,7 +167,7 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
|||||||
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
|
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
|
||||||
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
|
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
|
||||||
</button>)}
|
</button>)}
|
||||||
<input value={search} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 120)} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
|
<input value={search} onFocus={openPicker} onBlur={closePicker} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
|
||||||
</div>
|
</div>
|
||||||
{open ? <div className="v2-mileage-options" role="listbox">
|
{open ? <div className="v2-mileage-options" role="listbox">
|
||||||
<header><span>车牌候选</span><em>{value.length}/{MAX_SELECTED_VEHICLES} 已选</em></header>
|
<header><span>车牌候选</span><em>{value.length}/{MAX_SELECTED_VEHICLES} 已选</em></header>
|
||||||
|
|||||||
@@ -75,7 +75,17 @@ function eventTone(type: string) {
|
|||||||
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
|
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [debounced, setDebounced] = useState(value.trim());
|
const [debounced, setDebounced] = useState(value.trim());
|
||||||
|
const closeTimerRef = useRef<number>();
|
||||||
useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]);
|
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 params = useMemo(() => {
|
||||||
const next = new URLSearchParams({ limit: '10', offset: '0' });
|
const next = new URLSearchParams({ limit: '10', offset: '0' });
|
||||||
if (debounced) next.set('keyword', debounced);
|
if (debounced) next.set('keyword', debounced);
|
||||||
@@ -87,7 +97,7 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
|
|||||||
<IconSearch />
|
<IconSearch />
|
||||||
<input
|
<input
|
||||||
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
|
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
|
||||||
value={value} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)}
|
value={value} onFocus={openPicker} onBlur={closePicker}
|
||||||
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
|
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
|
||||||
/>
|
/>
|
||||||
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
|
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { routeSectionForPath } from './routeModules';
|
import { routeSectionForPath, scheduleIdleRoutePreloads, shouldPreloadRoutes } from './routeModules';
|
||||||
|
|
||||||
describe('route module preloading', () => {
|
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', () => {
|
it('maps nested and parameterized URLs to the correct lazy module', () => {
|
||||||
expect(routeSectionForPath('/monitor')).toBe('monitor');
|
expect(routeSectionForPath('/monitor')).toBe('monitor');
|
||||||
expect(routeSectionForPath('/vehicles/LTEST001?tab=telemetry')).toBe('vehicles');
|
expect(routeSectionForPath('/vehicles/LTEST001?tab=telemetry')).toBe('vehicles');
|
||||||
expect(routeSectionForPath('/alerts/rules#editor')).toBe('alerts');
|
expect(routeSectionForPath('/alerts/rules#editor')).toBe('alerts');
|
||||||
expect(routeSectionForPath('/not-a-platform-route')).toBeUndefined();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { lazy, type ComponentType } from 'react';
|
|||||||
|
|
||||||
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
|
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
|
||||||
type RouteModule = { default: ComponentType };
|
type RouteModule = { default: ComponentType };
|
||||||
|
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
|
||||||
|
type RoutePreloadScheduler = (callback: () => void) => () => void;
|
||||||
|
|
||||||
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
|
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
|
||||||
monitor: () => import('../pages/MonitorPage'),
|
monitor: () => import('../pages/MonitorPage'),
|
||||||
@@ -15,6 +17,9 @@ const importers: Record<RouteSection, () => Promise<RouteModule>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
|
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
|
||||||
|
const backgroundPreloadOrder: RouteSection[] = [
|
||||||
|
'monitor', 'tracks', 'history', 'statistics'
|
||||||
|
];
|
||||||
|
|
||||||
function loadRoute(section: RouteSection) {
|
function loadRoute(section: RouteSection) {
|
||||||
const pending = pendingModules.get(section);
|
const pending = pendingModules.get(section);
|
||||||
@@ -30,9 +35,70 @@ export function routeSectionForPath(pathname: string): RouteSection | undefined
|
|||||||
return section && section in importers ? section : undefined;
|
return section && section in importers ? section : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function preloadRoute(pathname: string) {
|
export async function preloadRoute(pathname: string) {
|
||||||
const section = routeSectionForPath(pathname);
|
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<unknown> | 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 = {
|
export const RoutePages = {
|
||||||
|
|||||||
Reference in New Issue
Block a user