
实不相瞒我是被豆包卡到忍无可忍才真正动手去研究这件事的。一次聊了六七十轮的长对话中间插了好几段代码和长文本我想回头翻翻前面的上下文结果滚轮每动一格页面都像在做深呼吸——先愣住然后突然跳到下一屏。打开浏览器的任务管理器豆包那个标签页的 CPU 占用一路飙着不下来。打开 DevTools 看一眼 DOM 规模整个对话区轻轻松松过万个节点浏览器每一帧滚动都要重新算布局、做命中测试、执行绘制这负荷换谁来都得卡。最后帮我把问题解决掉的是 Tampermonkey就是大家常说的“篡改猴”配一个针对性优化的用户脚本给豆包的消息节点注入 content-visibility让浏览器更合理地跳过屏幕外的渲染同时对滚动事件做节流。下面我会从问题根源讲起把脚本的结构、代码逐段拆开再分享我踩过的几个坑也算给所有用 Edge / Chrome 打开豆包网页版、被长对话滚动卡住的朋友交一份作业。1. 问题根源豆包长对话为什么越用越卡1.1 卡顿的第一根稻草DOM 膨胀先说一个很多人没意识到的点豆包网页版是典型的单页应用SPA。你和 AI 聊一百轮它不是每轮都重新加载页面而是在同一个 HTML 文档里不断往上追加新内容。聊得越久DOM 树就越臃肿这是所有对话类产品的通病。我在一次接近 100 轮的对话里实测过。打开 Elements 面板把对话区的子孙节点数量粗略数了一遍稳稳有两三万节点。每条消息还是复合结构用户那边是几行普通文本AI 这边通常用 Markdown 渲染里面再套段落、列表、代码块、表格这些。一个 80 行的代码块光高亮子节点就能上百个。这个基数摆在这里浏览器的每一项滚动开支持续被放大不卡才奇怪。1.2 滚动背后的三个性能瓶颈如果你用 Performance 面板录制一段滚动观察主线程的火焰图看到的开销主要来自三块。第一块是 reflow也就是布局计算。滚动本身会改变视口的位置浏览器需要根据新坐标判断哪些元素该排版、哪些可以先放一放。当消息容器被几千个节点撑起时这个“算出谁需要排版”的过程会被放大很多倍尤其是嵌套表格和飞宽代码块布局成本特别夸张。第二块是 paint也就是绘制。布局算完之后浏览器要把可见区域的像素画出来。消息气泡背景、代码高亮的配色、阴影和边框每一层都会产生绘制面积。DOM 足够多时单帧绘制面积可以撑满整个有几十万的像素区域主线程长时间占满帧率自然掉。第三块是 hit testing也就是命中测试。豆包页面本身有嵌套滚动结构左侧是会话列表中间才是对话区页面里还有各类浮层。滚动事件需要判断归属到哪个元素嵌套层级越深、数量越多这个判断的成本越高。这几项叠加起来就变成了你体感上的“滚动像挂了一样”。1.3 为什么 Tampermonkey 是性价比最高的解法严格来说让豆包团队从产品层面优化长对话性能才是最正路的做法但作为重度用户“等”不是一个好策略。前端团队要同时兼顾功能迭代、组件复用、浏览器兼容性性能优化的优先级通常不会排在需求前面。Tampermonkey 脚本的优势在于它完全不走业务代码链路。你只是在浏览器侧做一个“外挂式”的优化不用改豆包的源码不用搭编译环境不用等发版写完之后刷新页面立即生效。而且脚本只在你匹配的网址上运行动作再大也不影响其他网站。更何况很多人装“篡改猴”是为了给视频加加速、给页面做增强这类脚本的核心本质就是“往页面上注入 CSS 和 JS”跟我这次要写的滚动优化脚本没有什么本质区别。学会一次通用性非常强。2. 动手前的准备页面结构、浏览器工具与优化原理2.1 定位豆包的滚动容器写脚本的第一步不是写代码而是搞清楚豆包页面里到底是哪个元素在替对话区滚动。这个不像你想的那么显然很多 SPA 不会用 window 滚动它们更常用一个内部 div 做滚动容器。我的土办法是打开一个长对话页面F12 进入 Console执行一段遍历代码把所有 scrollHeight 明显大于 clientHeight 的元素筛出来。Array.from(document.querySelectorAll(div)).filter(el { const cs getComputedStyle(el); return el.scrollHeight el.clientHeight 300 cs.overflowY ! hidden cs.overflowY ! visible; });执行之后通常会得到两三个候选元素。你要做的不是随便挑一个而是点开 Elements 面板手动滚动对话区确认哪个元素的高度差和实际滚动行为最吻合。豆包这种页面一般是“消息内容包裹层”在滚而不是 body 在滚。这里有个额外提醒查找滚动容器的过程本身也在遍历整个 DOM成本不小所以这段逻辑应该在 document-idle 之后执行一次把结果缓存在一个变量里后面就不要重复跑了。2.2 消息节点的特征与选择器策略找到滚动容器之后还得识别哪些节点是“消息节点”因为我们要给这些节点加优化标记。这里不存在一个写死不变的 class 名最好的做法是用 DevTools 的 Elements 面板右键检查任意一条消息把 DOM 层次展开观察特征。实战中常见的选择器特征是[class*message]、[class*chat-item]、[class*turn]、[class*msg]这类半匹配表达。你可以在一个候选列表里多加几条尽量放宽匹配范围再用“节点存在性校验”做兜底避免豆包前端重构时脚本直接失效。我特别不建议只匹配一个精确的 class 名。前端团队改一下样式前缀你的脚本就白写了维护成本全落在自己头上。匹配规则宁可宽一点然后通过属性标记去重。2.3 核心武器content-visibility 和 contain-intrinsic-size这次优化的主药方是 content-visibility它算是近几年长列表渲染最有价值的 CSS 属性之一。这个属性告诉浏览器这个元素的视觉内容可以被推迟渲染等它真正进入或接近视口时再渲染。默认用 auto 值即可它在滚动场景下特别聪明地处理“即将进入视口”的节奏。但它有个强制要求必须配合 contain-intrinsic-size。否则浏览器会把未渲染元素的高度当成 0px 来计算整个容器的滚动高度会瞬间塌陷滚动条就会像发疯一样上下跳。这个属性相当于给每个未渲染的节点贴一个“预估厚度”标签浏览器通过它知道这个盒子竖起来大概占多高进而保持滚动条稳定。用生活里的例子类比content-visibility 相当于负责人把一百页图纸先存进资料柜视口桌面上只摆当前需要看的那一页contain-intrinsic-size 则相当于每个文件柜抽屉外贴着厚度标签负责人不需要把每个抽屉全打开也知道整排柜子大概多占空间。标签贴得不准柜子高度就会忽高忽低这就是为什么这个值最好来自真实环境的测量。Chrome 和 Edge 对这两个属性的支持已经很稳Firefox 新版也能用。豆包用户基本都在 Chromium 系浏览器里开网页这个方案完全够用。2.4 为什么不选虚拟滚动聊到长列表优化很多人第一反应是虚拟滚动。虚拟滚动确实强大它只渲染可视区和缓冲区附近的节点其他节点统统拔掉。但问题也在于“拔掉”。豆包是一个文档式对话场景用户经常需要向上翻记录、看上下文如果消息节点被回收再往上滚时又得重新创建重建过程中会有样式闪烁、布局偏移甚至滚动位置错乱这对对话场景来说太致命。content-visibility 的“跳过渲染”则完全不会改变 DOM 结构。节点仍然真实存在于文档里滚动条高度计算准确往上翻时只是重新触发渲染而不是重建节点树。改造的侵入性几乎为零这是它在豆包这类场景里最大的价值。3. 完整实操编写并挂载滚动优化脚本3.1 安装 Tampermonkey 并确定匹配规则在 Edge 或 Chrome 的扩展商店里搜索 Tampermonkey认准用户数多、更新活跃的官方版本安装后一般会自动跳到管理页。请记住中文界面里它显示为“篡改猴”后台图标也是 Tampermonkey 那个经典小方框。装好之后先别急着写脚本去豆包对话页看一眼地址栏。典型路径是 https://www.doubao.com/chat/xxx有时候直接落在 https://www.doubao.com/ 。这意味着脚本可以统一用 https://www.doubao.com/* 来匹配全部子路径。如果你是从某个特定入口进的也可以把 match 调整为更具体的前缀避免脚本在无关页面上空跑。3.2 脚本骨架元数据与权限声明在 Tampermonkey 面板点击“添加新脚本”清空模板先写元数据部分。// UserScript // name 豆包对话滚动流畅化 // namespace https://your.example/doubao-scroll // version 0.1.0 // description 缓解豆包长对话滚动卡顿content-visibility 滚动事件优化 长消息瘦身 // match https://www.doubao.com/* // run-at document-idle // grant none // /UserScript逐个字段说清楚name 是脚本列表里显示的名字match 决定哪些网址会运行run-at document-idle 表示文档解析完成后注入避开早期 DOM 还没生成的问题grant none 表示不需要 Tampermonkey 提供的扩展 API。很多人习惯在模板里直接写grant GM_addStyle但如果你只需要注入样式用原生document.head.appendChild更轻量也能减少不必要的权限声明。脚本权限越少出问题的概率越小这一点平时写脚本时也适用。3.3 核心代码逐段拆解下面是我维护的版本删减后的核心片段你可以直接粘进脚本运行但我更希望你读懂每段的作用。(function () { use strict; const SCRIPT_FLAG data-ds-opt; const css [ [${SCRIPT_FLAG}] {, content-visibility: auto;, contain-intrinsic-size: auto 180px;, }, // 长消息折叠样式可选 [${SCRIPT_FLAG}][data-ds-fold1] [class*content],, [${SCRIPT_FLAG}][data-ds-fold1] [class*markdown] {, max-height: 45vh;, overflow: hidden;, position: relative;, } ].join(\n); const styleEl document.createElement(style); styleEl.textContent css; document.head.appendChild(styleEl); })();第一段的核心只有一件事把优化样式注入页面。[data-ds-opt]是我们给消息节点打的私有属性标记配合content-visibility: auto让浏览器跳过屏幕外区域渲染。contain-intrinsic-size: auto 180px是让浏览器在未真正渲染节点时也能预估出高度180px 是我在豆包页面上量出来的平均消息高度你可以按自己的消息密度调整。接下来是打标记的逻辑。(function () { use strict; const SCRIPT_FLAG data-ds-opt; function hasTargetClass(el) { if (!el || !el.matches) return false; return el.matches( [class*message], [class*chat-item], [class*turn], [class*msg-item] ); } function mark(el) { if (el !el.hasAttribute(SCRIPT_FLAG) hasTargetClass(el)) { el.setAttribute(SCRIPT_FLAG, ); } } function markAll(root) { if (!root || !root.querySelectorAll) return; root.querySelectorAll( [class*message], [class*chat-item], [class*turn], [class*msg-item] ).forEach(mark); } markAll(document.body); })();这里为什么要用 MutationObserver 而不只是初始化时打一次标记因为豆包的 AI 回答是流式输出的消息节点可能在输出过程中被多次追加、替换、再插入如果只做一次性处理后面新增的消息就会漏掉优化。观察器的作用就是自动给新插入的消息也打上标记。(function () { use strict; const SCRIPT_FLAG data-ds-opt; const mo new MutationObserver(muts { for (const mut of muts) { mut.addedNodes.forEach(node { if (node.nodeType ! 1) return; mark(node); if (node.querySelectorAll) { node.querySelectorAll( [class*message], [class*chat-item], [class*turn], [class*msg-item] ).forEach(mark); } }); } }); mo.observe(document.body, { childList: true, subtree: true }); })();MutationObserver 只观察 childList 的插入setAttribute 改变属性不会触发递归循环安全。接下来是滚动容器和滚动事件的部分。(function () { use strict; const SCRIPT_FLAG data-ds-opt; function findScroller() { const all Array.from(document.querySelectorAll(div)); let best null; let bestScore 0; for (const el of all) { const diff el.scrollHeight - el.clientHeight; if (diff 300 || diff bestScore) continue; const cs getComputedStyle(el); if (cs.overflowY hidden || cs.overflowY visible) continue; bestScore diff; best el; } return best; } const scroller findScroller(); if (scroller) { scroller.style.willChange scroll-position; scroller.addEventListener(scroll, onScroll, { passive: true }); } let rafPending false; function onScroll() { if (rafPending) return; rafPending true; requestAnimationFrame(() { // 滚动时需要处理的逻辑放这里 rafPending false; }); } })();这里两个重点。第一willChange: scroll-position是让浏览器为这个滚动容器提前准备合成层但如果给所有元素都加 willChange等于把几千个节点都塞进合成层内存和合成压力反而会大幅上升所以只加在滚动容器这一个节点上。第二scroll 监听用passive: true明确告知浏览器这个监听器不会调用 preventDefault让它不用等待阻断逻辑滚动过程的输入响应能快一截。监听器自身用 rafPending 节流确保滚动过程中连续触发的事件最多每帧处理一次。3.4 验证优化效果主观手感和客观数据脚本写完之后验证环节非常关键。我一般分三步走。先看手感。进入一个长对话页面刷新后往下滚动如果之前的“粘滞感”明显减弱滚动能跟着手指和滚轮顺畅走那说明已经起效。手感这个东西很主观但恰恰是长对话场景最直接的诉求。再看数据。打开 Performance 面板点录制按钮在对话区反复滚动 3 到 5 秒停止录制。观察主线程火焰图如果绿色长条里代表 Layout 和 Paint 的部分比优化前明显变短说明浏览器花在每帧布局和绘制上的时间在下降。更直观的方式是调出 FPS 面板CtrlShiftP 输入 FPS看滚动时帧率是否更稳定。最后是逻辑确认。在 Console 执行document.querySelectorAll([data-ds-opt]).length如果返回的数字和对话区消息节点数量对得上说明标记逻辑正确、选择器没有失效。4. 进阶优化长消息折叠与进一步减负4.1 消息折叠的触发时机与实现content-visibility 把屏幕外区域的渲染成本降下来了但如果某几条消息本身就是“巨型消息”哪怕它就在视口附近绘制面积也巨大。这种场景我建议加一层“长消息折叠”。触发条件可以有两种一种是被渲染出来的消息 DOM 深度太大另一种是对话总轮数超过阈值之后把最老的一批消息收缩成摘要形式。我之前用的是第二个思路逻辑很简单当滚动容器内的消息节点超过 120 个时对最老的 80 个节点添加>