From d26274d89d377067c1e2648192cffbf89464b361 Mon Sep 17 00:00:00 2001
From: wsj <593216709@qq.com>
Date: Wed, 28 Oct 2026 23:34:32 +0800
Subject: [PATCH] feat: 拆包

---
 src/components/PageLoading.tsx   |   12 ++++
 vite.config.ts                   |   74 ++++++++++++++++++++++++
 src/components/PageLoading.scss  |   14 ++++
 src/router/routeConfig.tsx       |   21 ++++--
 CLAUDE.md                        |    5 +
 .claude/skills/new-page/SKILL.md |    8 ++
 src/layouts/BasicLayout.tsx      |    8 ++
 7 files changed, 128 insertions(+), 14 deletions(-)

diff --git a/.claude/skills/new-page/SKILL.md b/.claude/skills/new-page/SKILL.md
index 0bb9b31..39ae836 100644
--- a/.claude/skills/new-page/SKILL.md
+++ b/.claude/skills/new-page/SKILL.md
@@ -99,9 +99,13 @@
 
 ### 4.2 注册路由
 
-在 `src/router/routeConfig.tsx` 中增加配置,并补充组件与图标的 import:
+在 `src/router/routeConfig.tsx` 中增加配置,并补充图标 import 与懒加载页面组件:
 
 ```tsx
+// 顶部:import { lazy } from 'react';
+const Invoices = lazy(() => import('@/pages/business/invoices'));
+
+// appRoutes 中
 {
   path: '/business/invoices',
   title: '发票管理',
@@ -110,7 +114,7 @@
 },
 ```
 
-放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。
+页面组件必须用 `lazy(() => import(...))` 注册,不要静态 import,否则会把页面打进首屏 chunk。放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。
 
 ### 4.3 更新文档
 
diff --git a/CLAUDE.md b/CLAUDE.md
index 3837553..81b014f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,6 +32,7 @@
 ```
 
 - **路由与菜单**:`routeConfig.tsx` 是唯一数据源,配置 `path`、`title`、`icon`、`element` 后侧边栏与面包屑自动生效。支持 2-3 级嵌套;分组路由(无 `element`)通过 `<Outlet />` 渲染并默认跳转到第一个子页面;未匹配路由显示 404。
+- **路由懒加载**:页面组件统一通过 `lazy(() => import('@/pages/...'))` 注册,`BasicLayout` 的内容区已提供 `<Suspense>` 边界;新增页面不要用静态 import,避免所有页面打进入口 chunk。
 - **登录态**:`src/store/auth.ts` 持久化 `token` 与 `user`;`src/api/auth.ts` 调用 `POST /auth/tenant-login`(`{ phone, password, tenantCode }`)。未登录访问受保护页面跳转 `/login`,登录成功后返回原地址。
 - **布局与样式**:页面只负责内容区。全局 CSS 变量 `--app-primary`、`--app-bg`、`--app-border`、`--app-text`、`--app-text-secondary`;通用类 `.page-header`、`.page-toolbar`、`.filter-card`、`.table-card`、`.text-muted`;Ant Design 主题在 `main.tsx` 的 `ConfigProvider` 统一配置。
 
@@ -107,7 +108,7 @@
 /new-page 根据 <接口文档路径>,添加一个【页面名】页面,放到【分组名】目录下
 ```
 
-仅改动现有页面(如调整某一列)等不涉及脚手架的场景才直接改代码。新页面必须在 `src/router/routeConfig.tsx` 用完整路径注册(`path`、`title`、`icon`、`element`,需要分组时使用 `children`),完成后执行 `npm run typecheck`。
+仅改动现有页面(如调整某一列)等不涉及脚手架的场景才直接改代码。新页面必须在 `src/router/routeConfig.tsx` 用完整路径注册(`path`、`title`、`icon`、`element`,需要分组时使用 `children`),页面组件使用 `lazy(() => import(...))` 懒加载,完成后执行 `npm run typecheck`。
 
 ## 五、接口与状态约定
 
@@ -185,4 +186,4 @@
 
 - 使用 BrowserRouter,生产环境需配置 SPA fallback:所有前端路由回退到 `index.html`;构建产物在 `dist/`。
 - 开发环境通过 Vite 代理 `/proxy-api` 转发到 `VITE_API_PROXY_TARGET`(未配置时取 `VITE_API_BASE_URL`),浏览器端不会跨域;生产环境需后端开启 CORS,或将前端与接口部署在同一域名下由反向代理转发。
-- 当前主包较大(Ant Design 全量依赖),后续可通过路由懒加载 / 手动分包优化。
+- 已启用路由懒加载与依赖拆包:`vite.config.ts` 的 `manualChunks` 将 React、antd 核心、其余依赖拆成长缓存 chunk,ProComponents 及业务页面依赖由 Rollup 按需拆分为异步 chunk。新增页面时保持懒加载即可,无需再调整拆包配置。
diff --git a/src/components/PageLoading.scss b/src/components/PageLoading.scss
new file mode 100644
index 0000000..4502c40
--- /dev/null
+++ b/src/components/PageLoading.scss
@@ -0,0 +1,14 @@
+.page-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  min-height: 320px;
+  padding: 48px 0;
+
+  &__text {
+    color: var(--app-text-secondary);
+    font-size: 13px;
+  }
+}
diff --git a/src/components/PageLoading.tsx b/src/components/PageLoading.tsx
new file mode 100644
index 0000000..feb96e9
--- /dev/null
+++ b/src/components/PageLoading.tsx
@@ -0,0 +1,12 @@
+import { Spin } from 'antd';
+import './PageLoading.scss';
+
+/** 路由懒加载时的占位内容,尽量保持内容区高度,避免页面跳动。 */
+const PageLoading = () => (
+  <div className="page-loading" role="status" aria-live="polite">
+    <Spin size="large" />
+    <span className="page-loading__text">页面加载中…</span>
+  </div>
+);
+
+export default PageLoading;
diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx
index 713fe4d..fc6b1c4 100644
--- a/src/layouts/BasicLayout.tsx
+++ b/src/layouts/BasicLayout.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { Suspense, useEffect, useMemo, useState } from 'react';
 import {
   BellOutlined,
   DownOutlined,
@@ -20,6 +20,7 @@
   Tooltip,
 } from 'antd';
 import { Outlet, useLocation, useNavigate } from 'react-router-dom';
