首页
/ shadcn/ui MessageScroller 性能基准:非虚拟化滚动方案的三层度量设计与回归防护

shadcn/ui MessageScroller 性能基准:非虚拟化滚动方案的三层度量设计与回归防护

2026-09-05 15:01:37作者:贡沫苏Truman

MessageScroller 是 @shadcn/react 包中的 headless 聊天滚动容器,它默认不做虚拟化:每条消息都渲染为真实 DOM 节点,并通过 scrollresize 和内容变化路径上的几何读取来跟踪滚动状态。这篇技术文章基于仓库内的性能基准文档与配套源码,完整解析它如何用一个可运行的 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 驱动无头 Chromiumprovider: "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)。原因是该组件由事件循环驱动——requestAnimationFrameResizeObserverIntersectionObserver——这些是 act() 无法包裹的;因此基准里对被计时的渲染统一用 flushSync 同步提交,异步的滚动工作则在真实帧上落定。文档特别提醒:不要在这里重新引入 act()——它只会为 rAF 驱动的可见性订阅者重新加回"update not wrapped in act(...)"的虚假告警,却不会改变任何被测量的东西。

为什么度量"强制重排"而不是度量函数调用次数

这是整套基准的方法论核心。滚动器热路径的成本不是 getBoundingClientRect 的调用本身,而是当布局"脏(dirty)"时该调用触发的强制同步重排。基准建立在两个关于"布局何时脏、何时不脏"的事实之上:

  1. 滚动不会弄脏布局。 修改 scrollTop 是滚动偏移变化,不是布局失效。因此稳态滚动期间 getBoundingClientRect 读到的是已计算好的 box,成本随"被遍历的条目数"线性增长(getContentBottom 对每个顶层行做一次 rect 读取),几乎与每行子树多深无关。这正是"重型 Markdown 对滚动数字几乎没有影响"的原因。
  2. 增长会弄脏布局。 流式回复变高时,下一次几何读取就必须支付一次真实重排的代价。这是唯一可能让 DOM 重量反噬的路径,所以基准为它单独设了 Layer 3,并用一个普通滚动 <div> 做 A/B 隔离。

从源码看,热路径的实现就在 geometry.tsgetContentBottom 遍历 getMessageScrollerItems 返回的每一行元素,调用一次 item.getBoundingClientRect() 求内容底边(L305-L320),加上两次 getComputedStyle 读取 padding——这就是每次滚动提交 O(n) rect 读取的来源,也是回归守卫断言的对象。

三层基准设计

Layer 1 — 算法扩展性(隔离测量)

直接调用 getMessageScrollerScrollable(它驱动 O(n) 的 getContentBottom 扫描)在紧循环中压测真实已布局的 DOM,并在每次迭代切换 scrollTopviewport.scrollTop = 1000 + (i % 2)测试文件 L173-L188),强制出真实滚动才会发生的那种重排——否则浏览器会返回缓存的 rect,基准将失去意义。回答的问题是:单次滚动提交的成本如何随行数扩展?

getMessageScrollerScrollable 的实现见 geometry.ts L12-L35:它读取 contentBottom 后,用 scrollTopclientHeight 对比 scrollEdgeThreshold 得出 { start, end } 两个边缘状态——即"视口还能向上/向下滚动"的布尔信号,供自动跟随与回到底部按钮使用。

Layer 2 — 真实滚动下的负载证明(集成层)

挂载一个大规模对话记录,同时订阅滚动状态与可见性(模拟真实应用),执行一次从顶到底的滚动(120 步),并计时同步滚动处理器。调用链是 handleScroll → syncAfterScroll → commitScrollState → getContentBottom,其中 components.tsxhandleScroll 直接调用 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 批处理、代码块虚拟化)属于消息组件的职责。
  • 耗时是机器相关的。 不要把上表数字复制进面向用户的文档当作承诺;应引用方法学并自行重跑。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