news 2026/9/5 16:02:27

@shadcn/react 版本演进与源码解析:MessageScroller 锚定修复与 Questionnaire 多步问卷原始组件

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
@shadcn/react 版本演进与源码解析:MessageScroller 锚定修复与 Questionnaire 多步问卷原始组件

@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 明确标注headlessunstyledprimitivesaccessible);
  • 运行时依赖peerDependencies要求react >= 19@types/react >= 19,且两者均标记为 optional——这是版本能力的重要适用前提,使用该包的宿主应用必须基于 React 19;
  • 入口设计exports只暴露两个子路径——./message-scroller./questionnaire,分别指向dist/message-scroller/index.jsdist/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.0Minor#11414新增 Questionnaire 原始组件,支持多步提问、自由文本作答、校验与键盘导航
0.2.1Patch#11106修复 MessageScroller 在锚定轮次周围的 autoScroll:锚定保持(anchor hold)在流式回复撑满视口后移交给 follow-output;内容增长快于帧合并后的 resize 处理器时不再解除 follow-output;滚动按钮不再因 follow-output 追赶刚被流式块拉开的间隙而闪烁
0.2.1Patch#11100修复 MessageScroller 在开启 autoScroll 时丢弃新追加的 scrollAnchor 定位,使锚定轮次在回复流式输出期间保持在阅读行
0.2.1Patch#11085通过把 ResizeObserver 回调合并进 requestAnimationFrame,修复流式内容增长期间触发 "ResizeObserver loop completed with undelivered notifications" 的问题
0.2.0Minor#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(useMessageScrolleruseMessageScrollerScrollableuseMessageScrollerVisibility),以及若干类型(MessageScrollerScrollableMessageScrollerVisibilityStateMessageScrollerScrollOptionsMessageScrollerScrollAlignMessageScrollerDefaultScrollPosition)。

基本用法(继承自 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 = falsedefaultScrollPosition = "end",其余scrollEdgeThresholdscrollPreviousItemPeekscrollMargin透传给内部的useMessageScrollerController

部件职责关键 props
MessageScroller.Providerheadless 根节点;拥有滚动状态、锚定、自动跟随与可见性autoScrolldefaultScrollPositionscrollPreviousItemPeekscrollMarginscrollEdgeThreshold
MessageScroller.Root样式化外框;在 provider 内布局 viewport、content 与控件
MessageScroller.Viewport可滚动外框preserveScrollOnPrepend
MessageScroller.Content消息列表;默认role="log"+aria-relevant="additions"
MessageScroller.Item单条消息包装messageIdscrollAnchor
MessageScroller.Button滚动到末尾/开头的入口;跟平时自动隐藏direction

三个 hook 的实现(components.tsx)值得注意:useMessageScrollerScrollableuseMessageScrollerVisibility都基于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 个部件:RootProgressItemTitleDescriptionChoicesChoiceChoiceInputChoiceLabelChoiceShortcutInputErrorPreviousSkipNextSubmit。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 const
import { 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),配合ItemonStatusChange回调与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共享同一QuestionnaireNavigationStatedisabledshortcut: "Enter" | nullstatusvisible),意味着"Enter 推进"这一键盘导航行为在类型上被显式建模;
  • 自由文本输入QuestionnaireInputType覆盖datedatetime-localemailmonthnumberpasswordsearchteltexttimeurlweek等 13 种原生 input 类型(types.ts),支撑 CHANGELOG 所称的 freeform answers;
  • SSR 支持Root.items接收readonly QuestionnaireItemDefinition[](含namerequireddisabledchoices),由 collection.ts 的createQuestionnaireCollection一次性派生enabledItemsitemByName索引,使服务端无需 DOM 交互即可算出完整的集合状态。

Item渲染为原生<fieldset>QuestionnaireItemPropsReact.ComponentPropsWithRef<"fieldset">为基底),Title<legend>——从源码结构看,Questionnaire 延续了"原生表单元素 + headless 逻辑注入"的一贯路线,答案因此天然可被FormData采集。

测试与性能验证:版本承诺如何被守住

两个子模块各自的 README 与 PERFORMANCE.md 给出了版本变更的验证体系,这也是阅读 CHANGELOG 时值得参考的部分:

MessageScroller 的测试分层(README 测试表):

文件环境覆盖内容
geometry.test.tsjsdom使用桩 rect 的几何计算
message-scroller.browser.test.tsxchromiumjsdom 无法建模的行为:原生滚动锚定、prepend、可见性
message-scroller.perf.browser.test.tsxchromium性能基准 + 回归防护
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 与包元数据,给使用者的几点实操结论:

  1. 版本选择:需要多步问卷交互用0.3.0+(Questionnaire 在该版本才出现);仅用 MessageScroller 则0.2.1+更稳妥,因为 0.2.1 修复了流式输出下锚定丢失、follow-output 误解除与 ResizeObserver 告警三类实际体验问题;
  2. 环境前提:宿主应用必须是 React 19+(peer 依赖react >= 19),按子路径导入(@shadcn/react/message-scroller@shadcn/react/questionnaire);
  3. 样式自行负责:包定位是无样式原语,RootItemChoice等部件渲染的都是原生表单/结构元素,外观由消费方通过useRender风格的 render props 或 CSS 提供;
  4. 流式聊天场景:使用MessageScroller.Provider autoScroll时理解"锚定保持 → follow-output 移交"的状态语义(0.2.1 修复确立的行为),并给需要锚定的消息设置scrollAnchor
  5. 表单提交: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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 16:00:49

提示词、规则、Skill与MCP详解:构建AI工程化协作链路

最近讨论 AI 工程化的时候&#xff0c;总绕不开四个词&#xff1a;提示词、Prompt、规则、Skill、MCP。很多同学会把它们当成同一件事去搜资料&#xff0c;结果越看越乱。有人以为“只要提示词写得好&#xff0c;其他概念都不需要”&#xff0c;也有人以为“MCP 是一种新模型”…

作者头像 李华
网站建设 2026/9/5 15:57:59

开源AI短剧工具选型:从部署、资产到界面拆解生产管线

在实际 AI 短剧项目的选型里&#xff0c;最常见的误区是先把“生成视频”当成一个黑盒&#xff0c;以为找到一个开源仓库就能输入一句话&#xff0c;输出一集成品。真正深入之后会发现&#xff0c;AI 短剧是一条由剧本、画面、动态、声音、字幕、剪辑组成的生产流水线&#xff…

作者头像 李华
网站建设 2026/9/5 15:49:07

Custom System Prompt

Custom System Prompt 【免费下载链接】gemini-cli An open-source AI agent that brings the power of Gemini directly into your terminal. 项目地址: https://gitcode.com/GitHub_Trending/gemi/gemini-cli You are a helpful assistant. ${AgentSkills} ${SubAgent…

作者头像 李华