跳转到内容

MarkdownStream 流式正文 alpha

把已渲染的 Markdown 块列表投影为带稳定 key 的正文结构,按块的种类分流。

用法

块列表由宿主用流式渲染器得到,组件只按 key 铺开、按种类分流

结论

先给结论:这段正文是一次性渲好的。

  • 块列表由渲染器产出
  • 每块带一个稳定的 key

组件结构

加粗的是必需部件。

data-scope="markdown-stream"root · content · block · live-region

示例

流式增长

只有生长中的块每帧重渲,定型的块 key 不变、节点原地保留,选区与滚动位置才能保持

代码块交给代码视图

markdown 块铺设 html,代码块取 source 交出:按 html 渲染会使同一段代码出现两次

先看这段实现:

export function clamp(n: number, min: number, max: number) {  return Math.min(Math.max(n, min), max)}

两端都夹住,越界的输入不会漏过去。

流式光标

尚未收到任何块时光标就已存在,caret 设为 false 可以整个关闭

等第一个字:块列表还是空的

正在出字:光标停在生长块末尾

正在写的这一句。

caret 设成 false:一竖都不画

正在写的这一句。

尺寸

size 改变正文字号与块间距,三档共用同一份块列表

结论

先给结论:这段正文是一次性渲好的。

结论

先给结论:这段正文是一次性渲好的。

结论

先给结论:这段正文是一次性渲好的。

设计指引

何时使用

  • 展示 AI 回复的正文,且正文边生成边显示。
  • 正文中混有代码块与公式,需要分别交给专门的组件渲染。

何时不用

  • 正文是一次性获取的静态文档时,直接渲染,不经过流式内核。
  • 只是一段纯文本时,使用排印

特性

  • 组件不解析 Markdown,也不持有渲染器。块列表由宿主调用 @xihan-ui/markdowncreateStreamRenderer().render(全文) 得到后传入;渲染器有状态,由持有方负责。
  • 块的 key 稳定:生长中的块 key 不变,定型的块 key 不再变化。框架据此复用同一份 DOM 只更新文本;每收到一个字就重建节点会丢失选区与滚动位置。
  • html 只对 markdown 块有效。代码块取 source 交给代码视图,公式块取 source 交给宿主选择的公式引擎;不接管时的降级结果是把原文作为正文显示。
  • 流式光标是皮肤的 ::after,不做成组件。它绘制在带 data-caret 的部件上:正文增长时是生长中的块,尚无任何块时是外壳,因此请求刚发出、尚无内容时页面上也有反馈。caret 设为 false 时两处都不发该属性。
  • 光标在等待第一个字时闪烁,出字后停为实心:正文本身在变化,继续闪烁只是噪声。

组合

  • 代码块交给代码视图,整段正文放入消息流的一条消息。
  • 逐字输出的节奏由使用者驱动:@xihan-ui/chat-streamvisibleLength 是纯函数,时间原点与 rAF 循环由持有方编写。
  • 正文中需要嵌入行内来源角标、脚注等节点时,用 block 插槽接管该块自行渲染。组件不向已消毒的 html 中插入节点,这个插槽就是为此保留的位置。

最佳实践

  • 块列表整份传入,不在外部切片:稳定 key 依赖整份列表的下标与内容。
  • 交出代码块时一并传递 complete,代码组件据此决定是否着色。

反模式

  • 每帧新建渲染器:缓存失效,长回复后段会明显卡顿。
  • 同时渲染代码块的 html 与交给代码组件的内容:同一段代码会出现两次。

API 参考

产物

自定义元素<xh-markdown-stream>
Vue 组件XhMarkdownStreamContent XhMarkdownStreamLiveRegion XhMarkdownStreamRoot
组合式函数useMarkdownStream
状态机无,connect 直接由 props 算属性
皮肤@xihan-ui/styles/markdown-stream.css

Props

属性类型必填说明
announce'off' | 'polite' | 'assertive'播报档位,默认 off:会话级播报区在消息流层,不在每条回复中各开一个。
blocksreadonly MarkdownBlock[]已渲染完成的块列表。
caretboolean是否绘制流式光标,默认绘制。设为 false 时不发出任何 data-caret。
sizeSize尺寸:sm / md / lg。
streamingboolean该段正文是否仍在增长,只写 data-streaming。
translationsPartial<MarkdownStreamTranslations>

插槽

仅列出带载荷的插槽。

Vue 组件插槽载荷说明
XhMarkdownStreamContentblockMarkdownStreamBlockSlotProps
XhMarkdownStreamRootdefaultMarkdownStreamRootSlotProps

