From e63b8e6eb0ac8024a7128ffd646800a9f08bd51e Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:11:44 +0800 Subject: [PATCH] perf(frontend): eliminate navigation loading states --- src/components/layout/layout-item.tsx | 12 +-- src/main.tsx | 6 +- src/pages/_layout.tsx | 75 +------------- src/pages/_navigation.tsx | 137 +++----------------------- src/pages/home.tsx | 35 ++++--- src/services/i18n.ts | 5 +- 6 files changed, 54 insertions(+), 216 deletions(-) diff --git a/src/components/layout/layout-item.tsx b/src/components/layout/layout-item.tsx index 67365aac3..5873c25c8 100644 --- a/src/components/layout/layout-item.tsx +++ b/src/components/layout/layout-item.tsx @@ -29,10 +29,9 @@ interface Props { children: string icon: ReactNode[] sortable?: SortableProps - onPreload?: () => Promise } export const LayoutItem = (props: Props) => { - const { to, children, icon, sortable, onPreload } = props + const { to, children, icon, sortable } = props const { verge } = useVerge() const { menu_icon } = verge ?? {} const navCollapsed = verge?.collapse_navbar ?? false @@ -51,16 +50,11 @@ export const LayoutItem = (props: Props) => { ? (listeners ?? {}) : {} - const handlePreload = useCallback(() => { - void onPreload?.().catch(() => {}) - }, [onPreload]) - const handlePointerDown = useCallback( (event: PointerEvent) => { - handlePreload() onPointerDown?.(event) }, - [handlePreload, onPointerDown], + [onPointerDown], ) return ( @@ -105,8 +99,6 @@ export const LayoutItem = (props: Props) => { ]} title={navCollapsed ? children : undefined} aria-label={navCollapsed ? children : undefined} - onFocus={handlePreload} - onMouseEnter={handlePreload} onPointerDown={handlePointerDown} onClick={() => navigate(to)} > diff --git a/src/main.tsx b/src/main.tsx index a793010d0..01a683f28 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' import { BaseErrorBoundary } from './components/base' import { router } from './pages/_routers' +import { preloadHomePageCards } from './pages/home' import { AppDataProvider } from './providers/app-data-provider' import { WindowProvider } from './providers/window' import { FALLBACK_LANGUAGE, initializeLanguage } from './services/i18n' @@ -65,7 +66,10 @@ const initializeApp = (initialThemeMode: 'light' | 'dark') => { } const bootstrap = async () => { - const { initialThemeMode } = await preloadAppData() + const appDataPromise = preloadAppData() + void preloadHomePageCards() + + const { initialThemeMode } = await appDataPromise initializeApp(initialThemeMode) } diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index aeedf4164..3b6bd61e3 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -24,22 +24,14 @@ import { import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import type { CSSProperties } from 'react' -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Outlet, useLocation, useNavigate } from 'react-router' +import { Outlet, useNavigate } from 'react-router' import iconDark from '@/assets/image/icon_dark.svg?react' import iconLight from '@/assets/image/icon_light.svg?react' import LogoSvg from '@/assets/image/logo.svg?react' -import { BaseErrorBoundary, BaseLoading } from '@/components/base' +import { BaseErrorBoundary } from '@/components/base' import { LayoutItem } from '@/components/layout/layout-item' import { LayoutTraffic } from '@/components/layout/layout-traffic' import { NoticeManager } from '@/components/layout/notice-manager' @@ -50,7 +42,6 @@ import { } from '@/components/layout/window-controller' import { useI18n } from '@/hooks/use-i18n' import { useVerge } from '@/hooks/use-verge' -import { useVisibility } from '@/hooks/use-visibility' import { useWindowDecorations } from '@/hooks/use-window' import { useThemeMode } from '@/services/states' import getSystem from '@/utils/get-system' @@ -62,17 +53,11 @@ import { useNavMenuOrder, } from './_layout/hooks' import { handleNoticeMessage } from './_layout/utils' -import { - navItems, - preloadLogsPage, - preloadNavigationRoutes, -} from './_navigation' +import { navItems } from './_navigation' import 'dayjs/locale/ru' import 'dayjs/locale/zh-cn' -const LogsPage = lazy(() => preloadLogsPage()) - type NavItem = (typeof navItems)[number] type MenuContextPosition = { top: number; left: number } @@ -107,7 +92,6 @@ const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => { { const navCollapsed = verge?.collapse_navbar ?? false const { switchLanguage } = useI18n() const navigate = useNavigate() - const { pathname } = useLocation() - const isLogsPage = pathname === '/logs' - const pageVisible = useVisibility() const themeReady = useMemo(() => Boolean(theme), [theme]) const [menuUnlocked, setMenuUnlocked] = useState(false) @@ -236,19 +217,6 @@ const Layout = () => { useLoadingOverlay(themeReady) - useEffect(() => { - if (!themeReady || !pageVisible) { - return - } - - const controller = new AbortController() - void preloadNavigationRoutes(controller.signal) - - return () => { - controller.abort() - } - }, [themeReady, pageVisible]) - const handleNotice = useCallback( (payload: [string, string]) => { const [status, msg] = payload @@ -425,12 +393,7 @@ const Layout = () => { return null } return ( - + {t(item.label)} ) @@ -490,34 +453,6 @@ const Layout = () => { - {isLogsPage && ( -
- - - - } - > - - -
- )} diff --git a/src/pages/_navigation.tsx b/src/pages/_navigation.tsx index ee3f4a63a..1870ce521 100644 --- a/src/pages/_navigation.tsx +++ b/src/pages/_navigation.tsx @@ -6,8 +6,7 @@ import LockOpenOutlinedIcon from '@mui/icons-material/LockOpenOutlined' import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import SubjectOutlinedIcon from '@mui/icons-material/SubjectOutlined' import WifiOutlinedIcon from '@mui/icons-material/WifiOutlined' -import { Box } from '@mui/material' -import { lazy, Suspense, type ComponentType, type ReactNode } from 'react' +import { type ComponentType, type ReactNode } from 'react' import ConnectionsSvg from '@/assets/image/itemicon/connections.svg?react' import HomeSvg from '@/assets/image/itemicon/home.svg?react' @@ -17,115 +16,24 @@ import ProxiesSvg from '@/assets/image/itemicon/proxies.svg?react' import RulesSvg from '@/assets/image/itemicon/rules.svg?react' import SettingsSvg from '@/assets/image/itemicon/settings.svg?react' import UnlockSvg from '@/assets/image/itemicon/unlock.svg?react' -import { BaseLoading } from '@/components/base' -import { ensureLanguageSections } from '@/services/i18n' import { navigationItems } from './_navigation-meta' +import ConnectionsPage from './connections' import HomePage from './home' +import LogsPage from './logs' +import ProfilePage from './profiles' +import ProxyPage from './proxies' +import RulesPage from './rules' +import SettingPage from './settings' +import UnlockPage from './unlock' type NavigationItem = { label: (typeof navigationItems)[keyof typeof navigationItems]['label'] path: string icon: ReactNode[] Component: ComponentType - preload?: () => Promise<{ default: ComponentType }> } -const waitForWarmupIdle = (signal: AbortSignal) => - new Promise((resolve) => { - let idleId: number | undefined - let timeoutId: number | undefined - - const cleanup = () => { - signal.removeEventListener('abort', finish) - if (idleId !== undefined) { - window.cancelIdleCallback(idleId) - } - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId) - } - } - - const finish = () => { - cleanup() - resolve() - } - - if (signal.aborted) { - resolve() - return - } - - signal.addEventListener('abort', finish, { once: true }) - - if (window.requestIdleCallback) { - idleId = window.requestIdleCallback(finish, { timeout: 500 }) - } else { - timeoutId = window.setTimeout(finish, 120) - } - }) - -const createRoutePreload = ( - load: () => Promise<{ default: ComponentType }>, - sections?: string | readonly string[], -) => { - let componentPromise: Promise<{ default: ComponentType }> | undefined - - const loadComponent = () => { - componentPromise ??= load().catch((error) => { - componentPromise = undefined - throw error - }) - - return componentPromise - } - - if (!sections) { - return loadComponent - } - - return async () => { - const [component] = await Promise.all([ - loadComponent(), - ensureLanguageSections(sections), - ]) - return component - } -} - -const createLazyRoute = ( - load: () => Promise<{ default: ComponentType }>, - sections?: string | readonly string[], -) => { - const preload = createRoutePreload(load, sections) - const Component = lazy(preload) - const LazyRoute = () => ( - - - - } - > - - - ) - - return { Component: LazyRoute, preload } -} - -export const preloadLogsPage = createRoutePreload( - () => import('./logs'), - 'logs', -) - export const navItems: NavigationItem[] = [ { ...navigationItems.home, @@ -135,51 +43,36 @@ export const navItems: NavigationItem[] = [ { ...navigationItems.proxies, icon: [, ], - ...createLazyRoute(() => import('./proxies')), + Component: ProxyPage, }, { ...navigationItems.profiles, icon: [, ], - ...createLazyRoute(() => import('./profiles'), 'rules'), + Component: ProfilePage, }, { ...navigationItems.connections, icon: [, ], - ...createLazyRoute(() => import('./connections'), 'connections'), + Component: ConnectionsPage, }, { ...navigationItems.rules, icon: [, ], - ...createLazyRoute(() => import('./rules'), 'rules'), + Component: RulesPage, }, { ...navigationItems.logs, icon: [, ], - Component: () => null /* LogsPage rendered in Layout only on /logs route */, - preload: preloadLogsPage, + Component: LogsPage, }, { ...navigationItems.unlock, icon: [, ], - ...createLazyRoute(() => import('./unlock')), + Component: UnlockPage, }, { ...navigationItems.settings, icon: [, ], - ...createLazyRoute(() => import('./settings')), + Component: SettingPage, }, ] - -export const preloadNavigationRoutes = async (signal: AbortSignal) => { - await waitForWarmupIdle(signal) - if (signal.aborted) { - return - } - - await Promise.all( - navItems.map((item) => { - const preload = 'preload' in item ? item.preload : undefined - return preload?.().catch(() => {}) - }), - ) -} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5c79d739e..85196aa1b 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -36,26 +36,37 @@ import { useProfiles } from '@/hooks/use-profiles' import { useVerge } from '@/hooks/use-verge' import { entry_lightweight_mode, openWebUrl } from '@/services/cmds' -const LazyTestCard = lazy(() => +const preloadTestCard = () => import('@/components/home/test-card').then((module) => ({ default: module.TestCard, - })), -) -const LazyIpInfoCard = lazy(() => + })) +const preloadIpInfoCard = () => import('@/components/home/ip-info-card').then((module) => ({ default: module.IpInfoCard, - })), -) -const LazyClashInfoCard = lazy(() => + })) +const preloadClashInfoCard = () => import('@/components/home/clash-info-card').then((module) => ({ default: module.ClashInfoCard, - })), -) -const LazySystemInfoCard = lazy(() => + })) +const preloadSystemInfoCard = () => import('@/components/home/system-info-card').then((module) => ({ default: module.SystemInfoCard, - })), -) + })) + +const LazyTestCard = lazy(preloadTestCard) +const LazyIpInfoCard = lazy(preloadIpInfoCard) +const LazyClashInfoCard = lazy(preloadClashInfoCard) +const LazySystemInfoCard = lazy(preloadSystemInfoCard) + +// Used by bootstrap to initiate optional card imports without blocking render. +// eslint-disable-next-line react-refresh/only-export-components +export const preloadHomePageCards = () => + Promise.all([ + preloadTestCard().catch(() => {}), + preloadIpInfoCard().catch(() => {}), + preloadClashInfoCard().catch(() => {}), + preloadSystemInfoCard().catch(() => {}), + ]) // 定义首页卡片设置接口 interface HomeCardsSettings { diff --git a/src/services/i18n.ts b/src/services/i18n.ts index 2bdee4b2f..cf0b3b318 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -89,6 +89,9 @@ const STARTUP_LANGUAGE_SECTIONS = [ 'profiles', 'proxies', 'tests', + 'rules', + 'connections', + 'logs', ] as const const localeModules = import.meta.glob('@/locales/*/*.json') @@ -174,7 +177,7 @@ export const changeLanguage = async (language: string) => { ) await ensureLanguageSections( - loadedSections.length ? loadedSections : STARTUP_LANGUAGE_SECTIONS, + [...new Set([...loadedSections, ...STARTUP_LANGUAGE_SECTIONS])], targetLanguage, )