关于 message-list
@virtuoso.dev/message-list 是面向人工与 AI 聊天界面的虚拟列表,内置滚动管理,可处理流式回复、新消息和历史记录加载。
主要功能
- 01虚拟化聊天消息渲染
- 02消息更新滚动策略
- 03加载历史时保持视口位置
- 04支持流式回复
- 05滚动位置钩子与 API
适用场景
- 01构建聊天对话界面
- 02让流式回复保持可见
- 03加载旧消息并保持视图稳定
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ virtuoso-dev/ skills --skill message-list安装后在 Claude Code 中输入/message-list确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/10.smooth-scrolling.md11.testing.md15.imperative-data-api.md19.resize-observer-errors.md20.scroll-modifier.md21.controlled-mode-recipes.md4.headers-footers.md5.context.md6.item-keys.md7.hooks.md8.custom-scrollbar.md80.licensing.md9.scrolling-to-item.mdREADME.mdreferences/2.tutorial/01.intro.md02.message-list.md03.loading-older-messages.md04.scroll-to-bottom-button.md05.receive-messages.md06.send-messages.md07.multiple-channels.mdreferences/3.examples/01.messaging.md02.ai-chatbot.md03.gemini.md04.reactions.md05.date-separators.md06.scroll-to-reply.md07.grouped-messages.md详细介绍
@virtuoso.dev/message-list
VirtuosoMessageList 是一款专为人类与 AI 聊天打造的虚拟列表:支持贴底行为、流式响应增长时不发生滚动跳跃、前置历史消息时保持视觉位置,以及跟踪滚动位置。构建对话 UI 时,请使用它而不是普通的 Virtuoso——这些行为都是内置的,无需手动拼装。
许可
该软件包为商业软件(按年、按开发者收费)。每个实例都必须由 VirtuosoMessageListLicense 包裹:
<VirtuosoMessageListLicense licenseKey={licenseKey}>
<VirtuosoMessageList ... />
</VirtuosoMessageListLicense>
空的 licenseKey="" 可用于 30 天的非生产环境评估:在开发环境中仍会继续渲染,并在控制台显示提醒;生产环境中若没有有效密钥,则会渲染错误。验证在本地进行,不会发出网络请求。将此软件包引入项目时,请向用户说明这一点;密钥可从 https://virtuoso.dev/pricing/ 获取。建议使用应用级包装组件,从常规的客户端公开环境配置中读取密钥。
核心思路:数据更新携带滚动指令
无需单独调用命令式滚动方法,每次数据更新都包含一个 scrollModifier,用于描述视口应如何响应:
const [data, setData] = useState<VirtuosoMessageListProps<Message, null>['data']>(() => ({
data: initialMessages,
scrollModifier: { type: 'item-location', location: { index: 'LAST', align: 'end' } },
}))
<VirtuosoMessageList<Message, null> style={{ height: '100%' }} data={data} computeItemKey={({ data }) => data.key} ItemContent={ItemContent} />
ItemContent 是一个接收 { data, index, context } 属性的组件(而不是像 react-virtuoso 那样接收位置参数)。除非它需要访问本地状态闭包,否则请将它移到父级渲染函数之外。
外部行状态不同于数据更新。如果 context、旁路映射、展开状态、反应、审批或加载标记可能在数据数组不变的情况下改变行高,请在更新该状态后,调用消息列表 ref 上的 notifyItemsChanged({ scrollToBottom })。
滚动修饰符参考
| 修饰符 | 使用场景 |
|---|---|
{ type: 'item-location', location, purgeItemSizes? } | 初始加载或切换频道;当项目不同时,purgeItemSizes: true 会清除缓存的尺寸 |
{ type: 'auto-scroll-to-bottom', autoScroll } | 追加新消息;回调可以返回 false、一种行为、true 或一个项目位置 |
'prepend' | 在顶部添加较早的消息;保持当前消息在视觉上的位置 |
{ type: 'items-change', behavior } | 现有数据项尺寸发生变化,例如流式文本或存储在数据项中的反应 |
'remove-from-start' / 'remove-from-end' | 修剪数据,同时保持视觉位置 |
null / undefined | 保持滚动位置不变 |
使用回调形式时,返回一种行为或项目位置会触发滚动,即使 atBottom 为 false 也是如此。如果用户向上滚动,请返回 false 以保持视口不变。
常见模式
接收消息(在位于底部时自动滚动)
import { scrollToBottomIfAtBottom } from '@virtuoso.dev/message-list'
setData((current) => ({
data: [...current.data, incoming],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: scrollToBottomIfAtBottom,
},
}))
对于远程消息,请使用 scrollToBottomIfAtBottom。如果列表已经位于底部或正在滚动到底部,它会返回 'smooth',否则返回 false。如果应用需要未读消息计数器,请使用内联回调形式,并在返回 false 之前递增计数器。
发送本地消息(强制滚到底部)
import { scrollToBottomAlways } from '@virtuoso.dev/message-list'
setData((current) => ({
data: [...current.data, optimisticLocalMessage],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: scrollToBottomAlways,
},
}))
对于本地当前用户的操作,请使用 scrollToBottomAlways,确保即使用户向上滚动,新的最后一项也能显示出来。如果实时套接字将同一条乐观消息回传,请通过 localId 或其他客户端生成的标识进行协调,并将其视为确认,而不是新收到的远程消息。
流式输出 AI 回复
追加一条空的助手消息,然后随着 token 到达逐步填充:
setData((current) => ({
data: current.data.map((msg) => (msg.key === botKey ? { ...msg, text: msg.text + chunk } : msg)),
scrollModifier: { type: 'items-change', behavior: 'smooth' },
}))
items-change 会在消息增长时让视图保持在底部;如果用户向上滚动,则不会跳动。请参阅 ai-chatbot 和 gemini(问题固定在顶部,答案在下方流式输出)。
实时追加尾部记录,同时插入记录
对于 @virtuoso.dev/message-list 1.18.0 及更高版本,如果实时更新可能在尾部上方插入或重新排序记录,可将替换锚点与可重新定位的平滑滚动行为结合使用:
const liveTailScroll = () => ({
animationFrameCount: 15,
easing: (progress: number) => 1 - (1 - progress) * (1 - progress),
retarget: true,
})
<VirtuosoMessageList
ref={ref}
computeItemKey={({ data }) => data.id}
itemIdentity={(item) => item.id}
ItemContent={ItemContent}
/>
ref.current.data.batch(
() => {
ref.current.data.replace(nextData, { anchor: { index: 'LAST', position: 'preserve' } })
},
({ atBottom, scrollInProgress }) =>
atBottom || scrollInProgress
? {
index: 'LAST',
align: 'end',
behavior: liveTailScroll,
}
: false
)
替换期间,锚点会让之前的最后一条记录保持在视口中的原有位置。重新定位会更新正在进行的动画的目标位置,而不是在底部移动时重新启动动画。新旧请求都必须使用带有 retarget: true 的行为。用户滚离实时尾部时,返回 false。
当更新会重新创建记录对象时,itemIdentity 必须返回稳定的标识。默认的对象标识无法匹配重新创建的包装对象,因此此时锚点不会执行任何操作。
外部行状态改变尺寸
当行内容改变尺寸但数据数组未改变时,使用 notifyItemsChanged:
import { scrollToBottomIfAtBottom, type VirtuosoMessageListMethods } from '@virtuoso.dev/message-list'
const messageListRef = useRef<VirtuosoMessageListMethods<Message, MessageListContext>>(null)
setExpandedMessages((current) => new Map(current).set(messageId, expanded))
messageListRef.current?.notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })
ResizeObserver 会测量行;notifyItemsChanged 提供滚动策略。可将其用于 context 中的展开状态、用于表情回应或审批的旁路映射、活动加载状态,或任何其他外部行状态。
向上滚动时加载较早的消息
<VirtuosoMessageList
onScroll={(location) => {
if (location.listOffset > -100 && !loading) {
loadOlder().then((older) => setData((current) => ({ data: [...older, ...current.data], scrollModifier: 'prepend' })))
}
}}
/>
无需维护 firstItemIndex(与普通 Virtuoso 不同)——'prepend' 会自动保留位置。
滚动到底部按钮
StickyFooter 会固定显示在视口底部;将其与位置 Hook 结合使用:
const StickyFooter = () => {
const location = useVirtuosoLocation()
const methods = useVirtuosoMethods()
if (location.bottomOffset <= 200) return null
return <button onClick={() => methods.scrollToItem({ index: 'LAST', align: 'end', behavior: 'auto' })}>▼</button>
}
切换频道
为每个频道保留一个数据对象,并使用 replace/item-location + purgeItemSizes: true 进行切换,这样上一个频道的尺寸缓存就不会扭曲新频道的显示。请参阅 multiple-channels。
受控模式方案
使用受控的 data 属性时,将 DataWithScrollModifier<Message> 保存在应用自有操作之后,例如 replaceMessages、appendIncoming、appendLocal、confirmLocal、updateStreamingMessage 和 rowChromeChanged。这是一种应用代码中的方案层,不是官方导出的 Hook,也不能替代命令式的 data.* API。
replaceMessages:切换频道或线程时使用{ type: 'item-location', location: { index: 'LAST', align: 'end' }, purgeItemSizes: true }。appendIncoming:远程消息使用scrollToBottomIfAtBottom。appendLocal:乐观处理本地发送时使用scrollToBottomAlways。confirmLocal:通过localId匹配 socket/服务端确认,并更新现有行。updateStreamingMessage:映射现有消息,并使用{ type: 'items-change', behavior: 'smooth' }。rowChromeChanged:更新外部行状态,并调用notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })。
在尝试于应用代码中使用此方案级受控状态之前,不要臆造或推荐公开的 useVirtuosoMessageListData Hook。请参阅 controlled-mode-recipes。
命令式 API
可在外部使用 ref={useRef<VirtuosoMessageListMethods<Message>>(null)},也可在列表内部渲染的组件中使用 useVirtuosoMethods():
data.append(items, scrollToBottom?)、data.prepend(items)data.map(fn, autoscrollBehavior?)— 更新条目(反应、编辑、流式输出)data.mapWithAnchor(fn, anchorItemIndex)— 更新条目尺寸,同时保持当前某个条目位于视口中的原有位置(自 1.15.0 起)data.findAndDelete(predicate)、data.deleteRange(start, length)data.replace(data, { anchor?, initialLocation?, purgeItemSizes?, suppressItemMeasure? })— 替换记录,可选择保留现有条目(替换锚点要求 1.18.0)data.find(predicate)、data.findIndex(predicate)、data.get()、data.getCurrentlyRendered()notifyItemsChanged({ scrollToBottom })— 上下文或侧边状态发生变化,且可能导致已渲染行尺寸改变、但数据数组未改变时使用scrollToItem({ index: number | 'LAST', align, behavior })
声明式 data 属性和命令式 data.* 方法是驱动同一个列表的两种方式 — 每个组件应选择一种作为主要机制,以避免更新相互冲突。
关键属性和钩子
ItemContent: ({ data, index, context }) => JSX— 消息渲染器context— 共享状态(当前用户、加载标记),可供ItemContent和所有自定义插槽使用;避免逐层传递属性computeItemKey({ data })— 稳定的消息键;要让前置插入和流式输出不触发重新挂载,此项必不可少itemIdentity(item)— 稳定的条目标识;当更新重新创建条目对象时,用于受控前置插入/从开头移除的匹配,以及锚点替换匹配- 插槽:
Header、Footer(随内容滚动)、StickyHeader、StickyFooter(固定,并测量尺寸以避免重叠)、EmptyPlaceholder initialLocation: { index: 'LAST', align: 'end' }— 从底部开始- 钩子(在列表内部使用):
useVirtuosoMethods()、useVirtuosoLocation()(isAtBottom、bottomOffset、listOffset、lastVisibleItemIndex、lastItemBottomOffset)、useCurrentlyRenderedData();可见条目相关字段要求 1.16.0
常见问题
- 没有高度 → 不会渲染任何内容。 组件需要实际高度(父级有尺寸时使用
style={{ height: '100%' }})。 - 缺少许可证包装器 → 运行时错误。 使用
VirtuosoMessageListLicense包装;空密钥仅供非生产环境评估使用。 - 缺少
computeItemKey会导致前置插入和流式更新时重新挂载并引起滚动跳动。 - 缺少
itemIdentity可能导致受控前置插入/从开头移除的匹配,以及更新重新创建条目对象时的锚点替换出现问题。 - 已测量消息行上的外边距 会破坏高度测量,因为 ResizeObserver 不计入外边距。请改用内边距或内部包装器。
- 滚动策略不明确 会导致聊天功能退化。应按来源决定:本地发送通常强制滚到底部;远程接收时应让已向上滚动的用户保持原位;流式输出使用
items-change;仅上下文导致的行高变化使用notifyItemsChanged。 - “ResizeObserver loop” 错误无害 — 在开发覆盖层和错误跟踪中将其过滤;请参阅 resize-observer-errors。
- JSDOM 测试需要通过
VirtuosoMessageListTestingContext.Provider value={{ itemHeight, viewportHeight }}模拟测量结果,并添加 ResizeObserver polyfill;滚动行为测试建议使用 Playwright。请参阅 testing。
从普通映射聊天列表迁移的检查清单
- 添加
VirtuosoMessageListLicense包装器,并决定如何提供密钥。 - 通过设置尺寸的父元素或直接设置样式,为列表设置实际高度。
- 提取
ItemContent,并通过context传递共享状态。 - 为消息 ID 和本地乐观 ID 添加稳定的
computeItemKey。 - 当受控数据会重新创建对象并使用前置插入/裁剪时,添加
itemIdentity。 - 从测量行中移除外边距;使用内边距或内部包装器设置间距。
- 为本地发送、远程接收、流式更新、套接字回声和外部行增长选择明确的滚动策略。
- 尽可能使用 Playwright/浏览器测试验证滚动行为;使用测试上下文进行 JSDOM 单元测试。
参考资料
- references/README.md — 概览和在线示例
references/2.tutorial/— 分步构建聊天: 简介、消息列表、加载较早的消息、滚动到底部按钮、接收消息、发送消息、多个频道references/3.examples/— 消息、AI 聊天机器人、gemini、回应、日期分隔符、滚动到回复、分组消息- 功能指南:页眉和页脚、上下文、项目键、Hooks、自定义滚动条、滚动到项目、平滑滚动、命令式数据 API、滚动修饰符、受控模式用法、测试、许可
完整 API 参考:https://virtuoso.dev/message-list/