Skip to content

Commit 62c4c36

Browse files
committed
perf(app): lazy-load eight pages on first visit
1 parent 6bafa24 commit 62c4c36

2 files changed

Lines changed: 68 additions & 28 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,34 @@
1212
* 确认后 stop_live_session 再 close,取消则采集继续。
1313
* ai:task-update 全局 toast(REQ-145 第二通道)与采集状态无关,保留。
1414
*/
15-
import { useEffect, useRef, useState } from "react";
15+
import { lazy, Suspense, useEffect, useRef, useState } from "react";
1616
import { listen } from "@tauri-apps/api/event";
1717
import { invoke } from "@tauri-apps/api/core";
1818
import { confirm } from "@tauri-apps/plugin-dialog";
1919
import { getCurrentWindow } from "@tauri-apps/api/window";
20+
// 批 2 包体治理:课堂页(默认页)**保持静态 import**。
21+
// @ai-context: 它是首屏必渲染的页面 —— 改成 lazy 只是把同一批字节挪进动态 chunk,
22+
// 首屏仍要多一次 chunk 往返,且预算读数会失真(控制方 2026-09-12 裁决 2 末条认可该判断)。
2023
import ClassroomPage from "./pages/ClassroomPage";
21-
import NotesPage from "./pages/NotesPage";
22-
import SessionsPage from "./pages/SessionsPage";
24+
// 批 2 包体治理:其余 8 个页面从静态 import 改为按页动态 import。
25+
// @ai-context: 「保留挂载(TD-004)」的语义按页保留 —— **访问过的页常驻、永不卸载**;
26+
// 只是「从未访问过的页」不再进入首屏 module graph。
27+
// @ai-context: 不引入路由库(规格 §3 红线 2):入口仍是 useState<Page> + NAV_ITEMS,
28+
// 只是每个页面成为一个独立 chunk。
29+
const NotesPage = lazy(() => import("./pages/NotesPage"));
30+
const SessionsPage = lazy(() => import("./pages/SessionsPage"));
2331
// v0.20.5:行动域页(做——行动中心独立成页,意图分层)
24-
import ActionPage from "./pages/ActionPage";
32+
const ActionPage = lazy(() => import("./pages/ActionPage"));
2533
// v0.20.10(批 5,用户问题 6):复习域页(练——复习面独立顶层 Tab;spec §9 二期兑现)
26-
import ReviewPage from "./pages/ReviewPage";
34+
const ReviewPage = lazy(() => import("./pages/ReviewPage"));
2735
// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图——DSH 交互范式)
28-
import ChatPage from "./pages/ChatPage";
36+
const ChatPage = lazy(() => import("./pages/ChatPage"));
2937
// 2026-08-21 用户需求:设置页(课堂助手设置类面板迁出,单页滚动+分组)
30-
import SettingsPage from "./pages/SettingsPage";
38+
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
3139
// v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不入每日复习面)
32-
import KnowledgePage from "./pages/KnowledgePage";
40+
const KnowledgePage = lazy(() => import("./pages/KnowledgePage"));
3341
// v0.18.0:学习目标页(意图层——独立 Tab,零叙事元素)
34-
import GoalsPage from "./pages/GoalsPage";
42+
const GoalsPage = lazy(() => import("./pages/GoalsPage"));
3543
// REQ-274(v0.19.4):全局 AI 对话面板(丙案——按需唤起 + 内容保活)
3644
import AiConversationDock from "./components/AiConversationDock";
3745
import AppErrorBoundary from "./components/AppErrorBoundary";
@@ -92,10 +100,42 @@ function App() {
92100
);
93101
}
94102

