Skip to content

Commit fb33cbe

Browse files
committed
perf: P0 性能修复(内存泄漏 + 打字卡顿 + 冗余 RAF)
1) 课堂音频段 audioBase64 转写成功后剥离(useClassroomEvents/vadMarker),消除长课堂数百 MB 无界累积(内测 5GB 内存主因);2) 笔记 updateNote 改局部更新+重排,去掉每次自动保存的全量 loadNotes 与多余 getById,消除打字卡顿;3) 键入 JSON.stringify 移入防抖回调内,避免每次键入同步序列化整个文档阻塞主线程;4) 删除 TimerRing/ImmersiveTimer 冗余 RAF(进度环已由 React prop + CSS transition 驱动),消除每秒 60fps 无效 DOM 写。
1 parent eef95ba commit fb33cbe

7 files changed

Lines changed: 39 additions & 58 deletions

File tree

‎client/src/features/classroom/hooks/useClassroomEvents.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,14 @@ export function useClassroomEvents({
211211
})
212212
.then((text) => {
213213
asr.markSuccess();
214-
// 将转写结果回填到对应的音频段
214+
// 将转写结果回填到对应的音频段,并剥离 audioBase64 释放内存(单段约 1.2MB,
215+
// 长课堂数百段否则无界累积——内测 5GB 内存主因)。全量分析回退路径优先用
216+
// 已转写的 audioText(sessionAnalyzer: seg.audioText ?? transcribe),无需再持有原始音频;
217+
// 转写失败的段不走此分支,仍保留 audioBase64 供回退补转写。
215218
setSmartBundle((prev) => ({
216219
...prev,
217220
audioSegments: (prev.audioSegments ?? []).map((s) =>
218-
s.id === seg.id ? { ...s, audioText: text } : s,
221+
s.id === seg.id ? { ...s, audioText: text, audioBase64: '' } : s,
219222
),
220223
}));
221224
if (text) {

‎client/src/features/notes/hooks/useNoteEditor.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ export function useNoteEditor({ noteId, rawContent, noteKey, updateNote }: UseNo
5656
}, [noteKey]);
5757

5858
const debouncedSave = useCallback(
59-
(content: string) => {
59+
(getContent: () => string) => {
6060
if (debounceRef.current) clearTimeout(debounceRef.current);
6161
debounceRef.current = setTimeout(async () => {
6262
if (noteId) {
6363
setSaveStatus('saving');
6464
try {
65-
await updateNote(noteId, { content });
65+
// 序列化延迟到防抖回调内执行:避免每次键入都同步 JSON.stringify
66+
// 整个文档(含 base64 图片可达数 MB)阻塞主线程,仅保存时序列化一次。
67+
await updateNote(noteId, { content: getContent() });
6668
soundPlayer.play('note_autosave');
6769
setSaveStatus('saved');
6870
if (saveStatusTimerRef.current) clearTimeout(saveStatusTimerRef.current);
@@ -103,7 +105,8 @@ export function useNoteEditor({ noteId, rawContent, noteKey, updateNote }: UseNo
103105
],
104106
content: initialContent,
105107
onUpdate: ({ editor: e }) => {
106-
debouncedSave(JSON.stringify(e.getJSON()));
108+
// 传入 getter 而非预序列化字符串:getJSON/stringify 延迟到防抖回调内执行
109+
debouncedSave(() => JSON.stringify(e.getJSON()));
107110
},
108111
});
109112

‎client/src/features/notes/pages/NoteEditPage.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export default function NoteEditPage() {
8181
// 自由画布变更回调(稳定引用,避免每次渲染重建)
8282
const handleFreeCanvasChange = useCallback(
8383
(data: FreeCanvasData) => {
84-
if (noteId) debouncedSave(JSON.stringify(data));
84+
if (noteId) debouncedSave(() => JSON.stringify(data));
8585
},
8686
[noteId, debouncedSave],
8787
);
@@ -197,7 +197,7 @@ export default function NoteEditPage() {
197197
<CornellLayout
198198
content={(() => { try { return JSON.parse(note.content || '{}'); } catch { return {}; } })()}
199199
onChange={(data) => {
200-
if (noteId) debouncedSave(JSON.stringify(data));
200+
if (noteId) debouncedSave(() => JSON.stringify(data));
201201
}}
202202
/>
203203
) : (

‎client/src/features/notes/store/useNoteStore.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,19 +198,25 @@ export const useNoteStore = create<NoteState>((set, get) => {
198198
updateData.wordCount = changes.content.length;
199199
}
200200
await updateWithLog(noteStore, 'notes', id, updateData);
201-
// v0.9.0: 自动更新搜索索引
201+
// 局部更新内存数组并重排(updatedAt 变化需重排),避免全量 loadNotes:
202+
// 原实现每次自动保存都从 IndexedDB 全量重载所有笔记 + 触发全页重渲染,
203+
// 笔记上百后打字明显卡顿(P0 性能修复)。
204+
const existing = get().notes.find((n) => n.id === id);
205+
const merged = existing ? { ...existing, ...updateData } : null;
206+
set((s) => ({
207+
notes: sortNotes(s.notes.map((n) => (n.id === id ? { ...n, ...updateData } : n))),
208+
}));
209+
// v0.9.0: 自动更新搜索索引(用内存合并结果,省去额外 getById 往返)
202210
try {
203-
const note = await noteStore.getById(id);
204-
if (note) {
211+
if (merged) {
205212
const ts = updateData.updatedAt instanceof Date
206213
? updateData.updatedAt.getTime()
207214
: new Date(updateData.updatedAt as unknown as string).getTime();
208-
await dexieSearchIndexer.upsert(id, 'note', note.title, note.content, ts);
215+
await dexieSearchIndexer.upsert(id, 'note', merged.title, merged.content ?? '', ts);
209216
}
210217
} catch {
211218
// 索引更新失败不阻塞笔记更新
212219
}
213-
await get().loadNotes();
214220
},
215221

216222
deleteNote: async (id) => {

‎client/src/features/pomodoro/components/ImmersiveTimer.tsx‎

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*
88
* @ai-context: 通用组件:ImmersiveTimer。
99
*/
10-
import { useRef, useEffect, useMemo } from 'react';
10+
import { useMemo } from 'react';
1111
import { motion, AnimatePresence } from 'framer-motion';
1212
import { cn } from '@/lib/utils';
1313
import { Pause, Play, Square, Volume2, VolumeX } from 'lucide-react';
@@ -76,8 +76,6 @@ export default function ImmersiveTimer({
7676
onToggleWhiteNoise,
7777
onWhiteNoiseVolume,
7878
}: ImmersiveTimerProps) {
79-
const progressRef = useRef<SVGCircleElement>(null);
80-
const rafRef = useRef<number>(0);
8179
const prefersReduced = useReducedMotion();
8280

8381
const {
@@ -103,20 +101,9 @@ export default function ImmersiveTimer({
103101
[progressPercent],
104102
);
105103

106-
// SVG 进度环动画 (RAF)
107-
useEffect(() => {
108-
const animate = () => {
109-
const el = progressRef.current;
110-
if (!el) return;
111-
const currentProgress = totalSeconds > 0 ? remainingSeconds / totalSeconds : 1;
112-
const offset = CIRCUMFERENCE * (1 - currentProgress);
113-
el.setAttribute('stroke-dasharray', String(CIRCUMFERENCE));
114-
el.setAttribute('stroke-dashoffset', String(offset));
115-
rafRef.current = requestAnimationFrame(animate);
116-
};
117-
rafRef.current = requestAnimationFrame(animate);
118-
return () => cancelAnimationFrame(rafRef.current);
119-
}, [remainingSeconds, totalSeconds]);
104+
// 弧形光带进度由下方 JSX 的 strokeDashoffset prop + CSS transition(duration-1000)驱动:
105+
// remainingSeconds 每秒变化触发重渲染,offset 随之平滑过渡。原 RAF 循环以 60fps
106+
// 重复 setAttribute 相同值,纯属冗余,已移除。
120107

121108
// 呼吸动画参数 — 匹配 BEAT.x5 (600ms 周期 → 实际用4s完整呼吸)
122109
const breatheAnimation = prefersReduced
@@ -184,9 +171,8 @@ export default function ImmersiveTimer({
184171
strokeWidth={STROKE_WIDTH}
185172
/>
186173

187-
{/* 弧形光带进度条 */}
174+
{/* 弧形光带进度条,strokeDashoffset 变化由 CSS transition 平滑过渡 */}
188175
<circle
189-
ref={progressRef}
190176
cx={SIZE / 2}
191177
cy={SIZE / 2}
192178
r={R}

‎client/src/features/pomodoro/components/TimerRing.tsx‎

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @ai-context: 通用组件:TimerRing。
33
*/
4-
import { useRef, useEffect, useId } from 'react';
4+
import { useId } from 'react';
55
import { motion } from 'framer-motion';
66
import { cn } from '@/lib/utils';
77

@@ -57,8 +57,6 @@ export default function TimerRing({
5757
phase,
5858
isRunning,
5959
}: TimerRingProps) {
60-
const circleRef = useRef<SVGCircleElement>(null);
61-
const rafRef = useRef<number>(0);
6260
const gradientId = useId();
6361
const glowGradientId = useId();
6462

@@ -71,28 +69,10 @@ export default function TimerRing({
7169
const seconds = remainingSeconds % 60;
7270
const timeStr = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
7371

74-
useEffect(() => {
75-
const animate = () => {
76-
const el = circleRef.current;
77-
if (!el) return;
78-
79-
const size = window.innerWidth < 768 ? 200 : 280;
80-
const strokeWidth = 6;
81-
const r = (size - strokeWidth) / 2;
82-
const circumference = 2 * Math.PI * r;
83-
84-
const currentProgress = totalSeconds > 0 ? remainingSeconds / totalSeconds : 0;
85-
const offset = circumference * (1 - currentProgress);
86-
87-
el.setAttribute('stroke-dasharray', String(circumference));
88-
el.setAttribute('stroke-dashoffset', String(offset));
89-
90-
rafRef.current = requestAnimationFrame(animate);
91-
};
92-
93-
rafRef.current = requestAnimationFrame(animate);
94-
return () => cancelAnimationFrame(rafRef.current);
95-
}, [remainingSeconds, totalSeconds]);
72+
// 进度环由下方 JSX 的 strokeDashoffset prop + CSS transition(1s linear)驱动:
73+
// remainingSeconds 每秒变化触发重渲染,offset 随之平滑过渡。原 RAF 循环以 60fps
74+
// 重复 setAttribute 相同值(1h 计时约 21.6 万次无效 DOM 写)且 size 计算与 viewBox
75+
// 不一致,纯属冗余,已移除。
9676

9777
const size = 280;
9878
const strokeWidth = 6;
@@ -164,9 +144,8 @@ export default function TimerRing({
164144
className="stroke-border/20"
165145
/>
166146

167-
{/* 进度环 — 带渐变 */}
147+
{/* 进度环 — 带渐变,strokeDashoffset 变化由 CSS transition 平滑过渡 */}
168148
<circle
169-
ref={circleRef}
170149
cx={size / 2}
171150
cy={size / 2}
172151
r={r}

‎client/src/lib/capture/vadMarker.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ export class VADMarker {
269269
if (this.onSegmentReady) {
270270
this.onSegmentReady(newSegment);
271271
}
272+
// 内存释放:段已移交转写(消费方在 emit 期间同步捕获 audioBase64 构造转写参数),
273+
// 此处清空 base64(单段约 1.2MB)避免长会话无界累积。全量分析回退优先用
274+
// 已转写的 audioText(sessionAnalyzer),无需再持有原始音频。
275+
newSegment.audioBase64 = '';
272276
}
273277
}
274278

0 commit comments

Comments
 (0)