@shadcn/react 版本演进与源码解析:MessageScroller 锚定修复与 Questionnaire 多步问卷原始组件
【免费下载链接】uiA set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. Open Source. Open Code.项目地址: https://gitcode.com/GitHub_Trending/ui/ui
packages/react/CHANGELOG.md记录了 shadcn/ui 仓库中新孵化出的独立包@shadcn/react("Unstyled components for React")从 0.2.0 到 0.3.0 的完整版本历史:0.2.0 首发 MessageScroller 无样式聊天滚动容器,0.2.1 集中修复流式输出下的锚定与自动滚动问题,0.3.0 新增 Questionnaire 多步问卷原始组件。本文以该 CHANGELOG 为骨架,逐版本还原每次变更的内容,并结合packages/react/src下的真实源码、类型定义与测试配置,讲解这些版本背后的 API 设计、底层实现与验证方式。
包定位:@shadcn/react 是什么
在讨论版本之前,先明确这个包的边界,后续所有版本说明都以此为前提。根据 package.json:
- 包名与版本:
@shadcn/react,当前仓库中版本为0.3.0,与 CHANGELOG 中最新条目一致; - 定位:description 为 "Unstyled components for React.",即无样式、headless 的可访问组件原语(keywords 明确标注
headless、unstyled、primitives、accessible); - 运行时依赖:
peerDependencies要求react >= 19与@types/react >= 19,且两者均标记为 optional——这是版本能力的重要适用前提,使用该包的宿主应用必须基于 React 19; - 入口设计:
exports只暴露两个子路径——./message-scroller与./questionnaire,分别指向dist/message-scroller/index.js和dist/questionnaire/index.js,并附带对应的.d.ts类型声明。从导出结构看,use-render目录(packages/react/src/use-render/index.ts)是内部工具而非公开 API; - 工程事实:
"type": "module"、"sideEffects": false(利于 tree-shaking),构建使用tsup,测试使用 Vitest(vitest run为 jsdom 单元测试,vitest run -c vitest.browser.config.ts为 Chromium 浏览器测试)。
包级 README 给出的安装方式为:
npm install @shadcn/react版本时间线总览
完整继承 CHANGELOG 的全部条目(PR 编号保留,外部链接从略):
| 版本 | 变更级别 | PR | 变更摘要 |
|---|---|---|---|
| 0.3.0 | Minor | #11414 | 新增 Questionnaire 原始组件,支持多步提问、自由文本作答、校验与键盘导航 |
| 0.2.1 | Patch | #11106 | 修复 MessageScroller 在锚定轮次周围的 autoScroll:锚定保持(anchor hold)在流式回复撑满视口后移交给 follow-output;内容增长快于帧合并后的 resize 处理器时不再解除 follow-output;滚动按钮不再因 follow-output 追赶刚被流式块拉开的间隙而闪烁 |
| 0.2.1 | Patch | #11100 | 修复 MessageScroller 在开启 autoScroll 时丢弃新追加的 scrollAnchor 定位,使锚定轮次在回复流式输出期间保持在阅读行 |
| 0.2.1 | Patch | #11085 | 通过把 ResizeObserver 回调合并进 requestAnimationFrame,修复流式内容增长期间触发 "ResizeObserver loop completed with undelivered notifications" 的问题 |
| 0.2.0 | Minor | #11022 | @shadcn/react首次发布 |
可以看出这个包早期的演进主线非常清晰:0.2.0 落地 MessageScroller,0.2.1 密集修补它的流式输出场景,0.3.0 扩展出第二个组件 Questionnaire。下面按这条主线逐个版本展开。
0.2.0:MessageScroller 无样式聊天滚动容器
0.2.0 是@shadcn/react的 initial release(PR #11022),首发内容即 MessageScroller。按 MessageScroller README 的定义,它面向聊天转录(chat transcripts),是一个 headless 滚动容器:拥有(owns)滚动位置、锚定(anchoring)、自动跟随(auto-follow)与可见性追踪(visibility tracking),让消息组件本身保持纯展示(presentational)。
组合式 API 结构
对外 API 在 message-scroller/index.ts 中集中导出:一个MessageScroller命名空间对象(Provider / Root / Viewport / Content / Item / Button 六个部件),三个扁平 hook(useMessageScroller、useMessageScrollerScrollable、useMessageScrollerVisibility),以及若干类型(MessageScrollerScrollable、MessageScrollerVisibilityState、MessageScrollerScrollOptions、MessageScrollerScrollAlign、MessageScrollerDefaultScrollPosition)。
基本用法(继承自 README 示例):
import { MessageScroller, useMessageScroller, useMessageScrollerVisibility, } from "@shadcn/react/message-scroller" ;<MessageScroller.Provider autoScroll> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content> <MessageScroller.Item messageId="m1" scrollAnchor> … </MessageScroller.Item> </MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> </MessageScroller.Provider>各部件职责(README 表格)与 Provider 的关键 props 对应关系,可以直接在 components.tsx 中得到印证:MessageScrollerProvider的解构默认值正是autoScroll = false、defaultScrollPosition = "end",其余scrollEdgeThreshold、scrollPreviousItemPeek、scrollMargin透传给内部的useMessageScrollerController。
| 部件 | 职责 | 关键 props |
|---|---|---|
MessageScroller.Provider | headless 根节点;拥有滚动状态、锚定、自动跟随与可见性 | autoScroll、defaultScrollPosition、scrollPreviousItemPeek、scrollMargin、scrollEdgeThreshold |
MessageScroller.Root | 样式化外框;在 provider 内布局 viewport、content 与控件 | — |
MessageScroller.Viewport | 可滚动外框 | preserveScrollOnPrepend |
MessageScroller.Content | 消息列表;默认role="log"+aria-relevant="additions" | — |
MessageScroller.Item | 单条消息包装 | messageId、scrollAnchor |
MessageScroller.Button | 滚动到末尾/开头的入口;跟平时自动隐藏 | direction |
三个 hook 的实现(components.tsx)值得注意:useMessageScrollerScrollable与useMessageScrollerVisibility都基于React.useSyncExternalStore订阅 provider 内部的 store(stateStore/visibilityStore),返回值分别为视口可滚动边缘{ start, end }与可见性状态{ currentAnchorId, visibleMessageIds }。从源码结构看,滚动与可见性状态被刻意放在 React 渲染之外的 store 中,通过外部 store 同步机制消费,这正是为流式高频滚动场景做的隔离设计。
默认滚动位置
MessageScrollerDefaultScrollPosition类型取值为"start" | "end" | "last-anchor"(见 types 导出),配合 Provider 的defaultScrollPositionprop 决定初始落点;Provider 源码中默认值为"end"。
0.2.1:锚定与自动滚动的三项流式修复
0.2.1 是三个 Patch 变更的集合(#11106、#11100、#11085),全部围绕 MessageScroller 在"回复流式输出"这一最难场景下的行为。逐条说明:
修复一:锚定保持与 follow-output 的移交(#11106)
问题场景:当用户对某条消息设置了锚定(scrollAnchor),期望它保持在阅读位置;但随着流式回复不断变长,这条锚定轮次最终会撑满视口。此时继续"硬保持锚定"与"跟随输出"两种目标必然冲突。该修复明确了状态机语义:anchor hold 在流式回复撑满视口后移交给 follow-output(hand off);同时修复了"内容增长速度快于帧合并后的 resize 处理器"时 follow-output 被错误解除的问题,以及滚动按钮在 follow-output 追赶"刚被流式块拉开的间隙"期间反复闪烁的问题。
修复二:不丢弃新追加的 scrollAnchor(#11100)
问题场景:开启autoScroll时,若一条新消息带着scrollAnchor被追加,旧实现会放弃这个新锚定,导致"锚定轮次在回复流式输出期间保持在阅读行"的承诺失效。修复后新增的 scrollAnchor 定位会被保留,锚定轮次可以稳定停在阅读行。
修复三:ResizeObserver 回调合并进 requestAnimationFrame(#11085)
流式内容持续增长会触发频繁的 ResizeObserver 回调,浏览器在单次任务中收不完所有通知时会抛出 "ResizeObserver loop completed with undelivered notifications"。修复方式是把 ResizeObserver 回调合并(coalesce)进requestAnimationFrame,与帧节奏对齐,从机制上消除该告警。
这三项修复共同指向一个设计结论:MessageScroller 的核心难点不在"能滚动",而在流式追加、锚定保持与自动跟随三者的状态切换时机。后续版本对 MessageScroller 的维护基本都在这条轴线上。
0.3.0:Questionnaire 多步问卷原始组件
0.3.0(PR #11414)是 Minor 变更:新增 Questionnaire primitive,支持多步提问(multi-step questions)、自由文本作答(freeform answers)、校验(validation)与键盘导航(keyboard navigation)。其完整设计可从 src/questionnaire 目录中的类型、组件与集合逻辑读到。
组合式 API 与用法
对外导出(questionnaire/index.ts)为单一Questionnaire命名空间,共 16 个部件:Root、Progress、Item、Title、Description、Choices、Choice、ChoiceInput、ChoiceLabel、ChoiceShortcut、Input、Error、Previous、Skip、Next、Submit。README 给出的关键约定:Root渲染原生<form>,答案通过FormData读取,并且支持服务端渲染——把Root.items传入后,初始 HTML 会包含完整的、由集合推导出的状态。
完整用法示例(继承自 Questionnaire README,可直接作为接入参考):
const items = [ { name: "prototype", required: true, prompt: "What should we prototype next?", description: "Choose a direction or write your own.", choices: [ { value: "delegation", label: "Delegation", description: "Show how work moves to a specialist.", }, { value: "questions", label: "Question prompts" }, ], input: { label: "Another answer", placeholder: "Type another answer…" }, }, { name: "detail", required: false, prompt: "How much detail?", description: "Skip this if you are not sure yet.", choices: [ { value: "focused", label: "Focused" }, { value: "complete", label: "Complete flow" }, ], }, ] as constimport { Questionnaire } from "@shadcn/react/questionnaire" export function ProjectQuestionnaire() { return ( <Questionnaire.Root items={items} onSubmit={(event) => { event.preventDefault() const answers = new FormData(event.currentTarget) }} > <Questionnaire.Progress /> {items.map((question) => ( <Questionnaire.Item key={question.name} name={question.name} required={question.required} > <Questionnaire.Title>{question.prompt}</Questionnaire.Title> <Questionnaire.Description> {question.description} </Questionnaire.Description> <Questionnaire.Choices> {question.choices.map((choice) => ( <Questionnaire.Choice key={choice.value} value={choice.value}> <Questionnaire.ChoiceInput /> <Questionnaire.ChoiceLabel> <span>{choice.label}</span> {"description" in choice ? ( <span>{choice.description}</span> ) : null} </Questionnaire.ChoiceLabel> <Questionnaire.ChoiceShortcut /> </Questionnaire.Choice> ))} {"input" in question ? ( <Questionnaire.Input aria-label={question.input.label} placeholder={question.input.placeholder} /> ) : null} </Questionnaire.Choices> <Questionnaire.Error /> </Questionnaire.Item> ))} <Questionnaire.Previous /> <Questionnaire.Skip /> <Questionnaire.Next /> <Questionnaire.Submit /> </Questionnaire.Root> ) }类型层面的关键设计
结合 types.ts 可以读出 CHANGELOG 摘要中"校验与键盘导航"的具体含义:
- 题目状态机:
QuestionnaireItemStatus = "unanswered" | "answered" | "skipped"(types.ts),配合Item的onStatusChange回调与invalid状态驱动Error部件,这就是"validation"的落点; - 键盘快捷方式:
QuestionnaireShortcutMode = "letters" | "numbers",Root.shortcutsprop 选择模式后,ChoiceShortcut部件会展示对应按键。快捷键的分配逻辑在 collection.ts 的getShortcutByChoiceValue中:按顺序为未禁用的选项依次分配键值,分配完即止; - 初始题目定位:
Root支持受控item/ 非受控defaultItem。collection.ts 的getInitialItemName实现了回退规则:优先使用未禁用的defaultItem,否则取第一个未禁用题目,再否则为null——这与 SSR 场景下"首屏即有确定状态"的要求相吻合; - 导航按钮状态:
Previous/Skip/Next/Submit共享同一QuestionnaireNavigationState(disabled、shortcut: "Enter" | null、status、visible),意味着"Enter 推进"这一键盘导航行为在类型上被显式建模; - 自由文本输入:
QuestionnaireInputType覆盖date、datetime-local、email、month、number、password、search、tel、text、time、url、week等 13 种原生 input 类型(types.ts),支撑 CHANGELOG 所称的 freeform answers; - SSR 支持:
Root.items接收readonly QuestionnaireItemDefinition[](含name、required、disabled、choices),由 collection.ts 的createQuestionnaireCollection一次性派生enabledItems与itemByName索引,使服务端无需 DOM 交互即可算出完整的集合状态。
Item渲染为原生<fieldset>(QuestionnaireItemProps以React.ComponentPropsWithRef<"fieldset">为基底),Title为<legend>——从源码结构看,Questionnaire 延续了"原生表单元素 + headless 逻辑注入"的一贯路线,答案因此天然可被FormData采集。
测试与性能验证:版本承诺如何被守住
两个子模块各自的 README 与 PERFORMANCE.md 给出了版本变更的验证体系,这也是阅读 CHANGELOG 时值得参考的部分:
MessageScroller 的测试分层(README 测试表):
| 文件 | 环境 | 覆盖内容 |
|---|---|---|
geometry.test.ts | jsdom | 使用桩 rect 的几何计算 |
message-scroller.browser.test.tsx | chromium | jsdom 无法建模的行为:原生滚动锚定、prepend、可见性 |
message-scroller.perf.browser.test.tsx | chromium | 性能基准 + 回归防护 |
pnpm test # 单元测试(jsdom) pnpm test:browser # 行为 + 性能(chromium)PERFORMANCE.md 进一步说明:基准必须跑在真实 Chromium 中,因为 MessageScroller 热路径的成本不是getBoundingClientRect调用本身,而是它触发的强制同步重排(forced synchronous reflow),而 jsdom 把getBoundingClientRect桩成了免费属性读取,"jsdom 基准什么都测不到"。其回归防护分两级:一是确定性守卫——spy 统计单次滚动提交内的getBoundingClientRect调用次数并断言为 O(items) 而非 O(items²)(约 1.0 次/条,2000 条约 2030 次),该断言机器无关、零方差;二是带宽松余量的墙钟预算(如滚动 handler 中位数 < 16ms、p95 < 32ms),只用于捕捉量级性劣化。文档还明确边界:默认不做虚拟化,成本随渲染行数线性增长;千级消息的转录有巨大余量,万级实时行的极限优化路径(把getContentBottom的全量扫描折叠为 O(1))已预留但未启用。
Questionnaire 的测试:目录内包含questionnaire.test.tsx(jsdom 行为测试)、questionnaire.browser.test.tsx(Chromium 行为测试)与questionnaire-ssr.test.tsx,与其 README 宣称的"支持服务端渲染"一一对应。
升级与使用建议
结合 CHANGELOG 与包元数据,给使用者的几点实操结论:
- 版本选择:需要多步问卷交互用
0.3.0+(Questionnaire 在该版本才出现);仅用 MessageScroller 则0.2.1+更稳妥,因为 0.2.1 修复了流式输出下锚定丢失、follow-output 误解除与 ResizeObserver 告警三类实际体验问题; - 环境前提:宿主应用必须是 React 19+(peer 依赖
react >= 19),按子路径导入(@shadcn/react/message-scroller、@shadcn/react/questionnaire); - 样式自行负责:包定位是无样式原语,
Root、Item、Choice等部件渲染的都是原生表单/结构元素,外观由消费方通过useRender风格的 render props 或 CSS 提供; - 流式聊天场景:使用
MessageScroller.Provider autoScroll时理解"锚定保持 → follow-output 移交"的状态语义(0.2.1 修复确立的行为),并给需要锚定的消息设置scrollAnchor; - 表单提交:Questionnaire 走原生
<form>+FormData,服务端处理答案时应按题目name取值,可选项作答时注意multiple语义与skipped状态。
至此,从 0.2.0 首发到 0.3.0 新增 Questionnaire,packages/react/CHANGELOG.md中每一条记录都能在当前仓库源码中找到对应的 API、实现与测试落点;如需跟进后续版本,直接查看 packages/react/CHANGELOG.md 即可。
【免费下载链接】uiA set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. Open Source. Open Code.项目地址: https://gitcode.com/GitHub_Trending/ui/ui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考