mcpField
M
message-list

message-list

github.com/virtuoso-dev开发145 次安装4 周前更新
代码仓库

关于 message-list

@virtuoso.dev/message-list 是面向人工与 AI 聊天界面的虚拟列表,内置滚动管理,可处理流式回复、新消息和历史记录加载。

主要功能

  1. 01虚拟化聊天消息渲染
  2. 02消息更新滚动策略
  3. 03加载历史时保持视口位置
  4. 04支持流式回复
  5. 05滚动位置钩子与 API

适用场景

  1. 01构建聊天对话界面
  2. 02让流式回复保持可见
  3. 03加载旧消息并保持视图稳定

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/virtuoso-dev/skills --skill message-list

安装后在 Claude Code 中输入/message-list确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
10.smooth-scrolling.md
11.testing.md
15.imperative-data-api.md
19.resize-observer-errors.md
20.scroll-modifier.md
21.controlled-mode-recipes.md
4.headers-footers.md
5.context.md
6.item-keys.md
7.hooks.md
8.custom-scrollbar.md
80.licensing.md
9.scrolling-to-item.md
README.md
references/2.tutorial/
01.intro.md
02.message-list.md
03.loading-older-messages.md
04.scroll-to-bottom-button.md
05.receive-messages.md
06.send-messages.md
07.multiple-channels.md
references/3.examples/
01.messaging.md
02.ai-chatbot.md
03.gemini.md
04.reactions.md
05.date-separators.md
06.scroll-to-reply.md
07.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 单元测试。

参考资料

完整 API 参考:https://virtuoso.dev/message-list/

评论

支持 Markdown