perf(frontend): eliminate navigation loading states

This commit is contained in:
Tunglies
2026-07-20 02:43:38 +08:00
parent 971dd37965
commit e63b8e6eb0
6 changed files with 54 additions and 216 deletions
+2 -10
View File
@@ -29,10 +29,9 @@ interface Props {
children: string children: string
icon: ReactNode[] icon: ReactNode[]
sortable?: SortableProps sortable?: SortableProps
onPreload?: () => Promise<unknown>
} }
export const LayoutItem = (props: Props) => { export const LayoutItem = (props: Props) => {
const { to, children, icon, sortable, onPreload } = props const { to, children, icon, sortable } = props
const { verge } = useVerge() const { verge } = useVerge()
const { menu_icon } = verge ?? {} const { menu_icon } = verge ?? {}
const navCollapsed = verge?.collapse_navbar ?? false const navCollapsed = verge?.collapse_navbar ?? false
@@ -51,16 +50,11 @@ export const LayoutItem = (props: Props) => {
? (listeners ?? {}) ? (listeners ?? {})
: {} : {}
const handlePreload = useCallback(() => {
void onPreload?.().catch(() => {})
}, [onPreload])
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => { (event: PointerEvent<HTMLDivElement>) => {
handlePreload()
onPointerDown?.(event) onPointerDown?.(event)
}, },
[handlePreload, onPointerDown], [onPointerDown],
) )
return ( return (
@@ -105,8 +99,6 @@ export const LayoutItem = (props: Props) => {
]} ]}
title={navCollapsed ? children : undefined} title={navCollapsed ? children : undefined}
aria-label={navCollapsed ? children : undefined} aria-label={navCollapsed ? children : undefined}
onFocus={handlePreload}
onMouseEnter={handlePreload}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onClick={() => navigate(to)} onClick={() => navigate(to)}
> >
+5 -1
View File
@@ -10,6 +10,7 @@ import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import { BaseErrorBoundary } from './components/base' import { BaseErrorBoundary } from './components/base'
import { router } from './pages/_routers' import { router } from './pages/_routers'
import { preloadHomePageCards } from './pages/home'
import { AppDataProvider } from './providers/app-data-provider' import { AppDataProvider } from './providers/app-data-provider'
import { WindowProvider } from './providers/window' import { WindowProvider } from './providers/window'
import { FALLBACK_LANGUAGE, initializeLanguage } from './services/i18n' import { FALLBACK_LANGUAGE, initializeLanguage } from './services/i18n'
@@ -65,7 +66,10 @@ const initializeApp = (initialThemeMode: 'light' | 'dark') => {
} }
const bootstrap = async () => { const bootstrap = async () => {
const { initialThemeMode } = await preloadAppData() const appDataPromise = preloadAppData()
void preloadHomePageCards()
const { initialThemeMode } = await appDataPromise
initializeApp(initialThemeMode) initializeApp(initialThemeMode)
} }
+5 -70
View File
@@ -24,22 +24,14 @@ import {
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next' 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 iconDark from '@/assets/image/icon_dark.svg?react'
import iconLight from '@/assets/image/icon_light.svg?react' import iconLight from '@/assets/image/icon_light.svg?react'
import LogoSvg from '@/assets/image/logo.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 { LayoutItem } from '@/components/layout/layout-item'
import { LayoutTraffic } from '@/components/layout/layout-traffic' import { LayoutTraffic } from '@/components/layout/layout-traffic'
import { NoticeManager } from '@/components/layout/notice-manager' import { NoticeManager } from '@/components/layout/notice-manager'
@@ -50,7 +42,6 @@ import {
} from '@/components/layout/window-controller' } from '@/components/layout/window-controller'
import { useI18n } from '@/hooks/use-i18n' import { useI18n } from '@/hooks/use-i18n'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useVisibility } from '@/hooks/use-visibility'
import { useWindowDecorations } from '@/hooks/use-window' import { useWindowDecorations } from '@/hooks/use-window'
import { useThemeMode } from '@/services/states' import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
@@ -62,17 +53,11 @@ import {
useNavMenuOrder, useNavMenuOrder,
} from './_layout/hooks' } from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils' import { handleNoticeMessage } from './_layout/utils'
import { import { navItems } from './_navigation'
navItems,
preloadLogsPage,
preloadNavigationRoutes,
} from './_navigation'
import 'dayjs/locale/ru' import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
const LogsPage = lazy(() => preloadLogsPage())
type NavItem = (typeof navItems)[number] type NavItem = (typeof navItems)[number]
type MenuContextPosition = { top: number; left: number } type MenuContextPosition = { top: number; left: number }
@@ -107,7 +92,6 @@ const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => {
<LayoutItem <LayoutItem
to={item.path} to={item.path}
icon={item.icon} icon={item.icon}
onPreload={item.preload}
sortable={{ sortable={{
setNodeRef, setNodeRef,
attributes, attributes,
@@ -135,9 +119,6 @@ const Layout = () => {
const navCollapsed = verge?.collapse_navbar ?? false const navCollapsed = verge?.collapse_navbar ?? false
const { switchLanguage } = useI18n() const { switchLanguage } = useI18n()
const navigate = useNavigate() const navigate = useNavigate()
const { pathname } = useLocation()
const isLogsPage = pathname === '/logs'
const pageVisible = useVisibility()
const themeReady = useMemo(() => Boolean(theme), [theme]) const themeReady = useMemo(() => Boolean(theme), [theme])
const [menuUnlocked, setMenuUnlocked] = useState(false) const [menuUnlocked, setMenuUnlocked] = useState(false)
@@ -236,19 +217,6 @@ const Layout = () => {
useLoadingOverlay(themeReady) useLoadingOverlay(themeReady)
useEffect(() => {
if (!themeReady || !pageVisible) {
return
}
const controller = new AbortController()
void preloadNavigationRoutes(controller.signal)
return () => {
controller.abort()
}
}, [themeReady, pageVisible])
const handleNotice = useCallback( const handleNotice = useCallback(
(payload: [string, string]) => { (payload: [string, string]) => {
const [status, msg] = payload const [status, msg] = payload
@@ -425,12 +393,7 @@ const Layout = () => {
return null return null
} }
return ( return (
<LayoutItem <LayoutItem key={item.path} to={item.path} icon={item.icon}>
key={item.path}
to={item.path}
icon={item.icon}
onPreload={item.preload}
>
{t(item.label)} {t(item.label)}
</LayoutItem> </LayoutItem>
) )
@@ -490,34 +453,6 @@ const Layout = () => {
<BaseErrorBoundary> <BaseErrorBoundary>
<Outlet /> <Outlet />
</BaseErrorBoundary> </BaseErrorBoundary>
{isLogsPage && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<Suspense
fallback={
<Box
sx={{
display: 'flex',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BaseLoading />
</Box>
}
>
<LogsPage />
</Suspense>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+15 -122
View File
@@ -6,8 +6,7 @@ import LockOpenOutlinedIcon from '@mui/icons-material/LockOpenOutlined'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import SubjectOutlinedIcon from '@mui/icons-material/SubjectOutlined' import SubjectOutlinedIcon from '@mui/icons-material/SubjectOutlined'
import WifiOutlinedIcon from '@mui/icons-material/WifiOutlined' import WifiOutlinedIcon from '@mui/icons-material/WifiOutlined'
import { Box } from '@mui/material' import { type ComponentType, type ReactNode } from 'react'
import { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
import ConnectionsSvg from '@/assets/image/itemicon/connections.svg?react' import ConnectionsSvg from '@/assets/image/itemicon/connections.svg?react'
import HomeSvg from '@/assets/image/itemicon/home.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 RulesSvg from '@/assets/image/itemicon/rules.svg?react'
import SettingsSvg from '@/assets/image/itemicon/settings.svg?react' import SettingsSvg from '@/assets/image/itemicon/settings.svg?react'
import UnlockSvg from '@/assets/image/itemicon/unlock.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 { navigationItems } from './_navigation-meta'
import ConnectionsPage from './connections'
import HomePage from './home' 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 = { type NavigationItem = {
label: (typeof navigationItems)[keyof typeof navigationItems]['label'] label: (typeof navigationItems)[keyof typeof navigationItems]['label']
path: string path: string
icon: ReactNode[] icon: ReactNode[]
Component: ComponentType Component: ComponentType
preload?: () => Promise<{ default: ComponentType }>
} }
const waitForWarmupIdle = (signal: AbortSignal) =>
new Promise<void>((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 = () => (
<Suspense
fallback={
<Box
sx={{
display: 'flex',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BaseLoading />
</Box>
}
>
<Component />
</Suspense>
)
return { Component: LazyRoute, preload }
}
export const preloadLogsPage = createRoutePreload(
() => import('./logs'),
'logs',
)
export const navItems: NavigationItem[] = [ export const navItems: NavigationItem[] = [
{ {
...navigationItems.home, ...navigationItems.home,
@@ -135,51 +43,36 @@ export const navItems: NavigationItem[] = [
{ {
...navigationItems.proxies, ...navigationItems.proxies,
icon: [<WifiOutlinedIcon key="mui" />, <ProxiesSvg key="svg" />], icon: [<WifiOutlinedIcon key="mui" />, <ProxiesSvg key="svg" />],
...createLazyRoute(() => import('./proxies')), Component: ProxyPage,
}, },
{ {
...navigationItems.profiles, ...navigationItems.profiles,
icon: [<DnsOutlinedIcon key="mui" />, <ProfilesSvg key="svg" />], icon: [<DnsOutlinedIcon key="mui" />, <ProfilesSvg key="svg" />],
...createLazyRoute(() => import('./profiles'), 'rules'), Component: ProfilePage,
}, },
{ {
...navigationItems.connections, ...navigationItems.connections,
icon: [<LanguageOutlinedIcon key="mui" />, <ConnectionsSvg key="svg" />], icon: [<LanguageOutlinedIcon key="mui" />, <ConnectionsSvg key="svg" />],
...createLazyRoute(() => import('./connections'), 'connections'), Component: ConnectionsPage,
}, },
{ {
...navigationItems.rules, ...navigationItems.rules,
icon: [<ForkRightOutlinedIcon key="mui" />, <RulesSvg key="svg" />], icon: [<ForkRightOutlinedIcon key="mui" />, <RulesSvg key="svg" />],
...createLazyRoute(() => import('./rules'), 'rules'), Component: RulesPage,
}, },
{ {
...navigationItems.logs, ...navigationItems.logs,
icon: [<SubjectOutlinedIcon key="mui" />, <LogsSvg key="svg" />], icon: [<SubjectOutlinedIcon key="mui" />, <LogsSvg key="svg" />],
Component: () => null /* LogsPage rendered in Layout only on /logs route */, Component: LogsPage,
preload: preloadLogsPage,
}, },
{ {
...navigationItems.unlock, ...navigationItems.unlock,
icon: [<LockOpenOutlinedIcon key="mui" />, <UnlockSvg key="svg" />], icon: [<LockOpenOutlinedIcon key="mui" />, <UnlockSvg key="svg" />],
...createLazyRoute(() => import('./unlock')), Component: UnlockPage,
}, },
{ {
...navigationItems.settings, ...navigationItems.settings,
icon: [<SettingsOutlinedIcon key="mui" />, <SettingsSvg key="svg" />], icon: [<SettingsOutlinedIcon key="mui" />, <SettingsSvg key="svg" />],
...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(() => {})
}),
)
}
+23 -12
View File
@@ -36,26 +36,37 @@ import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { entry_lightweight_mode, openWebUrl } from '@/services/cmds' import { entry_lightweight_mode, openWebUrl } from '@/services/cmds'
const LazyTestCard = lazy(() => const preloadTestCard = () =>
import('@/components/home/test-card').then((module) => ({ import('@/components/home/test-card').then((module) => ({
default: module.TestCard, default: module.TestCard,
})), }))
) const preloadIpInfoCard = () =>
const LazyIpInfoCard = lazy(() =>
import('@/components/home/ip-info-card').then((module) => ({ import('@/components/home/ip-info-card').then((module) => ({
default: module.IpInfoCard, default: module.IpInfoCard,
})), }))
) const preloadClashInfoCard = () =>
const LazyClashInfoCard = lazy(() =>
import('@/components/home/clash-info-card').then((module) => ({ import('@/components/home/clash-info-card').then((module) => ({
default: module.ClashInfoCard, default: module.ClashInfoCard,
})), }))
) const preloadSystemInfoCard = () =>
const LazySystemInfoCard = lazy(() =>
import('@/components/home/system-info-card').then((module) => ({ import('@/components/home/system-info-card').then((module) => ({
default: module.SystemInfoCard, 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 { interface HomeCardsSettings {
+4 -1
View File
@@ -89,6 +89,9 @@ const STARTUP_LANGUAGE_SECTIONS = [
'profiles', 'profiles',
'proxies', 'proxies',
'tests', 'tests',
'rules',
'connections',
'logs',
] as const ] as const
const localeModules = import.meta.glob<LocaleModule>('@/locales/*/*.json') const localeModules = import.meta.glob<LocaleModule>('@/locales/*/*.json')
@@ -174,7 +177,7 @@ export const changeLanguage = async (language: string) => {
) )
await ensureLanguageSections( await ensureLanguageSections(
loadedSections.length ? loadedSections : STARTUP_LANGUAGE_SECTIONS, [...new Set([...loadedSections, ...STARTUP_LANGUAGE_SECTIONS])],
targetLanguage, targetLanguage,
) )