103+
/**
104+
* PageSlot — 页面容器:首访挂载 + 保活 + display 门控 + 独立 Suspense。
105+
*
106+
* @ai-context: 批 2 包体治理的挂载闸门,也是「保留挂载」语义的唯一实现点。
107+
* · mounted=false ⇒ 整棵子树不渲染 ⇒ 该页的 lazy chunk **不会被请求**(首屏收益的来源);
108+
* · mounted=true 之后永不回到 false ⇒ 已访问页面常驻(TD-004 保活语义,状态与事件监听不重置);
109+
* · 每页一个独立 Suspense(fallback=null):只有**新挂载**的页会挂起,
110+
* 已经可见的页不会因为邻居加载而被替换成 fallback(避免可见的闪烁)。
111+
* @ai-context: 为什么 fallback 是 null 而不是原语层的 Loading:本批是**尺寸治理批**,
112+
* 引入原语会把它连同 CSS 一起拉进首屏,与目标冲突;「首访加载态」登记给批 3/4
113+
* (壳层与加载原语一起做),见计划 Task 11 的瓶颈清单。
114+
* @ai-context: 动态 import 失败时 React 会把它抛到最近的错误边界 —— App.tsx 的
115+
* AppErrorBoundary 仍在最外层包着 MainShell,因此「chunk 加载失败」有兜底 UI,不会白屏。
116+
* 副作用:无。边界:children 是懒组件元素,未 mounted 时不会被 React 渲染 ⇒ 不触发 dynamic import。
117+
*/
118+
function PageSlot({ show, mounted, children }: { show: boolean; mounted: boolean; children: React.ReactNode }) {
119+
if (!mounted) return null;
120+
return (
121+
<div style={{ flex: 1, display: show ? "block" : "none", overflow: "hidden" }}>
122+
<Suspense fallback={null}>{children}</Suspense>
123+
</div>
124+
);
125+
}
126+
95127
/** 主导航壳(唯一实例由 App 包入 provider——同窗单实例纪律;状态与事件监听
96128
* 保留挂载语义不变,TD-004) */
97129
function MainShell() {
98130
const [page, setPage] = useState<Page>("classroom");
131+
// 批 2:首访挂载集合。初始只有默认页 —— 其余页面在首次被选中后的下一帧挂载。
132+
// @ai-context: 用 useState + useEffect 而不是「渲染期 ref.add」:渲染期改 ref 在
133+
// StrictMode 下虽幂等,但仍是渲染副作用;本仓 React 19 StrictMode 会双调用渲染。
134+
// 代价是切页时首帧内容区为空(本来也要等 chunk 下载),无观感回归。
135+
const [mountedPages, setMountedPages] = useState<ReadonlySet<Page>>(() => new Set<Page>(["classroom"]));
136+
useEffect(() => {
137+
setMountedPages((prev) => (prev.has(page) ? prev : new Set(prev).add(page)));
138+
}, [page]);
99139
// 2026-08 A4:跨页直达目标会话(课堂助手融合完成 → 会话页自动打开详情)
100140
const [focusSessionId, setFocusSessionId] = useState<number | null>(null);
101141
// v0.7.1:跨页直达目标笔记(会话页"查看笔记" → 笔记页自动选中并滚动可见)
@@ -309,16 +349,16 @@ function MainShell() {
309349
{/* 页面区(TD-004:保留挂载 + display 切换——页面切换不重挂载,
310350
避免 ClassroomPage 每次进入重复窗口枚举 100-500ms 停顿;状态与事件监听保留) */}
311351
<main style={{ flex: 1, minHeight: 0, display: "flex" }}>
312-
<div style={{ flex: 1, display: page === "classroom" ? "block" : "none", overflow: "hidden" }}>
352+
<PageSlot show={page === "classroom"} mounted={mountedPages.has("classroom")}>
313353
{/* 2026-08 A4:融合完成直达会话(onOpenSessions 跳转 + focusSessionId 定位) */}
314354
<ClassroomPage
315355
onOpenSessions={(id) => {
316356
setFocusSessionId(id);
317357
setPage("sessions");
318358
}}
319359
/>
320-
</div>
321-
<div style={{ flex: 1, display: page === "sessions" ? "block" : "none", overflow: "hidden" }}>
360+
</PageSlot>
361+
<PageSlot show={page === "sessions"} mounted={mountedPages.has("sessions")}>
322362
{/* v0.7.1:active 驱动列表刷新(display:none 挂载不刷新根治)+ 查看笔记跨页直达 */}
323363
<SessionsPage
324364
focusSessionId={focusSessionId}
@@ -332,8 +372,8 @@ function MainShell() {
332372
active={page === "sessions"}
333373
onOpenNote={(id) => openNotePlain(id)}
334374
/>
335-
</div>
336-
<div style={{ flex: 1, display: page === "notes" ? "block" : "none", overflow: "hidden" }}>
375+
</PageSlot>
376+
<PageSlot show={page === "notes"} mounted={mountedPages.has("notes")}>
337377
{/* v0.7.1:focusNoteId 定位 + 来源会话反向跳转(与课堂助手 onOpenSessions 同模式) */}
338378
<NotesPage
339379
focusNoteId={focusNoteId}
@@ -357,22 +397,22 @@ function MainShell() {
357397
setPage("sessions");
358398
}}
359399
/>
360-
</div>
400+
</PageSlot>
361401
{/* v0.20.5:行动域页(保活挂载 + active 门控切回重载——TD-004 模式) */}
362-
<div style={{ flex: 1, display: page === "action" ? "block" : "none", overflow: "hidden" }}>
402+
<PageSlot show={page === "action"} mounted={mountedPages.has("action")}>
363403
<ActionPage active={page === "action"} />
364-
</div>
404+
</PageSlot>
365405
{/* v0.20.10(批 5):复习域页(保活挂载 + active 门控切回重载——
366406
闪卡域无事件总线,TD-004 模式同 ActionPage;focusReviewGroupId 深链
367407
组预选,消费后清空) */}
368-
<div style={{ flex: 1, display: page === "review" ? "block" : "none", overflow: "hidden" }}>
408+
<PageSlot show={page === "review"} mounted={mountedPages.has("review")}>
369409
<ReviewPage
370410
active={page === "review"}
371411
focusGroupId={focusReviewGroupId}
372412
onFocusGroupConsumed={() => setFocusReviewGroupId(null)}
373413
/>
374-
</div>
375-
<div style={{ flex: 1, display: page === "chat" ? "block" : "none", overflow: "hidden" }}>
414+
</PageSlot>
415+
<PageSlot show={page === "chat"} mounted={mountedPages.has("chat")}>
376416
{/* v0.16.0:AI 对话页——跨页跳转复用 focus 机制(任务对话引用 → 会话/笔记/设置)。
377417
2026-09-09 批 1:active 门控透传——保活挂载下切回重同步(别页发起/完成的
378418
AI 任务本页无感知;SessionsPage/ActionPage 同款 active 语义) */}
@@ -397,8 +437,8 @@ function MainShell() {
397437
setPage("sessions");
398438
}}
399439
/>
400-
</div>
401-
<div style={{ flex: 1, display: page === "knowledge" ? "block" : "none", overflow: "hidden" }}>
440+
</PageSlot>
441+
<PageSlot show={page === "knowledge"} mounted={mountedPages.has("knowledge")}>
402442
{/* v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不与每日复习面混排)
403443
v0.13.7:focusSystemId 跨页直达(组行徽标/结算简报 → 自动选中体系) */}
404444
<KnowledgePage
@@ -407,16 +447,16 @@ function MainShell() {
407447
onOpenNote={(id) => { setFocusNoteId(id); setPage("notes"); }}
408448
onOpenGroup={(id) => { setFocusGroupId(id); setPage("notes"); }}
409449
/>
410-
</div>
411-
<div style={{ flex: 1, display: page === "goals" ? "block" : "none", overflow: "hidden" }}>
450+
</PageSlot>
451+
<PageSlot show={page === "goals"} mounted={mountedPages.has("goals")}>
412452
{/* v0.18.0:学习目标页(意图层——列表是导航不是仪表盘) */}
413453
<GoalsPage />
414-
</div>
415-
<div style={{ flex: 1, display: page === "settings" ? "block" : "none", overflow: "hidden" }}>
454+
</PageSlot>
455+
<PageSlot show={page === "settings"} mounted={mountedPages.has("settings")}>
416456
{/* 2026-08-21:设置页(保留挂载——面板状态不因切页重置;TD-004 同模式);
417457
active 透传——学习库段 8s 轮询按可见性门控(v0.19.1 审查 L2) */}
418458
<SettingsPage active={page === "settings"} />
419-
</div>
459+
</PageSlot>
420460
</main>
421461
{/* REQ-274:全局 AI 对话面板(常驻挂载——开合仅切 display,选中态/后台任务保活) */}
422462
<AiConversationDock

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
| app/src-tauri/src/live_session_persist.rs | 488 | 定稿落库域(persist_final/digest_merged/handle_final_event)+ P2 flush_tail_and_persist(停止/暂停共用尾句落库)内聚 | 若再增长:flush_tail_and_persist 与 digest_merged 拆至 live_session_persist_tail.rs |
4242
| app/src-tauri/src/db_sessions.rs | 482 | 会话仓储;批 4 delete_sessions_batch + 审查修复轮 3(transaction() 改造)——2026-09-09 实测纠偏(登记值 394 过期) | 若再增长:recent_ocr_texts 等建议查询拆至 db_sessions_queries.rs |
4343
| app/src-tauri/src/commands_ai_enrich.rs | 479 | v0.8.0 M3(REQ-142)+ F1/F2/F3:知识补充命令域(九子项校验/预估/异步任务/采纳/撤销 + 配额去重门控 + 成本硬拦截 + 任务落库)——与精修共用任务注册表上下文,命令域内聚;2026-09 修复(章节目录注入/逐块审查回执)微增 | 若再增长:门控/拦截拆至 commands_ai_enrich_gate.rs |
44+
| app/src/App.tsx | 479 | 根装配(页面切换/焦点跨页直达状态机/全局事件监听)+ v0.20.5 行动页 + v0.20.10 批 5 复习页 Tab/深链/保活挂载(f702d876)——登记值 363 过期,实测纠偏 | 若再增长:焦点跨页直达 state 族拆至 useFocusRouting.ts |
4445
| app/src-tauri/src/app_commands.rs | 478 | 宏约束下的**唯一**注册点:**312 条** `generate_handler!` 条目是**数据不是逻辑**(批 1 删除批收口实测:334 → 312,−22;此数由本行人工维护,`--write` 只刷新数字列) —— `Invoke` 按值语义使分域组合不可行(见文件头),拆成多份只会在域间新增「命令名 → 域」路由漂移面;一致性由 `scripts/check-command-registry.mjs` 机器门禁守 | 不拆(数据文件);新增命令时同步条目,由门禁强制 |
4546
| app/src-tauri/src/region_tracker.rs | 478 | v0.4.0 M2(REQ-037)起:ROI 跟踪状态机(播放区域检测/锁定聚簇/重扫/前台切换冻结)+ 纯函数单测内联;与 RoiTracker 状态强耦合 | 若再增长:lock_roi/prior_roi 纯函数拆至 region_lock.rs |
4647
| app/src-tauri/src/layout_analyzer.rs | 475 | v0.5.0 M3(REQ-047):规则版版面分析(行/列投影 + 表格线检测 + 区域分类启发式)内聚于同一分类管线;审查加固(公式启发 + 低信息纯色方差滤除) | 若再增长:区域分类启发式拆至 layout_classify.rs |
@@ -54,7 +55,6 @@
5455
| app/src-tauri/src/commands_session.rs | 443 | v0.6.0 M6 + v0.7.6 审查硬拆后回归:会话命令域(CRUD/质量报告/课程分组/段搜索)内聚;笔记转换管线已拆至 commands_session_note.rs;M2 修复(search_ocr_blocks 传 data_dir 参数) | 若再增长:course/search 拆至 commands_session_extra.rs |
5556
| app/src-tauri/src/symbol_normalize.rs | 443 | v0.6.0 M1(REQ-060):口语符号映射域(映射表/上下文守卫/中文数字解析)内聚;数字解析与守卫共享字符判定 | 若再增长:parse_chinese_number/replace_number_runs 拆至 symbol_numbers.rs |
5657
| app/src-tauri/src/commands_goals_plan.rs | 442 | 规划=单次同步调用 + spawn_blocking(10-30s 交互等待可接受,对话框 loading 态);审计完整(ai_tasks op_type='goal_plan',trajectory/成本落库——轨迹可见性达成,任务化轮询留观察项)。(自动摘取,待细化) | 若再增长:按职责拆分 |
57-
| app/src/App.tsx | 439 | 根装配(页面切换/焦点跨页直达状态机/全局事件监听)+ v0.20.5 行动页 + v0.20.10 批 5 复习页 Tab/深链/保活挂载(f702d876)——登记值 363 过期,实测纠偏 | 若再增长:焦点跨页直达 state 族拆至 useFocusRouting.ts |
5858
| app/src-tauri/src/screens.rs | 437 | v0.7.3(REQ-155/156/160):画面要点屏构建编排(分组/聚类/图匹配 IO)+ 可消费块过滤扩展——编排与纯函数分层(纯函数在 screen_merge.rs) | 若再增长:filter_usable_blocks 拆至 screen_filter.rs |
5959
| app/src/pages/KnowledgePage.tsx | 437 | v0.13.8 画布接线越线(原 296):中栏「画布」标签 + 树/画布双入口 + v0.15 左列/详情列状态迁移(useColumnLayout + 折叠窄条 + 宽度 prop)——页面编排层内聚(数据获取/选中态/标签态为页面本地状态),子组件已全部下沉(Tree/Canvas/DetailPanel/Wizard/ConceptCardRow/Sample) | 若再增长:中栏视图块(树/画布/概念/模型 + 标签栏)拆至 KnowledgeMiddlePane.tsx,SystemCard 拆至 SystemSidebarCard.tsx |
6060
| app/src-tauri/src/commands_knowledge_tests.rs | 435 | v0.13.1(REQ-202~205):知识体系命令层单测域(校验纯函数 + inner 编排 + 四类 target/审计信号聚合)单模块 #[path] 挂载 | 若再增长:引用与审计组拆至 commands_knowledge_links_tests.rs |

0 commit comments

Comments
 (0)