马一丁

Optimize Front-End Memory Usage

@@ -1228,22 +1228,66 @@ @@ -1228,22 +1228,66 @@
1228 let activeConsoleLayer = currentApp; 1228 let activeConsoleLayer = currentApp;
1229 const logRenderers = {}; 1229 const logRenderers = {};
1230 1230
1231 - // 轻量日志虚拟渲染器:不限制总行数,使用可视窗口渲染 + 节流 1231 + // 轻量日志虚拟渲染器:可视窗口渲染 + 节流 + 包级别截断,降低内存占用
1232 class LogVirtualList { 1232 class LogVirtualList {
1233 constructor(container) { 1233 constructor(container) {
1234 this.container = container; 1234 this.container = container;
  1235 + this.scrollElement = document.getElementById('consoleOutput') || container;
1235 this.lines = []; 1236 this.lines = [];
1236 this.pending = []; 1237 this.pending = [];
1237 this.pool = []; 1238 this.pool = [];
1238 this.lineHeight = 18; 1239 this.lineHeight = 18;
1239 this.maxVisible = 120; 1240 this.maxVisible = 120;
  1241 + this.maxLines = 2000; // 硬性保留的最大行数,超出时裁剪老旧数据
  1242 + this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪
1240 this.rafId = null; 1243 this.rafId = null;
  1244 + this.autoScrollEnabled = true;
  1245 + this.resumeDelay = 10000; // 手动滚动后重新自动滚动的延迟
  1246 + this.resumeTimer = null;
1241 this.attachScroll(); 1247 this.attachScroll();
1242 } 1248 }
1243 1249
1244 attachScroll() { 1250 attachScroll() {
1245 - if (!this.container) return;  
1246 - this.container.addEventListener('scroll', () => this.scheduleRender()); 1251 + if (!this.scrollElement) return;
  1252 + this.scrollElement.addEventListener('scroll', () => {
  1253 + this.handleUserScroll();
  1254 + this.scheduleRender();
  1255 + });
  1256 + }
  1257 +
  1258 + handleUserScroll() {
  1259 + if (!this.scrollElement) return;
  1260 + const atBottom = this.isNearBottom();
  1261 + if (atBottom) {
  1262 + this.autoScrollEnabled = true;
  1263 + this.clearResumeTimer();
  1264 + return;
  1265 + }
  1266 +
  1267 + this.autoScrollEnabled = false;
  1268 + this.clearResumeTimer();
  1269 + this.resumeTimer = setTimeout(() => {
  1270 + this.autoScrollEnabled = true;
  1271 + this.scrollToBottom();
  1272 + }, this.resumeDelay);
  1273 + }
  1274 +
  1275 + clearResumeTimer() {
  1276 + if (this.resumeTimer) {
  1277 + clearTimeout(this.resumeTimer);
  1278 + this.resumeTimer = null;
  1279 + }
  1280 + }
  1281 +
  1282 + isNearBottom() {
  1283 + if (!this.scrollElement) return true;
  1284 + const { scrollTop, clientHeight, scrollHeight } = this.scrollElement;
  1285 + return (scrollTop + clientHeight) >= (scrollHeight - this.lineHeight * 2);
  1286 + }
  1287 +
  1288 + scrollToBottom() {
  1289 + if (!this.scrollElement) return;
  1290 + this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
1247 } 1291 }
1248 1292
1249 setLineHeight(px) { 1293 setLineHeight(px) {
@@ -1255,6 +1299,7 @@ @@ -1255,6 +1299,7 @@
1255 if (this.pending.length > 200) { 1299 if (this.pending.length > 200) {
1256 this.flush(); 1300 this.flush();
1257 } 1301 }
  1302 + this.maybeTrim();
1258 this.scheduleRender(); 1303 this.scheduleRender();
1259 } 1304 }
1260 1305
@@ -1272,6 +1317,21 @@ @@ -1272,6 +1317,21 @@
1272 if (!this.pending.length) return; 1317 if (!this.pending.length) return;
1273 this.lines.push(...this.pending); 1318 this.lines.push(...this.pending);
1274 this.pending = []; 1319 this.pending = [];
  1320 + this.maybeTrim();
  1321 + }
  1322 +
  1323 + maybeTrim() {
  1324 + // 在 flush 之后调用:控制 lines 总量,减少内存
  1325 + if (this.lines.length <= this.maxLines) return;
  1326 +
  1327 + const toDrop = this.lines.length - this.trimTarget;
  1328 + if (toDrop > 0) {
  1329 + this.lines.splice(0, toDrop);
  1330 + // 调整滚动位置使得视觉保持在底部附近
  1331 + if (this.scrollElement && !this.autoScrollEnabled) {
  1332 + this.scrollElement.scrollTop = Math.max(0, this.scrollElement.scrollTop - toDrop * this.lineHeight);
  1333 + }
  1334 + }
1275 } 1335 }
1276 1336
1277 scheduleRender(force = false) { 1337 scheduleRender(force = false) {
@@ -1292,25 +1352,34 @@ @@ -1292,25 +1352,34 @@
1292 } 1352 }
1293 1353
1294 const lh = this.lineHeight; 1354 const lh = this.lineHeight;
1295 - const viewport = this.container.clientHeight || 1; 1355 + const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1;
1296 const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible); 1356 const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible);
1297 1357
1298 - const start = Math.max(0, Math.floor(this.container.scrollTop / lh) - Math.floor(visible / 2)); 1358 + const scrollTop = (this.scrollElement && this.scrollElement.scrollTop) || 0;
  1359 + const halfVisible = Math.floor(visible / 2);
  1360 + const rawStart = Math.floor(scrollTop / lh) - halfVisible;
  1361 + const start = Math.max(0, Math.min(total, rawStart));