状态

公开状态写入 data-state

部件取值
root'streaming' | 'complete'

connect API

getXxxProps() 返回对应部件的宿主属性。

成员类型说明
blocksreadonly MarkdownBlock[]
streamingboolean
announcementstring | undefined播报文本;announce 为 off、或正文仍在增长时为 undefined。
getRootProps() => T['element']
getContentProps() => T['element']
getBlockProps(props: { block: MarkdownBlock }) => T['element']
getLiveRegionProps() => T['element']

无障碍

键盘

规格出处:W3C APG

按键生效条件行为
任何时候组件不接管任何按键;块内的链接、代码块各自的停靠点由它们自己提供

ARIA

以下属性由 connect 生成。

部件属性
live-regionaria-atomic'true'
live-regionaria-live'assertive' | 'polite'
live-regionrole'alert' | 'status'
  • 正文不加 role,也不做成活动区域:每个 token 播报一次会淹没读屏。
  • 需要在一段回复完成时播报一句,把 announce 设为 polite 并渲染播报区。一个会话中只应有一个活动区域,多个会互相打断。

样式参考

皮肤

@xihan-ui/styles/markdown-stream.css 使用 [data-scope="markdown-stream"][data-part="root"] 部件选择器,位于 xihan.componentsxihan.motion 层。覆盖样式使用 xihan.overrides

数据属性

connect 生成;条件不成立时不输出无值属性。

部件属性
rootdata-caret''(条件成立时才出现)
rootdata-sizeprops.size
rootdata-state'streaming' | 'complete'
blockdata-caret''(条件成立时才出现)
blockdata-complete''(条件成立时才出现)
blockdata-kindblock.kind
blockdata-langblock.lang
blockdata-live''(条件成立时才出现)

CSS 变量

本组件公开覆盖槽由独立皮肤的实际消费位生成;默认来源、作用部件和状态均与 CSS 同源。

变量部件CSS 属性状态默认来源说明
--xh-markdown-stream-caret-bgblock
root
backgroundcaret--xh-fg-defaultmarkdown-stream 的 block、root 部件 background 覆盖槽。
--xh-markdown-stream-caret-durationrootanimationcaret--xh-caret-durationmarkdown-stream 的 root 部件 animation 覆盖槽。
--xh-markdown-stream-caret-enter-durationblockanimationcaret--xh-motion-duration-entermarkdown-stream 的 block 部件 animation 覆盖槽。
--xh-markdown-stream-caret-gapblock
root
margin-inline-startcaret0.1emmarkdown-stream 的 block、root 部件 margin-inline-start 覆盖槽。
--xh-markdown-stream-caret-hblock
root
block-sizecaret1.05emmarkdown-stream 的 block、root 部件 block-size 覆盖槽。
--xh-markdown-stream-caret-radiusblock
root
border-radiuscaret--xh-shape-insetmarkdown-stream 的 block、root 部件 border-radius 覆盖槽。
--xh-markdown-stream-caret-shiftblock
root
translatecaret-0.5pxmarkdown-stream 的 block、root 部件 translate 覆盖槽。
--xh-markdown-stream-caret-wblock
root
inline-sizecaret--xh-stroke-thickmarkdown-stream 的 block、root 部件 inline-size 覆盖槽。
--xh-markdown-stream-fgrootcolordefault--xh-fg-defaultmarkdown-stream 的 root 部件 color 覆盖槽。
--xh-markdown-stream-font-sizerootfont-sizedefault--xh-_markdown-stream-font-sizemarkdown-stream 的 root 部件 font-size 覆盖槽。
--xh-markdown-stream-gapcontentgapdefault--xh-_markdown-stream-gapmarkdown-stream 的 content 部件 gap 覆盖槽。
--xh-markdown-stream-leadingrootline-heightdefault--xh-text-prose-leadingmarkdown-stream 的 root 部件 line-height 覆盖槽。
--xh-markdown-stream-monoblockfont-familykind=code
kind=math
--xh-font-family-monomarkdown-stream 的 block 部件 font-family 覆盖槽。

动效

关键帧 xh-markdown-stream-caret · xh-markdown-stream-caret-in 随皮肤自带,不引用别处文件里的名字。时长与缓动读动效令牌,改令牌即改全局节奏。

prefers-reduced-motion: reduce 下本组件另有降级规则。

RTL

皮肤用逻辑属性排布(inline-start 一族),dir="rtl" 下自动镜像。

Released under The MIT License