mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
用户报「聊天页面卡得让人没有用的欲望」+「打开 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
320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
// 聊天页重渲性能的判据 —— 在**真实 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);
|