1299 const end = Math.min(total, start + visible); 1362 const end = Math.min(total, start + visible);
1300 const beforeHeight = start * lh; 1363 const beforeHeight = start * lh;
1301 const afterHeight = (total - end) * lh; 1364 const afterHeight = (total - end) * lh;
1302 1365
1303 - const needed = end - start; 1366 + const needed = Math.max(0, end - start);
1304 while (this.pool.length < needed) { 1367 while (this.pool.length < needed) {
1305 const node = document.createElement('div'); 1368 const node = document.createElement('div');
1306 node.className = 'console-line'; 1369 node.className = 'console-line';
1307 this.pool.push(node); 1370 this.pool.push(node);
1308 } 1371 }
1309 1372
  1373 + // 截断池中过期结点,减少 DOM 引用
  1374 + if (needed && this.pool.length > needed * 2) {
  1375 + this.pool.length = needed * 2;
  1376 + }
  1377 +
1310 const fragment = document.createDocumentFragment(); 1378 const fragment = document.createDocumentFragment();
1311 for (let idx = start; idx < end; idx++) { 1379 for (let idx = start; idx < end; idx++) {
1312 const line = this.lines[idx]; 1380 const line = this.lines[idx];
1313 const node = this.pool[idx - start]; 1381 const node = this.pool[idx - start];
  1382 + if (!node) continue; // 防御性避免越界
1314 node.className = line.className || 'console-line'; 1383 node.className = line.className || 'console-line';
1315 node.textContent = line.text; 1384 node.textContent = line.text;
1316 fragment.appendChild(node); 1385 fragment.appendChild(node);
@@ -1325,9 +1394,9 @@ @@ -1325,9 +1394,9 @@
1325 this.container.appendChild(fragment); 1394 this.container.appendChild(fragment);
1326 this.container.appendChild(afterSpacer); 1395 this.container.appendChild(afterSpacer);
1327 1396
1328 - const shouldStick = (this.container.scrollTop + this.container.clientHeight) >= (this.container.scrollHeight - lh * 2); 1397 + const shouldStick = this.autoScrollEnabled || this.isNearBottom();
1329 if (shouldStick) { 1398 if (shouldStick) {
1330 - this.container.scrollTop = this.container.scrollHeight; 1399 + this.scrollToBottom();
1331 } 1400 }
1332 } 1401 }
1333 } 1402 }
@@ -2210,14 +2279,12 @@ @@ -2210,14 +2279,12 @@
2210 layer.style.display = 'none'; 2279 layer.style.display = 'none';
2211 } 2280 }
2212 2281
2213 - const placeholder = document.createElement('div');  
2214 - placeholder.className = 'console-line';  
2215 - placeholder.textContent = `[系统] ${appNames[app] || app} 日志就绪`;  
2216 - layer.appendChild(placeholder);  
2217 logRenderers[app] = new LogVirtualList(layer); 2282 logRenderers[app] = new LogVirtualList(layer);
2218 -  
2219 container.appendChild(layer); 2283 container.appendChild(layer);
2220 consoleLayers[app] = layer; 2284 consoleLayers[app] = layer;
  2285 +
  2286 + // 初始提示仅在渲染器内部渲染,不保留在 DOM
  2287 + logRenderers[app].clear(`[系统] ${appNames[app] || app} 日志就绪`);
2221 }); 2288 });
2222 2289
2223 container.scrollTop = container.scrollHeight; 2290 container.scrollTop = container.scrollHeight;