shadcn/ui MessageScroller 性能基准:非虚拟化滚动方案的三层度量设计与回归防护
MessageScroller 是 @shadcn/react 包中的 headless 聊天滚动容器,它默认不做虚拟化:每条消息都渲染为真实 DOM 节点,并通过 scroll、resize 和内容变化路径上的几何读取来跟踪滚动状态。这篇技术文章基于仓库内的性能基准文档与配套源码,完整解析它如何用一个可运行的 Playwright 浏览器基准(而非口头断言)证明该设计在长 Markdown 对话记录下依然成立:一次 1000 条消息的滚动提交主线程阻塞不到 1ms,600 条重型 Markdown 消息(6.4 万 DOM 节点)下依然零丢帧,流式输出时滚动器自身的每 token 开销仅约 1–2ms。读完你能掌握一套"强制布局(forced layout)基准 + 确定性调用计数回归守卫"的完整性能验证方法论,以及该组件热路径的真实调用链。
核心结论:非虚拟化设计成立,且余量充足
性能基准文档位于 PERFORMANCE.md,它给出的结论是"成立,且余量很大(yes, with wide headroom)":
- 1000 条消息下的一次滚动提交阻塞主线程 <1ms;
- 600 条重型 Markdown 条目(64,135 个 DOM 节点)下同样 <1ms,且零丢帧;
- 重型回复流式输出期间,滚动器自身的每 token 开销约 1–2ms——只是 Markdown 渲染成本之上的一小帧零头;
- 极限规模下的真正瓶颈是"未虚拟化的行数"本身,而不是滚动器的工作量。
这一结论不是推断,而是由 message-scroller.perf.browser.test.tsx 这套真实浏览器基准逐层证明的,并在 CI 中充当回归守卫。
如何运行基准
在 packages/react 目录下执行:
# from packages/react
pnpm test:browser src/message-scroller/message-scroller.perf.browser.test.tsx
对应的 npm script 定义在 package.json 中:"test:browser": "vitest run -c vitest.browser.config.ts"。其配置文件 vitest.browser.config.ts 通过 Playwright 驱动无头 Chromium(provider: "playwright"、headless: true),并以 src/**/*.browser.test.{ts,tsx} 为纳入 glob——因此该文件既可在本地运行,也会在 CI(browser-tests.yml,触发条件为 PR 触碰 packages/react/**,Node 20 + pnpm 10.33.4)中运行,一鱼两吃。
为什么必须在真实浏览器中运行: 这里的全部成本都是"强制同步重排(forced synchronous reflow)",而 jsdom 把 getBoundingClientRect 桩化为一次免费的属性读取,jsdom 下的"基准"什么都量不到。测试文件头部注释也明确写道:jsdom 无法承载 Layer 1 与 Layer 2,因为被强制布局这一"真正成本"根本不存在。
为什么必须绕开 React 的 act() 环境: 全局 setup vitest.browser.setup.ts 会把 IS_REACT_ACT_ENVIRONMENT 置为 true,而基准文件在开头将其覆盖为 false(测试文件 L49-L51)。原因是该组件由事件循环驱动——requestAnimationFrame、ResizeObserver、IntersectionObserver——这些是 act() 无法包裹的;因此基准里对被计时的渲染统一用 flushSync 同步提交,异步的滚动工作则在真实帧上落定。文档特别提醒:不要在这里重新引入 act()——它只会为 rAF 驱动的可见性订阅者重新加回"update not wrapped in act(...)"的虚假告警,却不会改变任何被测量的东西。
为什么度量"强制重排"而不是度量函数调用次数
这是整套基准的方法论核心。滚动器热路径的成本不是 getBoundingClientRect 的调用本身,而是当布局"脏(dirty)"时该调用触发的强制同步重排。基准建立在两个关于"布局何时脏、何时不脏"的事实之上:
- 滚动不会弄脏布局。 修改
scrollTop是滚动偏移变化,不是布局失效。因此稳态滚动期间getBoundingClientRect读到的是已计算好的 box,成本随"被遍历的条目数"线性增长(getContentBottom 对每个顶层行做一次 rect 读取),几乎与每行子树多深无关。这正是"重型 Markdown 对滚动数字几乎没有影响"的原因。 - 增长会弄脏布局。 流式回复变高时,下一次几何读取就必须支付一次真实重排的代价。这是唯一可能让 DOM 重量反噬的路径,所以基准为它单独设了 Layer 3,并用一个普通滚动
<div>做 A/B 隔离。
从源码看,热路径的实现就在 geometry.ts:getContentBottom 遍历 getMessageScrollerItems 返回的每一行元素,调用一次 item.getBoundingClientRect() 求内容底边(L305-L320),加上两次 getComputedStyle 读取 padding——这就是每次滚动提交 O(n) rect 读取的来源,也是回归守卫断言的对象。
三层基准设计
Layer 1 — 算法扩展性(隔离测量)
直接调用 getMessageScrollerScrollable(它驱动 O(n) 的 getContentBottom 扫描)在紧循环中压测真实已布局的 DOM,并在每次迭代切换 scrollTop(viewport.scrollTop = 1000 + (i % 2),测试文件 L173-L188),强制出真实滚动才会发生的那种重排——否则浏览器会返回缓存的 rect,基准将失去意义。回答的问题是:单次滚动提交的成本如何随行数扩展?
getMessageScrollerScrollable 的实现见 geometry.ts L12-L35:它读取 contentBottom 后,用 scrollTop 与 clientHeight 对比 scrollEdgeThreshold 得出 { start, end } 两个边缘状态——即"视口还能向上/向下滚动"的布尔信号,供自动跟随与回到底部按钮使用。
Layer 2 — 真实滚动下的负载证明(集成层)
挂载一个大规模对话记录,同时订阅滚动状态与可见性(模拟真实应用),执行一次从顶到底的滚动(120 步),并计时同步滚动处理器。调用链是 handleScroll → syncAfterScroll → commitScrollState → getContentBottom,其中 components.tsx 里 handleScroll 直接调用 syncAfterScroll(),且整条链在 dispatchEvent("scroll") 内同步执行——计时 dispatch 就是在计时用户滚动所导致的精确主线程阻塞。同时用 PerformanceObserver 捕获 longtask(>50ms)。该层既用平凡行(1000 条消息、每 8 行一个 scrollAnchor)跑,也用重型 Markdown 行(300 轮对话)跑。
Layer 3 — 流式输出(真实聊天的最坏情况)
在一段由 memoized 重型历史轮次(150 轮)构成的长对话记录底部,逐 token 流式输出助手回复——历史轮次用 React.memo 包裹,保证每个 token 只有正在增长的回复重渲染,与真实应用一致。随后在两种环境里执行完全相同的增长:
- MessageScroller 内(auto-follow 自动跟随);
- 普通滚动
<div>内(手动scrollTop = scrollHeight跟随)。
两者的**差值(delta)**就把滚动器每 token 的开销从 Markdown 自身的渲染成本中剥离出来。计时手段是对每次 flushSync 提交计时:它覆盖 render + layout effects,而滚动器的 handleContentChange 内容变化逻辑正运行在 layout effect 中(components.tsx L230-L266 注册了监听内容子节点变化的 effect 并调用 handleContentChange())。
指示性数字(indicative numbers)
测量环境:Apple Silicon、无头 Chromium(Playwright)、2026-06。这些数字是指示性的,不是保证——绝对耗时随硬件与 CI 漂移。持久的契约是下文预算断言;测试才是唯一事实来源,请在自己的机器上重跑。
Layer 1 — 单次提交成本 vs 行数(平凡行,每档 200 次采样):
| messages | median | p95 | max |
|---|---|---|---|
| 100 | 0.1ms | 0.1ms | 0.7ms |
| 500 | 0.3ms | 0.3ms | 1.2ms |
| 1000 | 0.5ms | 0.6ms | 1.9ms |
| 2000 | 1.1ms | 1.2ms | 4.6ms |
随行数线性增长、常数极小。外推而言,单次提交要逼近一个 16ms 帧需要约 3 万行渲染行数。
Layer 1 — 单次提交成本 vs DOM 重量(重型 Markdown,每档 150 次采样):
| turns | items | DOM nodes | median | p95 |
|---|---|---|---|---|
| 50 | 100 | 10,509 | 0.1ms | 0.2ms |
| 150 | 300 | 31,996 | 0.2ms | 0.3ms |
| 300 | 600 | 64,135 | 0.4ms | 0.5ms |
64k DOM 节点几乎不改变成本——成本跟踪的是条目数而非子树深度,因为滚动不弄脏布局。
Layer 2 — 真实从顶到底滚动(120 步,滚动状态 + 可见性均在线订阅):
| 场景 | handler median | handler p95 | handler max | long tasks |
|---|---|---|---|---|
| 1000 条平凡消息 | 0.9ms | 1.2ms | 2.1ms | 0 |
| 300 轮重型对话(64k 节点) | 0.6ms | 0.8ms | 0.9ms | 0 |
Layer 3 — 流式重型回复(per-token 提交,flushSync 计时):
| 树 | median | p95 | max | long tasks |
|---|---|---|---|---|
普通 <div> 基线 |
1.2ms | 2.2ms | 2.3ms | 0 |
| MessageScroller | 3.2ms | 5.7ms | 8.3ms | 0 |
| 滚动器额外开销 | 约 1–2ms |
每个 token 都会重渲染正在增长的回复;flushSync 计的是这次提交加上滚动器 handleContentChange 布局效果(重扫条目、跟随底部)的总和。相对普通 <div> 的额外开销稳定在每 token 约 1–2ms,是不到一帧的零头,且零 long task。文档还诚实披露了一段方法论修正:早期 act() 包裹版本计量的是整个异步 flush,虚假地报告了约 0/负开销;flushSync 计时才是诚实口径。并且每 token 总成本仍由 Markdown 渲染主导——那是消息组件的成本,不是滚动器的。
防止回归:确定性守卫 + 时间预算
基准套件在每个触碰 packages/react/** 的 PR 中由 browser-tests.yml 在 CI 运行。它按强度分两级守护:
1. 确定性守卫(真正的那个)
在单次滚动提交期间对 getBoundingClientRect 打 spy,断言调用次数是 O(items) 而非 O(items²)——当前约每条目 1.0 次读取(2000 条目时 2030 次)。这个计数与机器无关、零方差,因此可以设紧界:经典性能回归(嵌套的 rect 循环、误加的逐条目 getComputedStyle)会让 per-item 比值随条目数增长而确定性地冲破边界,而此时计时测试只会"耸耸肩"。断言实现见 回归守卫测试 L299-L336:spy 包装 Element.prototype.getBoundingClientRect 计数,单次 dispatchEvent 提交后断言 perItem < 4("每条目几次 rect 读取可接受;任何随条目数二次放大的东西会在 2000 条目时远超此界")。
2. 时间预算(粗粒度兜底)
墙钟断言只能抓住灾难性变慢,且依赖硬件,因此留有宽裕余量,仅标记多倍劣化(FRAME_BUDGET = 16ms,即一个 60fps 帧):
- Layer 1:最大行数下 per-commit 中位数
< 16ms(一帧); - Layer 2:scroll handler 中位数
< 16ms,p95< 32ms; - Layer 3:滚动器 per-token 中位数
< 16ms,且相对基线开销< 16ms。
文档给出了明确的维护告诫:不要把时间预算往指示性数字收紧——CI runner 噪声会让它们 flake。如果想对缓慢爬升更早告警,应优先给确定性守卫加不变量(更多计数、同一次运行内两个行数档之间的 scaling-ratio 检查),而不是缩小毫秒阈值。
边界与非目标
- 不做虚拟化。 成本是 O(渲染行数)。现实对话记录(数百到低数千轮)余量巨大;若真到了数万行"活"行,
getContentBottom的全条目扫描才可能成为问题。文档指出修复方案已提前设计好:最后一个非 spacer 子元素携带最大 bottom,扫描可收敛到 O(1)。 - Markdown 渲染成本不在范围内。 Layer 3 证明 per-token 成本属于内容渲染器而非滚动器;缓解手段(历史 memo 化、token 批处理、代码块虚拟化)属于消息组件的职责。
- 耗时是机器相关的。 不要把上表数字复制进面向用户的文档当作承诺;应引用方法学并自行重跑。
延伸阅读
- 性能基准主体:packages/react/src/message-scroller/PERFORMANCE.md
- 基准实现:packages/react/src/message-scroller/message-scroller.perf.browser.test.tsx
- 几何热路径源码:packages/react/src/message-scroller/geometry.ts(
getContentBottom、getMessageScrollerScrollable、getMessageScrollerItems) - 滚动处理器挂载点:packages/react/src/message-scroller/components.tsx(
handleScroll → syncAfterScroll) - 浏览器测试配置:packages/react/vitest.browser.config.ts;act 环境全局设置:packages/react/vitest.browser.setup.ts
- CI 工作流:.github/workflows/browser-tests.yml
- 组件使用与 API 总览:packages/react/src/message-scroller/README.md(含 jsdom 几何单测
geometry.test.ts与行为浏览器测试message-scroller.browser.test.tsx的分工表)
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00