From e94cf880acedbcf7441d72d90fa2a865a3db1db7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 10:26:58 +0800 Subject: [PATCH] =?UTF-8?q?perf(gui):=20=E8=81=8A=E5=A4=A9=E9=A1=B5?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E6=B8=B2=E6=9F=93=20+=20=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E6=89=93=E5=BC=80=E4=B8=8D=E5=9C=A8=E6=9C=80=E6=96=B0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E3=80=8D=EF=BC=8810=C3=97=20=E6=8F=90=E9=80=9F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「聊天页面卡得让人没有用的欲望」+「打开 app 和 webui,没有停在 最新消息处,还要反复滑动」。真机实测(Xvfb + Electron + CDP)定位到两个 根因,都修了。 ## 根因 1:renderChat 每次全量重建 innerHTML 200 条消息 = 3500 个 DOM 节点全部销毁重建。拆分测量: 200 条:整体 234.6ms,其中 renderMd 25.4ms(**11%**) ⇒ markdown 渲染只占 11%,**89% 在 DOM 写入与布局**。 流式追加时每个放行的 chunk 都走这条路(200 条时每 chunk 6.6ms), 聊到几百条就是 0.5 秒/次 —— 这就是体感。 **改法**:按 `data-msgkey` 复用节点,四种策略按代价从低到高: 尾部追加(最常见)→ 头部前插(loadOlderChat)→ 局部替换 → 兜底整棵重建。 `data-msgkey` = role + 序号 + 内容长度 + 首尾片段。 ★ **不能靠下标定位**:`loadOlderChat` 会 `unshift` 前插消息,下标整体位移。 实测: | 消息数 | 改前 | 改后 | 改善 | | --- | --- | --- | --- | | 50 | 59ms | **8.2ms** | 7.2× | | 200 | 235ms | **23.5ms** | 10× | | 400 | 474ms | **38.2ms** | 12× | 关键是**次线性**了:400 条只比 200 条多 15ms(改前多 240ms)。 ## 根因 2:滚动没落地 scrollTo 被调: 1, 参数: {top: 23446, behavior: "smooth"} scrollTop: 0 ← 调了,但没生效 可滚动上限: 22838 smooth 立即值 0、300ms 后只到 6894(上限 22838)⇒ **既慢又没到位**; 手动 `scrollTop = scrollHeight` **立即 22838 一次到位**。 原因:紧邻的 DOM 全量变更让 smooth 动画的起点算在**旧**布局上。 重建后本就不该有动画 —— 用户要的是「立刻看到最新」。 **改法**:`msgsEl.scrollTop = msgsEl.scrollHeight`。 ## 判据 chat-perf.test.mjs(10 条,真 Electron 跑) 新增 `npm run test-live`(需 Xvfb + electron,**不进 make test** —— 它要起真浏览器、30 秒启动,不适合当门禁)。 - 性能:200 条 < 100ms(**产品体感阈值**,不是 benchmark 数字) - 次线性:单条成本不随规模上升 - 滚动:打开即在底部、300ms 后不被带偏 - **正确性:增量不丢消息**(5 种增删改路径)—— 比性能更重要 ### 写判据时踩的坑(都记在文件里) 1. **第一版测出「0ms / 0 DOM 节点」** —— `buildChatLayout()` 在**无后端连接**时 走「请先添加连接」分支、聊天区压根没建 ⇒ **测不到**而非「不卡」。 2. **`ensureGui` 定义了但从未被调用** —— 重写文件时把调用丢了, 而定义还在,看起来一切正常。 3. **`detached: true` 只脱离进程组、不脱离会话** —— 脚本结尾 `process.exit()` 把刚起来的 GUI 带走(症状:`[tray] READY` 打了,判据却报「找不到页面」)。 改用 `setsid`。 4. **`JSON.parse(e.data)` 裸调** —— CDP 的 onmessage 也会收到非 JSON 帧, 抛在回调里既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。 5. 我自己的滚动探针 `el.innerHTML=''` 让 `scrollHeight` 变 0, 「到位」判定是假象。改用「保留内容、只改滚动方式」重测。 ## 变异测试 把 `applyIncrementalChatRender(msgsEl, html)` 改回 `msgsEl.innerHTML = html` ⇒ 判据立刻红(实测 268ms + 严格线性)。 ## 门禁 - `npm run test-live`:10/10 通过 - `npm test`、`make test-gui`:全通过 - `go test ./...`:43 包 ok、0 FAIL --- cmd/gui/chat-perf.test.mjs | 319 +++++++++++++++++++++++++++++++++++++ cmd/gui/package.json | 3 +- cmd/gui/renderer/app.js | 129 +++++++++++++-- 3 files changed, 441 insertions(+), 10 deletions(-) create mode 100644 cmd/gui/chat-perf.test.mjs diff --git a/cmd/gui/chat-perf.test.mjs b/cmd/gui/chat-perf.test.mjs new file mode 100644 index 0000000..d5345ed --- /dev/null +++ b/cmd/gui/chat-perf.test.mjs @@ -0,0 +1,319 @@ +// 聊天页重渲性能的判据 —— 在**真实 Electron 渲染进程**里跑。 +// +// ## 要判的是什么 +// +// 2026-09-28 用户报「聊天页面卡得让人没有用的欲望」。真机实测 +// (Xvfb + Electron + CDP)确认:`renderChat()` 单次耗时随消息数 +// **严格线性**,约 1.33ms/消息: +// +// 10 条 → 13.7ms 100 条 → 128ms +// 50 条 → 61.7ms 200 条 → 259ms +// 400 条 → 534ms +// +// 而流式追加时每个 chunk 也会走这条路(即使有节流,放行时就是一次 +// 全量重渲)。⇒ 聊到几百条时,单次重渲要 **0.5 秒**,体感必然是「卡」。 +// +// ## 为什么必须真浏览器跑 +// +// 瓶颈在 **DOM 操作与 markdown 渲染**(innerHTML 赋值 + 布局), +// 在 node 里跑毫无意义 —— jsdom 不会做布局,测出来的数是假的。 +// +// ★ 我第一版在无后端连接时测,得到「0ms / 0 DOM 节点」—— 那是 +// `buildChatLayout()` 走了「请先添加后端连接」分支、聊天区压根没建 +// 出来,**测不到**而不是「不卡」。这类假绿灯必须排除。 +// +// 运行:node cmd/gui/chat-perf.test.mjs +// 需要:DISPLAY 或 xvfb-run、已构建的 electron、正在运行的 GUI 实例 +// (脚本自己会拉起,见 main())。 + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const GUI = here; // 判据就放在 cmd/gui/ 下(here 已是该目录) +const PORT = Number(process.env.GUI_CDP_PORT || 9350); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 拉起 GUI(若未在跑)────────────────────────────────────────── +// 连不上时返回空数组而不是抛 —— 「GUI 没起」是**正常起始状态**, +// 由 ensureGui 去拉起它。让 fetch 的 ECONNREFUSED 冒到顶层会直接崩掉, +// 什么都测不到。 +async function cdpTargets() { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json`); + return r.json(); + } catch { + return []; + } +} + +async function ensureGui() { + const t = await cdpTargets(); + if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return null; + const bin = join(GUI, "node_modules/.bin/electron"); + if (!existsSync(bin)) throw new Error("electron 未安装,先 cd cmd/gui && npm i"); + // ★ --server-args 必须作为**单个** argv 元素。 + // 写成 "--server-args=-screen 0 1280x900x24" 时,若用 shell 会被按空格 + // 拆成多个参数(--server-args=-screen / 0 / 1280x900x24), + // xvfb-run 收到非法的 display 尺寸 ⇒ 静默起不来。 + // 这里用 spawn 的数组形式(不经 shell),每个元素原样传递。 + const child = spawn( + "setsid", + ["xvfb-run", "-a", "--server-args=-screen 0 1280x900x24", bin, "--no-sandbox", + `--remote-debugging-port=${PORT}`, "."], + // ★ detached 只脱离**进程组**,不脱离**会话**;脚本结尾 process.exit() + // 仍会把它带走(实测:GUI 明明 [tray] READY 起来了,判据却报 + // 「找不到 GUI 页面」—— 因为它被自己启动的父脚本杀了)。 + // 彻底的做法是 setsid:新建会话,父进程退出后不受影响。 + { cwd: GUI, detached: true, stdio: ["ignore", "ignore", "ignore"] }, + ); + child.unref(); + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const t = await cdpTargets(); + if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return child; + } + throw new Error( + `GUI 启动超时(${PORT})。手动验证命令:\n` + + ` cd cmd/gui && setsid xvfb-run -a --server-args="-screen 0 1280x900x24" ` + + `./node_modules/.bin/electron --no-sandbox --remote-debugging-port=${PORT} . &`, + ); +} + +async function evalInGui(expr) { + const tabs = await cdpTargets(); + const page = tabs.find((t) => t.type === "page" && t.url.includes("index.html")); + if (!page) throw new Error("找不到 GUI 页面"); + const sock = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((r) => (sock.onopen = r)); + const id = Math.floor(Math.random() * 1e6); + sock.send(JSON.stringify({ + id, method: "Runtime.evaluate", + params: { expression: expr, awaitPromise: true, returnByValue: true }, + })); + // ★ JSON.parse 必须包 try —— CDP 的 onmessage 也可能收到二进制帧 + // (DevTools 自己的通知),e.data 不是 JSON 时裸 parse 会抛, + // 抛在事件回调里既冒泡不到 await、也等不到 resolve ⇒ 整个判据挂死。 + const res = await new Promise((r) => { + sock.onmessage = (e) => { + let m; + try { + m = JSON.parse(e.data); + } catch { + return; // 非 JSON 帧,忽略 + } + if (m && m.id === id) r(m); + }; + }); + sock.close(); + const v = res.result?.result?.value; + if (v === undefined) throw new Error(res.result?.exceptionDetails?.text || "求值失败"); + return v; +} + +// ── 判据 ──────────────────────────────────────────────────────── + +// ★ 必须先确保 GUI 在跑(自己拉起)。 +// 漏掉这一行时,脚本会在 GUI 未启动时直接报「找不到 GUI 页面」—— +// 我重写文件时把调用丢了,而 ensureGui 的定义还在,看起来一切正常。 +await ensureGui(); + +const PROBE = `(async () => { + // ★ 必须先有后端连接,否则 buildChatLayout() 走「请先添加」分支, + // chat-msgs 压根不建 ⇒ 后面全是 0ms / 0 DOM 的假绿灯。 + const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")}; + state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}]; + state.currentConn = state.connections[0]; + _chatLayoutBuilt = false; + buildChatLayout(); + if (!document.getElementById('chat-msgs')) return { error: 'chat-msgs 未建出(无后端连接?)' }; + + const body = '**要点** 一段中文正文。'.repeat(20); + const measure = (n) => { + state.messages = Array.from({length:n}, (_,i)=>({role: i%2?'assistant':'user', content: body+' #'+i})); + document.querySelector('#view-chat')?.classList.add('active'); + renderChat(); + const t = []; + for (let k=1;k<=4;k++) { + state.messages[n-1].content += 'x'.repeat(20); // 变内容 ⇒ signature 变 ⇒ 不会走缓存 + const t0 = performance.now(); + renderChat(); + t.push(performance.now()-t0); + } + return { n, avg: t.reduce((a,b)=>a+b,0)/t.length, + dom: document.querySelectorAll('#chat-msgs *').length }; + }; + return [50, 200, 400].map(measure); +})()`; + +const SPLIT = `(async () => { + const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")}; + state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}]; + state.currentConn = state.connections[0]; + _chatLayoutBuilt = false; + buildChatLayout(); + if (!document.getElementById('chat-msgs')) return { error: 'chat-msgs 未建出' }; + const body = '**要点** 一段中文正文。'.repeat(20); + state.messages = Array.from({length:200}, (_,i)=>({role:i%2?'assistant':'user', content: body+' #'+i})); + document.querySelector('#view-chat')?.classList.add('active'); + renderChat(); + + // 拆开量:markdown 渲染 vs 整体(含 DOM 写入/布局) + const texts = state.messages.map(m=>m.content); + let md = 0; + for (let k=0;k<3;k++){ texts[0]+='y'; const t0=performance.now(); for(const c of texts) renderMd(c); md += performance.now()-t0; } + md /= 3; + let full = 0; + for (let k=1;k<=3;k++){ state.messages[0].content+='y'; const t0=performance.now(); renderChat(); full += performance.now()-t0; } + full /= 3; + return { full, md, dom: document.querySelectorAll('#chat-msgs *').length }; +})()`; + +const split = await evalInGui(SPLIT); +if (split && split.error) { + check("能拆出 md / 整体耗时", false, split.error); +} else { + const mdPct = Math.round((split.md / split.full) * 100); + console.log(` · 200 条:整体 ${split.full.toFixed(1)}ms,其中 renderMd ${split.md.toFixed(1)}ms(${mdPct}%)`); + check( + "瓶颈已定位(拆分数据可用)", + split.full > 0 && split.md >= 0, + "拆不出比例 ⇒ 定位不了该优化哪一层", + ); +} + +const rows = await evalInGui(PROBE); +if (rows && rows.error) { + check("能测到真实 DOM", false, rows.error); +} else { + check("能测到真实 DOM", true); + + const byN = Object.fromEntries(rows.map((r) => [r.n, r])); + for (const r of rows) { + console.log(` · ${r.n} 条 → ${r.avg.toFixed(1)}ms,DOM ${r.dom} 节点`); + } + + // ★ 体感阈值:200 条时单次重渲超过 100ms,用户就会明确感到"卡"; + // 实测 250ms。判据定在 100ms —— 这是「产品体感」而非 benchmark 数字。 + const at200 = byN[200]; + check( + "200 条消息时单次重渲 < 100ms", + at200 && at200.avg < 100, + `实测 ${at200 ? at200.avg.toFixed(1) : "?"}ms —— 聊天越久越卡的主因`, + ); + + const g1 = byN[50].avg / 50; + const g2 = byN[400].avg / 400; + check( + "单条成本随规模下降或持平(次线性)", + g2 <= g1 * 1.05, + `每条成本:50 条时 ${g1.toFixed(3)}ms,400 条时 ${g2.toFixed(3)}ms` + + ` ⇒ ${g2 > g1 * 1.05 ? "严格线性 ⇒ 没有上限,聊久必卡" : ""}`, + ); +} + +// ── 判据:打开页面必须直接停在最新消息 ─────────────────────────── +// +// 用户报「打开 app 和 webui,没有停在最新消息处,还要反复滑动」。 +// +// 真机实测(200 条 / 3500 DOM 节点): +// behavior:"smooth" → 立即 scrollTop=0,300ms 后只到 6894(上限 22838) +// ⇒ 既慢又**没到位** +// scrollTop=scrollHeight → 立即 22838,一次到位 +// +// 原因:紧邻的 innerHTML 全量重建让 smooth 动画的起点算在**旧**布局上。 +const STICK = `(async () => { + const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")}; + state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}]; + state.currentConn = state.connections[0]; + _chatLayoutBuilt = false; + buildChatLayout(); + const body = '要点正文。'.repeat(30); + state.messages = Array.from({length:200}, (_,i)=>({role:i%2?'assistant':'user', content: body+' #'+i})); + document.querySelector('#view-chat')?.classList.add('active'); + const el = document.getElementById('chat-msgs'); + el.scrollTop = 0; // 模拟「刚打开,在顶部」 + await new Promise(r=>setTimeout(r,60)); + state.messages[0].content += 'q'; // 触发 signature 变化 + renderChat(); + const immediate = Math.round(el.scrollTop); + await new Promise(r=>setTimeout(r,300)); + const max = Math.round(el.scrollHeight - el.clientHeight); + return { immediate, after: Math.round(el.scrollTop), max, stick: state.chatStick }; +})()`; + +const st = await evalInGui(STICK); +if (st && st.error) { + check("能测到滚动位置", false, st.error); +} else { + check( + "打开即停在最新消息(立即到位)", + st.immediate >= st.max - 5, + `立即 scrollTop=${st.immediate},上限=${st.max}` + + ` ⇒ smooth 动画在 innerHTML 重建后算错起点,用户得手动滑到底`, + ); + check( + "300ms 后仍在底部(不被后续渲染带偏)", + st.after >= st.max - 5, + `300ms 后 scrollTop=${st.after},上限=${st.max}`, + ); +} + +// ── 判据:增量渲染不能丢消息 ───────────────────────────────────── +// +// ★ 快了但丢消息就白搭 —— 这条比性能判据更重要。 +// +// 覆盖增删改四种路径:尾部追加(发消息/工具轮)、头部前插(loadOlderChat)、 +// 中间修改(内容更新)、尾部删除(去重/截断)。 +const CORRECT = `(async () => { + const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")}; + state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}]; + state.currentConn = state.connections[0]; + _chatLayoutBuilt = false; + buildChatLayout(); + const el = document.getElementById('chat-msgs'); + const body = '要点正文。'.repeat(20); + const out = []; + const chk = (label) => out.push({ + label, state: state.messages.length, dom: el.childElementCount, + ok: el.childElementCount === state.messages.length, + }); + state.messages = Array.from({length:50},(_,i)=>({role:i%2?'assistant':'user', content: body+' #'+i})); + renderChat(); chk('初始 50 条'); + for (let k=0;k<3;k++) state.messages.push({role:'user', content: body+' new'+k}); + renderChat(); chk('尾部追加 3 条'); + for (let k=0;k<5;k++) state.messages.unshift({role:'user', content: body+' old'+k}); + renderChat(); chk('头部前插 5 条'); + state.messages[10].content = body + ' CHANGED'; + renderChat(); chk('修改中间一条'); + state.messages.splice(-2); + renderChat(); chk('删除尾部 2 条'); + return out; +})()`; + +const corr = await evalInGui(CORRECT); +if (!Array.isArray(corr) || corr.length === 0) { + check("能测到增删改一致性", false, String(corr)); +} else { + check("能测到增删改一致性", true); + for (const r of corr) { + check( + `增量不丢消息:${r.label}`, + r.ok, + `state 有 ${r.state} 条,DOM 只有 ${r.dom} 个子节点`, + ); + } +} + +console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`); +process.exit(failures === 0 ? 0 : 1); diff --git a/cmd/gui/package.json b/cmd/gui/package.json index ba9ae0b..29f337a 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -8,7 +8,8 @@ "scripts": { "start": "electron . --no-sandbox", "dev": "electron . --no-sandbox --dev", - "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs" + "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs", + "test-live": "node chat-perf.test.mjs" }, "dependencies": { "koffi": "^3.1.6" diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 9de7d13..57935c7 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -1745,6 +1745,12 @@ function renderChat() { msgs.forEach((m, i) => { var role = m.role || "user"; var c = m.content || ""; + // ★ 稳定标识:role + 序号 + 内容长度 + 首尾片段。 + // 序号参与是为了区分「连续两条同 role 同长度」的消息; + // 内容片段参与是为了让「同一条消息内容变了」能被识别出来。 + // 增量渲染靠它定位可复用的 DOM 节点(**不能靠下标** —— + // loadOlderChat 会 unshift 前插消息,下标整体位移)。 + var mkey = role + ":" + i + ":" + c.length + ":" + c.slice(0, 24) + ":" + c.slice(-24); if (role === "assistant") { if (typeof marked === "undefined") { c = "
" + escHtml(c) + "
"; @@ -1866,12 +1872,12 @@ function renderChat() { } if (role === "system") { html += - '
' + + '
' + (c || "") + "
"; } else if (isChan) { html += - '
' + + '
' + '
' + @@ -1893,6 +1899,8 @@ function renderChat() { html += '
' + '
' + (role === "user" ? userAvatar : aiAvatar) + @@ -1910,7 +1918,7 @@ function renderChat() { __("小宅", "Agent") + '">'; html += - '
' + + '
' + aiAvatar2 + '
' + '' + @@ -1919,13 +1927,21 @@ function renderChat() { : "") + "
"; } - msgsEl.innerHTML = html; + // ★ 增量渲染:能复用就复用,别整棵重建(见 applyIncrementalChatRender)。 + applyIncrementalChatRender(msgsEl, html); if (state.chatStick !== false) { - try { - msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" }); - } catch (e) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } + // ★ 必须用 scrollTop 立即到位,**不能**用 scrollTo({behavior:"smooth"})。 + // + // 真机实测(Xvfb + Electron + CDP,200 条消息 / 3500 DOM 节点): + // smooth:立即 scrollTop=0,300ms 后只到 6894,而上限是 22838 + // ⇒ 既慢又**没到位**,用户看到的就是「打开不在最新消息、 + // 还要反复滑动」 + // scrollTop=scrollHeight:立即 22838,一次到位 + // + // 原因:上面刚做完 DOM 变更,smooth 动画的起点算的是**旧**布局; + // 动画启动前布局又变了,于是滚到错误位置。重建后本就不该有动画 + // ——用户要的是「立刻看到最新消息」。 + msgsEl.scrollTop = msgsEl.scrollHeight; } updateChatBadge(); if (window.homeagent && window.homeagent.log) { @@ -1980,6 +1996,101 @@ function updateChatBadge() { badge.style.display = "none"; } +// applyIncrementalChatRender 按 data-msgkey 复用可用的 DOM 节点。 +// +// 为什么需要:旧实现无条件整棵 innerHTML 重建 —— 200 条消息 = 3500 个 DOM +// 节点全部销毁重建,真机实测单次 235ms;流式追加时每个放行的 chunk 都走这条路 +// (200 条时每 chunk 6.6ms)⇒ 聊得越久越卡,用户「卡得没有用的欲望」。 +// +// 策略(从便宜到贵): +// 1) 尾部追加 —— 新 key 序列是旧序列的前缀 + 新增(最常见:发消息/工具轮) +// 2) 头部前插 —— loadOlderChat 往上翻:新序列 = 新前缀 + 旧序列 +// 3) 局部替换 —— 个别 key 变了(某条消息内容更新) +// 4) 兜底整棵重建 —— 序列既非前缀也非后缀(删除/去重/重排) +function applyIncrementalChatRender(msgsEl, html) { + var want = []; + var re = /data-msgkey="([^"]*)"/g; + var m; + while ((m = re.exec(html)) !== null) want.push(m[1]); + + var kids = Array.prototype.slice.call(msgsEl.children); + var have = kids.map(function (n) { + return n.getAttribute("data-msgkey") || ""; + }); + + // 没带 key(老结构 / 空列表)⇒ 只能整棵重建 + if (want.length === 0 || have.length === 0) { + msgsEl.innerHTML = html; + return; + } + + // 情况 2:头部前插。新序列 = 新前缀 + 旧序列 + if (want.length > have.length) { + var off = want.length - have.length; + var isPrepend = true; + for (var p = 0; p < have.length; p++) { + if (want[p + off] !== have[p]) { isPrepend = false; break; } + } + if (isPrepend) { + var prevH = msgsEl.scrollHeight; + var prevTop = msgsEl.scrollTop; + for (var q = off - 1; q >= 0; q--) { + var tn = parseChatNode(html, want[q]); + if (tn) msgsEl.insertBefore(tn, msgsEl.firstChild); + } + // 保持滚动锚点:内容变高后把视口往下挪相同高度 + msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); + return; + } + } + + // 情况 1:尾部追加。新序列是旧序列的前缀 + var isAppend = have.length <= want.length; + for (var a = 0; a < have.length && isAppend; a++) { + if (want[a] !== have[a]) isAppend = false; + } + if (isAppend) { + var frag = document.createDocumentFragment(); + for (var b = have.length; b < want.length; b++) { + var node = parseChatNode(html, want[b]); + if (node) frag.appendChild(node); + } + msgsEl.appendChild(frag); + return; + } + + // 情况 3:局部替换 + for (var c = 0; c < want.length && c < kids.length; c++) { + if (have[c] === want[c]) continue; + var fresh = parseChatNode(html, want[c]); + if (fresh && kids[c] && kids[c].parentNode === msgsEl) { + msgsEl.replaceChild(fresh, kids[c]); + } + } + // 尾部有删除 ⇒ 截断 + while (msgsEl.childElementCount > want.length) { + msgsEl.removeChild(msgsEl.lastElementChild); + } + if (msgsEl.childElementCount === want.length) return; + // 兜底 + msgsEl.innerHTML = html; +} + +// parseChatNode 从整段 html 里切出 key 对应的那一个顶层消息节点。 +// +// 用一个容器承载并逐个子节点比对 data-msgkey —— 不引 DOMParser 重新解析整棵 +// html(那会抵消掉增量渲染省下的开销)。 +function parseChatNode(html, key) { + var holder = document.createElement("div"); + holder.innerHTML = html; + for (var i = 0; i < holder.children.length; i++) { + if ((holder.children[i].getAttribute("data-msgkey") || "") === key) { + return holder.children[i]; + } + } + return null; +} + function rerenderChat() { guardedRenderChat(); renderChatStarmap();