面向 AI 对话与 token streaming 的 Vue 3 Markdown 渲染器
稳定提交已经闭合的内容,保守处理仍在生成的尾部,并用唯一的 Unified 链路生成 Vue VNode。
Important
当前仓库是可运行的源码工程和完整实现验证,尚未配置 npm 发布与稳定包导出。下面的导入示例以本仓库的 Vite @ 别名为准。
普通 Markdown 渲染器通常在每个新 token 到达时重新解析整篇文档。对于长回答,这不仅产生重复工作,还可能让未闭合的强调、链接、代码和列表在不同结构之间反复跳动。
md-stream-plus 将问题拆成三个互不越权的层:
| 层 | 只负责决定 | 不负责 |
|---|---|---|
| SourceEngine / Incremark 思路 | 哪一段源文本需要重新处理、稳定边界在哪里 | Markdown AST 语义 |
| StreamingNormalizer / Markstream-vue 思路 | pending 尾部以什么临时形态交给 parser | 修改原始文本、提交稳定块 |
| Unified + VNode renderer / XMarkdown 思路 | MDAST/HAST 如何生成,以及 HAST 如何转成 Vue VNode | 流式边界与源文本生命周期 |
这意味着:已证明稳定的块可以保持身份与缓存;不完整的尾部仍然可读;最终结果继续由标准 Markdown 语义链路负责。
- ⚡ 真正的增量源文本:piece table、可编辑行索引、稳定边界 checkpoint 和持久 slice ID。
- 🌊 流式中间态容错:只追加 synthetic suffix,不删除、不替换、不污染 raw source。
- ✍️ 支持源文本编辑:
replaceText()使用 UTF-16 exclusive range,setText()自动计算最小 replacement。 - 🧠 全局语义正确性:definition、heading slug 和 footnote 按文档顺序建立索引并细粒度失效。
- 🧩 分区式全局编译:需要全局 AST 的 transform 仍可按 source owner 拆回稳定渲染区。
- 🦶 增量脚注:维护引用编号、重复引用 backref、ARIA 属性和独立 footnote section。
- 🧵 大文档 Worker:浏览器中默认超过 64 KiB 自动迁移;revision/generation 阻止旧结果覆盖新状态。
- 🔒 安全 HTML 策略:默认显示为文本;开启后走
rehype-raw,并默认经过rehype-sanitize。 - 🧮 常用 Markdown 扩展:GFM、KaTeX 数学公式、directive container、任务列表、表格和脚注。
- 🎨 纯 Vue VNode 渲染:不依赖
v-html,支持组件 alias、slot alias 和自定义属性。 - 🧪 可观测、可回归:trace 记录边界、缓存、失效、patch、Worker、partition 和阶段耗时。
| 能力 | 状态 | 说明 |
|---|---|---|
| CommonMark 基础语法 | ✅ | 标题、段落、引用、列表、链接、图片、代码等 |
| GFM | ✅ | 表格、任务列表、删除线;singleTilde: false |
| 行内与块级数学公式 | ✅ | remark-math + KaTeX,支持 $...$ / $$...$$ |
| Directive container | ✅ | :::tip 与兼容的 ::: tip,统一输出安全 div |
| Footnote | ✅ | 增量编号、重复引用、backref、ARIA 和全局 section |
| Raw HTML | ✅ | 默认关闭;开启后解析,默认清理危险内容 |
| Definition 晚到达 | ✅ | 后到 definition 可激活已提交 reference |
| Heading 全局去重 | ✅ | 重复标题生成稳定、唯一的文档级 slug |
| Source replacement | ✅ | UTF-16 range replacement 与最小化 setText |
| Web Worker | ✅ | 自动阈值、显式开关、旧 ticket 隔离 |
| 自定义 Vue 组件 | ✅ | heading、code、list、container 等 alias |
| Shiki / Mermaid | ⏳ | 当前未内置,可通过自定义 code renderer 扩展 |
| npm package / SSR Worker | ⏳ | 当前不在实现范围内 |
flowchart LR
A["文本 chunk / UTF-8 bytes / replacement"] --> B["Piece table + editable line index"]
B --> C["Boundary detector"]
C --> D["Committed slices"]
C --> E["唯一 Pending slice"]
E --> F["Suffix-only normalizer"]
D --> G["MDAST cache"]
F --> G
G --> H["Global semantics"]
H --> I["HAST cache / partitioner"]
I --> J["Pure HAST → Vue VNode"]
H --> K["Footnote global fragment"]
K --> J
- offset 统一使用 JavaScript UTF-16 code unit,range end 为 exclusive。
- replacement 从受影响 slice 的前驱回退扫描,处理段落合并、Setext、表格和 CRLF 边界变化。
- reconciliation 按内容与 context signature 复用 slice ID;仅位移的块不会失去 RenderBlock/DOM 身份。
- 每次 mutation 发布有序、无重叠的 committed slices,以及至多一个 pending slice。
核心不变量:
safeText === rawText + syntheticSuffix高置信状态下可以临时闭合 code span、$ / $$、link destination/title,以及符合 CommonMark flanking 和 multiple-of-three 规则的强调分隔符。对于 fence、HTML、container、\[ 或无法证明安全的歧义状态,选择 literal/deferred fallback。
remark-parse → remark-gfm / remark-math / remark-directive → remark-rehype → rehype-katex → rehype-sanitize 负责 MDAST/HAST;递归 renderer 只读 HAST 并纯函数地生成 VNode。
ParseCache 和 HastCache 分离保存 slice-relative position。全局 transform 完成后,HAST partitioner 根据 position、祖先与 ownership hook 将顶层节点归还给 source owner;归属不明确时才降级为 document-global。
- Node.js
>= 20.19.0 - npm(项目包含
package-lock.json) - 现代浏览器;Worker 仅在浏览器环境启用
git clone https://github.com/seren-spark/md-stream.git
cd md-stream
npm install
npm run devPlayground 支持:
- 逐字符、逐词、逐行和确定性随机分块;
- 播放、暂停、单步、完成解析与重置;
- 可编辑完整 Markdown,并观察 source replacement;
- HTML 安全模式与 Worker 自动迁移;
- slice、pending、raw source、cache、partition 和 compile trace 面板。
下面示例适用于仓库内部或直接复用源码的项目:
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import { createStreamingMarkdownController } from '@/index'
import StreamingMarkdown from '@/vue/StreamingMarkdown.vue'
const controller = createStreamingMarkdownController({
documentId: 'assistant-message-1',
scheduler: 'microtask',
allowHtml: false,
worker: 'auto',
})
async function consume(response: Response) {
const reader = response.body?.getReader()
if (!reader) return
while (true) {
const { done, value } = await reader.read()
if (done) break
await controller.appendBytes(value)
}
await controller.finalize()
}
onBeforeUnmount(() => controller.dispose())
</script>
<template>
<StreamingMarkdown :controller="controller" />
</template>appendBytes() 内部使用流式 TextDecoder,可以正确处理跨网络 chunk 的 UTF-8 多字节字符。
await controller.appendText('# Hello\n\nStreaming **mark')
await controller.appendText('down**.')
await controller.finalize()浏览器 textarea 的 selectionStart / selectionEnd 本身就是 UTF-16 offset,可以直接传入:
await controller.replaceText(selectionStart, selectionEnd, replacement)
// 自动通过最长公共前后缀转换为最小 replacement
await controller.setText(nextMarkdown)仅改变文档头部时,后方未改变的 slice 会尽量保持 ID、MDAST/HAST 缓存和 DOM key。
<script setup lang="ts">
import CodeBlock from './CodeBlock.vue'
const components = {
'block-code': CodeBlock,
}
</script>
<template>
<StreamingMarkdown
:controller="controller"
:components="components"
/>
</template>| Alias | 匹配节点 | 额外 props |
|---|---|---|
heading |
h1—h6 |
level |
inline-code |
段落内 code |
language、content、inline |
block-code |
pre > code |
language、content、loading、sourceRange |
list |
ul / ol |
ordered、depth |
list-item |
li |
ordered、depth、index |
container |
任意 directive container | 原始安全属性 |
container:tip |
指定名称的 container | 原始安全属性 |
| HTML tag name | 对应 HAST element | 原始安全属性 |
Markdown 不能直接指定任意 Vue 组件。Directive container 始终先转换成 div,再由宿主通过受控 alias 映射组件。
const customAttrs = {
heading: { class: 'article-heading' },
a: (_node, props) => ({
class: ['article-link', props.class],
'data-track': 'markdown-link',
}),
}<StreamingMarkdown
:controller="controller"
:custom-attrs="customAttrs"
/>renderer 不修改缓存 HAST;属性转换和 custom attrs 合并都会创建新对象。
interface StreamingMarkdownControllerOptions {
documentId?: string
allowHtml?: boolean
sanitize?: boolean
plugins?: StreamingMarkdownPlugin[]
scheduler?: 'immediate' | 'microtask' | 'animation-frame' | 'manual'
worker?: 'auto' | boolean
workerThreshold?: number
workerBatchMs?: number
}| 选项 | 默认值 | 说明 |
|---|---|---|
documentId |
'document' |
slice ID 命名空间 |
allowHtml |
false |
是否将 raw HTML 作为结构解析 |
sanitize |
true |
是否运行 HAST sanitize;不可信内容不要关闭 |
plugins |
[] |
Unified、boundary、semantics 与 ownership 扩展 |
scheduler |
'microtask' |
mutation 调度与 append 合并策略 |
worker |
'auto' |
自动、强制或禁用 Worker |
workerThreshold |
64 * 1024 |
自动迁移阈值,单位为 UTF-16 code unit |
workerBatchMs |
16 |
Worker append 批处理等待时间 |
| 方法 | 返回值 | 说明 |
|---|---|---|
appendText(chunk) |
Promise<void> |
追加字符串 chunk |
appendBytes(chunk) |
Promise<void> |
追加 UTF-8 bytes,支持跨 chunk 解码 |
replaceText(start, end, text) |
Promise<void> |
替换 UTF-16 [start, end) 范围 |
setText(text) |
Promise<void> |
将全文转换为最小 replacement |
flush() |
Promise<void> |
立即排空 mutation scheduler |
finalize() |
Promise<void> |
flush decoder/scheduler,再执行 strict 全文 parse/run |
reset() |
void |
清空文档、缓存、Worker session 和 finalized 状态 |
dispose() |
void |
释放订阅与 Worker;controller 不可再次写入 |
getSnapshot() |
RenderStateSnapshot |
获取当前不可变渲染快照 |
subscribe(listener) |
() => void |
订阅快照,注册时立即收到当前值 |
- 同一个
reset()周期内,appendBytes()不能与appendText()/ 编辑 API 混用。 replaceText()的 range 必须是合法整数、end exclusive,且不能超出当前 raw source。finalize()后禁止继续写入;需要新一轮内容时先调用reset()。dispose()后 controller 永久不可写。finalize()是 correctness oracle:完整 raw source 会经过 strict parse/run,并原子替换 provisional 结果。
interface RenderStateSnapshot {
blocks: readonly RenderBlock[]
pending: PendingRenderRegion | null
global: readonly HastFragment[]
sourceRevision: number
structureRevision: number
semanticRevision: number
final: boolean
rawSource: string
committedSlices: readonly CommittedSlice[]
trace: AppendTrace | null
}trace 包含 boundary evidence、normalizer 决策、MDAST/HAST cache hit、语义失效、patch 数量、piece 数量、重扫范围、partition owner、执行线程与各阶段耗时。
| 环境 / 配置 | 执行位置 |
|---|---|
浏览器、小于阈值、worker: 'auto' |
主线程 |
| 浏览器、达到阈值、无自定义运行时插件 | 自动迁移到 Worker |
SSR 或没有 Worker 全局对象 |
主线程 |
worker: false |
始终主线程 |
worker: true 且 Worker 不可用 |
构造 controller 时明确报错 |
worker: 'auto' 且存在自定义插件 |
留在主线程并写入 diagnostic |
worker: true 且存在自定义插件 |
构造 controller 时明确报错 |
迁移时会传递完整 raw source 并在 Worker 重建缓存。reset/dispose 通过 generation 隔离旧 session,编译结果通过 source/structure/semantic revision ticket 防止竞态回写。
interface StreamingMarkdownPlugin {
name: string
remark?: Pluggable
rehype?: Pluggable
boundary?: BoundaryExtension
semantics?: SemanticExtension
scope: 'block-local' | 'symbol-aware' | 'document-global'
requiresGlobalParse?: boolean
async: boolean
introducesBlockSyntax?: boolean
ownership?: HastOwnershipExtension
}remark/rehype:进入唯一的 Unified pipeline。boundary:告诉 SourceEngine 新块语法如何影响稳定边界。semantics:声明跨 slice 的语义 revision。scope:声明插件是块局部、符号感知还是文档全局。requiresGlobalParse:强制使用完整 raw-source MDAST。ownership:认领 transform 新建且没有可靠 position 的顶层 HAST。
Warning
声明 introducesBlockSyntax: true 却没有提供 boundary extension 会直接报错。否则 SourceEngine 无法证明提交边界安全。
allowHtml: false时 raw HTML 作为文本显示。allowHtml: true时执行remark-rehype(allowDangerousHtml) → rehype-raw。sanitize默认开启,schema 显式允许 KaTeX/MathML、GFM task list、footnote 和 container 所需属性。- URL 协议限制为
http、https、mailto、tel等安全集合;VNode renderer 再拒绝javascript:、vbscript:与data:。 - 外部 HTTP(S) 链接默认补充
target="_blank"和rel="noopener noreferrer"。 - HAST 到 VNode 采用纯函数递归,不通过
v-html注入字符串。
只有在 Markdown 来源完全可信时才考虑 sanitize: false。
| 阶段 | 主要能力 |
|---|---|
| V1 | append-only SourceEngine、稳定边界、pending suffix、Unified 编译、definition 依赖、全局 heading slug、footnote fallback、双缓存、Vue renderer 和 strict finalize |
| V2 | piece table、source replacement、持久 slice ID、增量 footnote、全局 HAST 分区、完整 link/delimiter scanner、Worker 与编辑型 Playground |
src/
├─ source/ piece table、行索引、边界检测与 SourcePatch
├─ streaming/ pending tail scanner、normalizer 与 source map
├─ compiler/ MDAST/HAST、双缓存、全局语义与 partition
├─ renderer/ 纯 HAST → Vue VNode renderer
├─ orchestration/ controller、scheduler 与 RenderStore
├─ worker/ Worker client、protocol 与 worker core
├─ vue/ StreamingMarkdown、RenderBlock、PendingRegion
├─ plugins/ 内置 GFM/math/directive 与插件类型
├─ App.vue 交互式 Playground
└─ styles.css Playground 与 Markdown 展示样式
tests/
├─ source/ piece table、line index、boundary 与随机编辑
├─ streaming/ suffix-only normalizer 与中间态
├─ compiler/ HAST partition 与 ownership
├─ renderer/ deep-freeze、属性转换和 URL 安全
├─ integration/ definition、footnote、Worker、finalize 与 differential
└─ visual/ Playwright 固定视口截图回归
更完整的设计、边界状态和一致性约束见 流式渲染实现方案。
# TypeScript / Vue SFC 类型检查
npm run typecheck
# ESLint
npm run lint
# Vitest + fast-check
npm test
# Playwright 截图回归
npm run test:visual
# 类型检查 + Vite production build
npm run build当前基线:
- 8 个 Vitest 文件,37 个测试;
- 5 个 Chromium 视觉用例;
- 覆盖 append、replacement、footnote、partition、Worker、HTML 安全与 strict finalize;
finalize(stream)与 one-shot strict pipeline 进行规范化语义对比。
首次执行视觉测试前安装 Chromium:
npx playwright install chromium更新确认后的视觉基线:
npm run test:visual:update以下能力尚未包含在当前版本:
- npm package、正式 exports、版本发布与变更日志流程
- 内置 Shiki 代码高亮与 Mermaid 图表
- SSR Worker
- 可传输第三方插件模块协议
- 独立 benchmark suite 与 CI 性能预算
欢迎通过 Issues 提交问题或建议。涉及 parser、boundary、normalizer、renderer 的改动请同时补充对应层测试;涉及 Playground 的视觉调整请更新 Playwright screenshot,并确保以下命令全部通过:
npm run typecheck
npm run lint
npm test
npm run test:visual
npm run build项目没有将三套上游 parser 作为运行时依赖,而是按职责选择性改写扫描、增量维护与 renderer 思路:
- Incremark @
0e20ef4:增量源文本与稳定边界。 - Markstream-vue @
deb2adf:流式 Markdown 中间态。 - XMarkdown @
301781a:HAST 到 Vue VNode 的纯渲染思路。 mdast-util-to-hast@13.2.1:footnote ID、可访问性 label 与 backreference 语义参考。
完整说明见 THIRD_PARTY_NOTICES.md。
