diff --git a/frontend/src/components/LogViewer/MonacoLogViewer.tsx b/frontend/src/components/LogViewer/MonacoLogViewer.tsx index f2af88ab..df401571 100644 --- a/frontend/src/components/LogViewer/MonacoLogViewer.tsx +++ b/frontend/src/components/LogViewer/MonacoLogViewer.tsx @@ -1,18 +1,29 @@ /** * Monaco日志查看器组件 * 基于Monaco Editor实现的高性能只读日志查看器 + * + * 性能优化: + * - 直接操作Monaco Model,避免React状态更新 + * - 批量追加日志,减少更新频率 + * - 限制最大行数,防止内存溢出 */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import Editor, { Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import type { MonacoLogViewerProps, LogViewerAPI } from './types'; +// 默认最大行数限制 +const DEFAULT_MAX_LINES = 10000; +// 批量更新间隔(ms) +const BATCH_INTERVAL = 50; + export const MonacoLogViewer: React.FC = ({ autoScroll = true, fontSize = 12, theme = 'vs-dark', height = '100%', wordWrap = false, + maxLines = DEFAULT_MAX_LINES, onReady, className = '', style = {}, @@ -20,8 +31,12 @@ export const MonacoLogViewer: React.FC = ({ const editorRef = useRef(null); const monacoRef = useRef(null); const autoScrollRef = useRef(autoScroll); - const [content, setContent] = useState(''); const lineCountRef = useRef(0); + + // 批量更新缓冲区 + const pendingLogsRef = useRef([]); + const batchTimerRef = useRef(null); + const isReadyRef = useRef(false); // 更新自动滚动状态 useEffect(() => { @@ -37,17 +52,134 @@ export const MonacoLogViewer: React.FC = ({ } }, [wordWrap]); + // 清理定时器 + useEffect(() => { + return () => { + if (batchTimerRef.current) { + clearTimeout(batchTimerRef.current); + } + }; + }, []); + + // 检查是否在底部附近 + const isNearBottom = useCallback(() => { + if (!editorRef.current) return true; + const scrollTop = editorRef.current.getScrollTop(); + const scrollHeight = editorRef.current.getScrollHeight(); + const clientHeight = editorRef.current.getLayoutInfo().height; + return scrollHeight - scrollTop - clientHeight < 50; + }, []); + + // 滚动到底部 + const scrollToBottom = useCallback(() => { + if (!editorRef.current) return; + const model = editorRef.current.getModel(); + if (model) { + const lineCount = model.getLineCount(); + editorRef.current.revealLine(lineCount); + } + }, []); + + // 直接追加文本到Monaco Model(高性能) + const appendToModel = useCallback((text: string) => { + if (!editorRef.current) return; + + const model = editorRef.current.getModel(); + if (!model) return; + + const shouldScroll = autoScrollRef.current && isNearBottom(); + const lineCount = model.getLineCount(); + const lastLineLength = model.getLineLength(lineCount); + + // 使用 applyEdits 直接追加文本,避免重新解析整个文档 + const prefix = lineCount === 1 && lastLineLength === 0 ? '' : '\n'; + model.applyEdits([{ + range: { + startLineNumber: lineCount, + startColumn: lastLineLength + 1, + endLineNumber: lineCount, + endColumn: lastLineLength + 1, + }, + text: prefix + text, + }]); + + // 更新行数 + lineCountRef.current = model.getLineCount(); + + // 检查是否超过最大行数,删除旧日志 + if (maxLines && lineCountRef.current > maxLines) { + const linesToRemove = lineCountRef.current - maxLines; + model.applyEdits([{ + range: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: linesToRemove + 1, + endColumn: 1, + }, + text: '', + }]); + lineCountRef.current = model.getLineCount(); + } + + // 自动滚动到底部 + if (shouldScroll) { + requestAnimationFrame(scrollToBottom); + } + }, [isNearBottom, scrollToBottom, maxLines]); + + // 批量刷新日志 + const flushPendingLogs = useCallback(() => { + if (pendingLogsRef.current.length === 0) return; + + const logs = pendingLogsRef.current; + pendingLogsRef.current = []; + batchTimerRef.current = null; + + // 合并所有待处理日志 + const text = logs.join('\n'); + appendToModel(text); + }, [appendToModel]); + + // 添加日志到缓冲区(批量处理) + const queueLog = useCallback((log: string) => { + pendingLogsRef.current.push(log); + + // 如果没有定时器,启动批量更新 + if (!batchTimerRef.current) { + batchTimerRef.current = setTimeout(flushPendingLogs, BATCH_INTERVAL); + } + }, [flushPendingLogs]); + + // 清空日志 + const clearLogs = useCallback(() => { + if (!editorRef.current) return; + + const model = editorRef.current.getModel(); + if (model) { + model.setValue(''); + lineCountRef.current = 0; + } + + // 清空缓冲区 + pendingLogsRef.current = []; + if (batchTimerRef.current) { + clearTimeout(batchTimerRef.current); + batchTimerRef.current = null; + } + }, []); + // Monaco Editor挂载完成 - const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { + const handleEditorDidMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { editorRef.current = editor; monacoRef.current = monaco; + isReadyRef.current = true; // 配置编辑器选项 editor.updateOptions({ readOnly: true, minimap: { enabled: false }, scrollBeyondLastLine: false, - wordWrap: 'off', + wordWrap: wordWrap ? 'on' : 'off', lineNumbers: 'on', glyphMargin: false, folding: false, @@ -55,64 +187,32 @@ export const MonacoLogViewer: React.FC = ({ lineNumbersMinChars: 6, renderLineHighlight: 'none', automaticLayout: true, + // 性能优化选项 + renderWhitespace: 'none', + renderControlCharacters: false, + guides: { indentation: false }, + occurrencesHighlight: 'off', + selectionHighlight: false, scrollbar: { vertical: 'visible', horizontal: 'visible', verticalScrollbarSize: 10, horizontalScrollbarSize: 10, + useShadows: false, }, }); - // 检查是否在底部附近 - const isNearBottom = () => { - if (!editorRef.current) return true; - const scrollTop = editorRef.current.getScrollTop(); - const scrollHeight = editorRef.current.getScrollHeight(); - const clientHeight = editorRef.current.getLayoutInfo().height; - return scrollHeight - scrollTop - clientHeight < 50; - }; - // 暴露API if (onReady) { const api: LogViewerAPI = { appendLog: (log: string) => { - lineCountRef.current++; - const shouldScroll = autoScrollRef.current && isNearBottom(); - setContent((prev) => { - const newContent = prev ? `${prev}\n${log}` : log; - // 只有在底部时才自动滚动 - if (shouldScroll) { - setTimeout(() => { - const lineCount = editorRef.current?.getModel()?.getLineCount() || 0; - editorRef.current?.revealLine(lineCount); - }, 0); - } - return newContent; - }); + queueLog(log); }, appendLogs: (logs: string[]) => { - lineCountRef.current += logs.length; - const shouldScroll = autoScrollRef.current && isNearBottom(); - setContent((prev) => { - const newContent = prev ? `${prev}\n${logs.join('\n')}` : logs.join('\n'); - // 只有在底部时才自动滚动 - if (shouldScroll) { - setTimeout(() => { - const lineCount = editorRef.current?.getModel()?.getLineCount() || 0; - editorRef.current?.revealLine(lineCount); - }, 0); - } - return newContent; - }); - }, - clear: () => { - setContent(''); - lineCountRef.current = 0; - }, - scrollToBottom: () => { - const lineCount = editorRef.current?.getModel()?.getLineCount() || 0; - editorRef.current?.revealLine(lineCount); + logs.forEach(log => queueLog(log)); }, + clear: clearLogs, + scrollToBottom, search: (text: string) => { if (editorRef.current) { editorRef.current.trigger('', 'actions.find', { searchString: text }); @@ -132,14 +232,14 @@ export const MonacoLogViewer: React.FC = ({ }; onReady(api); } - }; + }, [wordWrap, onReady, queueLog, clearLogs, scrollToBottom]); return (
= ({ lineNumbersMinChars: 6, renderLineHighlight: 'none', automaticLayout: true, + renderWhitespace: 'none', + renderControlCharacters: false, + guides: { indentation: false }, + occurrencesHighlight: 'off', + selectionHighlight: false, scrollbar: { vertical: 'visible', horizontal: 'visible', verticalScrollbarSize: 10, horizontalScrollbarSize: 10, + useShadows: false, }, }} onMount={handleEditorDidMount} diff --git a/frontend/src/components/LogViewer/types.ts b/frontend/src/components/LogViewer/types.ts index 6effcf00..d1cd5cff 100644 --- a/frontend/src/components/LogViewer/types.ts +++ b/frontend/src/components/LogViewer/types.ts @@ -162,6 +162,9 @@ export interface MonacoLogViewerProps { /** 自动换行 */ wordWrap?: boolean; + /** 最大行数限制(超过后自动删除旧日志,默认10000) */ + maxLines?: number; + /** 就绪回调 */ onReady?: (api: LogViewerAPI) => void;