+import PageLoading from '@/components/PageLoading';
 import { buildMenuItems, getOpenKeys } from '@/router/menu';
 import { appRoutes, findBreadcrumb } from '@/router/routeConfig';
 import { useAuthStore } from '@/store/auth';
@@ -181,7 +182,10 @@
         </Header>
 
         <Content className="app-content">
-          <Outlet />
+          {/* 路由懒加载边界放在内容区内,切页时侧边栏与头部保持不动 */}
+          <Suspense fallback={<PageLoading />}>
+            <Outlet />
+          </Suspense>
         </Content>
 
         <Footer className="app-footer">
diff --git a/src/router/routeConfig.tsx b/src/router/routeConfig.tsx
index dbb62a7..226fa47 100644
--- a/src/router/routeConfig.tsx
+++ b/src/router/routeConfig.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from 'react';
+import { lazy, type ReactNode } from 'react';
 import {
   ApartmentOutlined,
   AppstoreOutlined,
@@ -12,13 +12,18 @@
   TeamOutlined,
   UnorderedListOutlined,
 } from '@ant-design/icons';
-import Orders from '@/pages/business/orders';
-import CustomerList from '@/pages/customers/list';
-import Dashboard from '@/pages/dashboard';
-import AuditLogs from '@/pages/platform/audit-logs';
-import Departments from '@/pages/system/departments';
-import Roles from '@/pages/system/roles';
-import Users from '@/pages/system/users';
+
+/**
+ * 页面组件统一按路由懒加载:首屏只加载布局与当前页面,
+ * 其余页面在切换菜单时按需加载,避免所有页面打进同一个入口 chunk。
+ */
+const Orders = lazy(() => import('@/pages/business/orders'));
+const CustomerList = lazy(() => import('@/pages/customers/list'));
+const Dashboard = lazy(() => import('@/pages/dashboard'));
+const AuditLogs = lazy(() => import('@/pages/platform/audit-logs'));
+const Departments = lazy(() => import('@/pages/system/departments'));
+const Roles = lazy(() => import('@/pages/system/roles'));
+const Users = lazy(() => import('@/pages/system/users'));
 
 export interface AppRouteItem {
   /** 完整路径,同时作为菜单 key */
diff --git a/vite.config.ts b/vite.config.ts
index af0bd62..5c245e3 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,71 @@
 import react from '@vitejs/plugin-react';
 import { defineConfig, loadEnv } from 'vite';
 
+/**
+ * 布局、登录等入口页面会用到的 antd 组件。
+ * 这些模块首屏必载,单独放进 antd chunk 以稳定缓存;
+ * 表格、日期选择等只在业务页面使用的组件则交给 Rollup 按需分包。
+ *
+ * 注意:不要匹配 `antd/es/index.js`(`from 'antd'` 的 barrel 文件)。
+ * manualChunks 会把 chunk 当成入口保留全部导出,barrel 进手动 chunk 后
+ * 会把所有 antd 组件都拉进首屏(约 1.3MB),失去按需分包的意义。
+ */
+const ANTD_CORE_DIRS = [
+  '_util',
+  'app',
+  'avatar',
+  'breadcrumb',
+  'button',
+  'config-provider',
+  'dropdown',
+  'form',
+  'input',
+  'layout',
+  'locale',
+  'menu',
+  'message',
+  'modal',
+  'notification',
+  'space',
+  'spin',
+  'style',
+  'tag',
+  'theme',
+  'tooltip',
+  'version',
+  'wave',
+].join('|');
+
+/** 第三方依赖拆包规则,页面本身通过路由懒加载按需加载。 */
+const resolveVendorChunk = (id: string): string | undefined => {
+  if (!id.includes('node_modules')) return undefined;
+
+  const path = id.replace(/\\/g, '/');
+
+  // 图标与 antd 互相引用,放在同一个 chunk,避免循环依赖。
+  if (
+    new RegExp(
+      `\\/node_modules\\/(antd\\/es\\/(${ANTD_CORE_DIRS})\\/|@ant-design\\/(cssinjs|cssinjs-utils|colors|fast-color|icons|icons-svg)\\/)`,
+    ).test(path)
+  ) {
+    return 'antd';
+  }
+
+  if (
+    /\/node_modules\/(react|react-dom|scheduler|react-router|react-router-dom|@remix-run\/[^/]+)\//.test(
+      path,
+    )
+  ) {
+    return 'react';
+  }
+
+  if (/\/node_modules\/(axios|zustand|dayjs)\//.test(path)) {
+    return 'vendor';
+  }
+
+  return undefined;
+};
+
 /** 开发环境通过 Vite 代理访问后端,避免浏览器跨域。 */
 export default defineConfig(({ mode }) => {
   const env = loadEnv(mode, process.cwd(), '');
@@ -26,5 +91,14 @@
         },
       },
     },
+    build: {
+      // 首屏 chunk 已控制在 600KB 以内;按需加载的 ProComponents 业务包约 830KB。
+      chunkSizeWarningLimit: 900,
+      rollupOptions: {
+        output: {
+          manualChunks: resolveVendorChunk,
+        },
+      },
+    },
   };
 });

--
Gitblit v1.9.3