refactor: 重构 RouterContainer 组件以支持懒加载页面模块

- 移除不必要的 Suspense 组件,改为使用 LoadedPage 组件进行懒加载。
- 更新特性获取逻辑,使用 getFeatureByKey 函数替代直接查找。
- 新增 featureMeta.ts 文件,集中管理功能配置和相关方法。
- 实现 pageLoaders 以支持按需加载页面模块,提升性能和用户体验。
This commit is contained in:
2026-06-30 22:45:32 +08:00
parent d18ae5d41c
commit c7f4305fc3
17 changed files with 159 additions and 68 deletions
+35 -10
View File
@@ -1,11 +1,41 @@
import { FEATURES } from '@/config/features'; import { getFeatureByKey } from '@/config/features';
import { loadPage } from '@/config/pageLoaders/index';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react'; import type { PageType } from '@/types/storage';
import { type ComponentType, useEffect, useState } from 'react';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
function LoadedPage({ pageKey }: { pageKey: PageType }) {
const [Page, setPage] = useState<ComponentType | null>(null);
useEffect(() => {
let cancelled = false;
loadPage(pageKey)
.then((mod) => {
if (!cancelled) {
setPage(() => mod.default);
}
})
.catch((err) => {
console.error('[Router Page Load Error]', err);
});
return () => {
cancelled = true;
};
}, [pageKey]);
if (!Page) {
return <PageSkeleton variant={pageKey === 'dashboard' ? 'dashboard' : 'tool'} />;
}
return <Page />;
}
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
@@ -16,8 +46,7 @@ export default function RouterContainer() {
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />; return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
} }
const currentFeature = FEATURES.find((f) => f.key === currentPage); const currentFeature = getFeatureByKey(currentPage);
const MatchedComponent = currentFeature?.component;
return ( return (
<div <div
@@ -27,13 +56,10 @@ export default function RouterContainer() {
'scrollbar-gutter-stable motion-reduce:transition-none', 'scrollbar-gutter-stable motion-reduce:transition-none',
animationClass, animationClass,
)} )}
>
<Suspense
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
> >
<PageErrorBoundary resetKey={currentPage}> <PageErrorBoundary resetKey={currentPage}>
{MatchedComponent ? ( {currentFeature ? (
<MatchedComponent /> <LoadedPage key={currentPage} pageKey={currentPage} />
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300"> <div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
@@ -46,7 +72,6 @@ export default function RouterContainer() {
</div> </div>
)} )}
</PageErrorBoundary> </PageErrorBoundary>
</Suspense>
</div> </div>
); );
} }
+12 -10
View File
@@ -67,19 +67,21 @@ describe('features 懒加载', () => {
expect(pageLoadTracker.loaded).toEqual([]); expect(pageLoadTracker.loaded).toEqual([]);
}); });
it('渲染 lazy 组件时才应加载对应页面模块', async () => { it('loadPage 应仅加载对应页面模块', async () => {
const React = await import('react'); const { loadPage } = await import('@/config/pageLoaders/index');
const { render, waitFor } = await import('@testing-library/react');
const { FEATURES } = await import('@/config/features');
const DashboardPage = FEATURES.find((f) => f.key === 'dashboard')!.component; await loadPage('dashboard');
render(
React.createElement(React.Suspense, { fallback: null }, React.createElement(DashboardPage)),
);
await waitFor(() => {
expect(pageLoadTracker.loaded).toEqual(['Dashboard']); expect(pageLoadTracker.loaded).toEqual(['Dashboard']);
}); });
it('loadPage 切换页面时不应加载无关页面模块', async () => {
const { loadPage } = await import('@/config/pageLoaders/index');
await loadPage('timestamp');
expect(pageLoadTracker.loaded).toEqual(['Timestamp']);
expect(pageLoadTracker.loaded).not.toContain('QrCode');
expect(pageLoadTracker.loaded).not.toContain('TestDataGenerator');
}); });
}); });
+1 -3
View File
@@ -19,13 +19,11 @@ describe('features', () => {
expect(feature).toHaveProperty('label'); expect(feature).toHaveProperty('label');
expect(feature).toHaveProperty('description'); expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('component'); expect(feature).not.toHaveProperty('component');
expect(typeof feature.key).toBe('string'); expect(typeof feature.key).toBe('string');
expect(typeof feature.label).toBe('string'); expect(typeof feature.label).toBe('string');
expect(typeof feature.description).toBe('string'); expect(typeof feature.description).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.defaultVisible).toBe('boolean');
expect(feature.component).toBeDefined();
expect(['function', 'object']).toContain(typeof feature.component);
if (feature.key !== 'dashboard') { if (feature.key !== 'dashboard') {
expect(feature).toHaveProperty('icon'); expect(feature).toHaveProperty('icon');
@@ -1,4 +1,4 @@
import { type ComponentType, lazy } from 'react'; import type { ComponentType } from 'react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { import {
@@ -15,18 +15,6 @@ import {
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
// 懒加载页面组件
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
const QrCodePage = lazy(() => import('@/pages/QrCode'));
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator'));
export interface FeatureConfig { export interface FeatureConfig {
key: PageType; key: PageType;
label: string; label: string;
@@ -34,7 +22,6 @@ export interface FeatureConfig {
themeColorKey?: PaletteColorKey; themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>; icon?: ComponentType<LucideProps>;
defaultVisible: boolean; defaultVisible: boolean;
component: ComponentType;
} }
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
@@ -43,7 +30,6 @@ export const FEATURES: FeatureConfig[] = [
label: '仪表盘', label: '仪表盘',
description: '', description: '',
defaultVisible: true, defaultVisible: true,
component: DashboardPage,
}, },
{ {
key: 'timestamp', key: 'timestamp',
@@ -52,7 +38,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
component: TimestampPage,
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
@@ -61,7 +46,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
component: StorageCleanerPage,
}, },
{ {
key: 'qrCode', key: 'qrCode',
@@ -70,7 +54,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
component: QrCodePage,
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
@@ -79,7 +62,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
component: TextStatisticsPage,
}, },
{ {
key: 'jwt', key: 'jwt',
@@ -88,7 +70,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
component: JwtPage,
}, },
{ {
key: 'jsonTools', key: 'jsonTools',
@@ -97,7 +78,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
component: JsonToolsPage,
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
@@ -106,7 +86,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
component: Base64ConverterPage,
}, },
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
@@ -115,7 +94,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
component: RightClickRestorerPage,
}, },
{ {
key: 'testDataGenerator', key: 'testDataGenerator',
@@ -124,7 +102,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'warning', themeColorKey: 'warning',
icon: FileSpreadsheet, icon: FileSpreadsheet,
defaultVisible: true, defaultVisible: true,
component: TestDataGeneratorPage,
}, },
]; ];
+9
View File
@@ -0,0 +1,9 @@
export type { FeatureConfig, PaletteColorKey } from '@/config/featureMeta';
export {
FEATURES,
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getEntryPointType,
getFeatureByKey,
} from '@/config/featureMeta';
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadBase64Converter(): Promise<{ default: ComponentType }> {
return import('@/pages/Base64Converter');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadDashboard(): Promise<{ default: ComponentType }> {
return import('@/pages/Dashboard');
}
+33
View File
@@ -0,0 +1,33 @@
import type { ComponentType } from 'react';
import type { PageType } from '@/types/storage';
type PageModule = { default: ComponentType };
export function loadPage(key: PageType): Promise<PageModule> {
switch (key) {
case 'dashboard':
return import('@/config/pageLoaders/dashboard').then((m) => m.default());
case 'timestamp':
return import('@/config/pageLoaders/timestamp').then((m) => m.default());
case 'storageCleaner':
return import('@/config/pageLoaders/storageCleaner').then((m) => m.default());
case 'qrCode':
return import('@/config/pageLoaders/qrCode').then((m) => m.default());
case 'textStatistics':
return import('@/config/pageLoaders/textStatistics').then((m) => m.default());
case 'jwt':
return import('@/config/pageLoaders/jwt').then((m) => m.default());
case 'jsonTools':
return import('@/config/pageLoaders/jsonTools').then((m) => m.default());
case 'base64Converter':
return import('@/config/pageLoaders/base64Converter').then((m) => m.default());
case 'rightClickRestorer':
return import('@/config/pageLoaders/rightClickRestorer').then((m) => m.default());
case 'testDataGenerator':
return import('@/config/pageLoaders/testDataGenerator').then((m) => m.default());
default: {
const _exhaustive: never = key;
return Promise.reject(new Error(`Unknown page: ${String(_exhaustive)}`));
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadJsonTools(): Promise<{ default: ComponentType }> {
return import('@/pages/JsonTools');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadJwt(): Promise<{ default: ComponentType }> {
return import('@/pages/Jwt');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadQrCode(): Promise<{ default: ComponentType }> {
return import('@/pages/QrCode');
}
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadRightClickRestorer(): Promise<{ default: ComponentType }> {
return import('@/pages/RightClickRestorer');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadStorageCleaner(): Promise<{ default: ComponentType }> {
return import('@/pages/StorageCleaner');
}
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadTestDataGenerator(): Promise<{ default: ComponentType }> {
return import('@/pages/TestDataGenerator');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadTextStatistics(): Promise<{ default: ComponentType }> {
return import('@/pages/TextStatistics');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadTimestamp(): Promise<{ default: ComponentType }> {
return import('@/pages/Timestamp');
}
+5 -8
View File
@@ -22,14 +22,10 @@ function manualChunksForHtmlOnly(): Plugin {
) { ) {
return 'vendor-react'; return 'vendor-react';
} }
// 二维码活态感知依赖分流
if (id.includes('qr-scanner') || id.includes('qrious')) { // qrious / qr-scanner / @dnd-kit 不单独拆 vendor chunk
return 'vendor-qr'; // 独立 vendor 会与 Vite preload 辅助函数共 chunk,被 App Shell 静态拉取。
} // 这些依赖随各自页面 async chunk 加载即可。
// 拖拽排序高级组件分流
if (id.includes('@dnd-kit')) {
return 'vendor-dnd';
}
return undefined; return undefined;
}, },
@@ -77,6 +73,7 @@ export default defineConfig({
vite: () => ({ vite: () => ({
plugins: [manualChunksForHtmlOnly()], plugins: [manualChunksForHtmlOnly()],
build: { build: {
modulePreload: false,
minify: 'terser', minify: 'terser',
terserOptions: { terserOptions: {
format: { ascii_only: true, comments: false }, format: { ascii_only: true, comments: false },