流式回答的状态机只能告诉你这段话在生成、取消还是重连。界面真正会被打崩的,是半截围栏、半截表格和半截标签。下面把它当成增量解析和安全边界来做:未闭合标记先占位、高亮只跑在闭合代码块上,并且把模型输出当不可信 HTML 收口。
状态机解决不了半截 Markdown
聊天页通常已经有一套消息状态:streaming、aborted、retrying。它能驱动光标、停用发送键、在断线后接着拉。但渲染层如果只做 buffer += token,再把整段字符串丢进 Markdown 组件,状态机帮不上忙。
常见错位有三类。
第一类是围栏。模型先打出 ```ts,后面几十个 token 都是代码。解析器看到奇数个围栏,会把后面的解释、下一个标题、甚至下一轮工具调用提示全部收进同一个代码块。围栏闭合的那一瞬间,整页突然「缩回去」,滚动位置也跟着跳。
第二类是表格。GFM 表头和分隔行还没写完时,remark-gfm 不会得到一张稳定的表。列数对不上的那一行,可能被当成普通段落,下一轮又变成 table,单元格来回拆合。
第三类是 HTML。不少模型会夹带 <div>、<br> 或内联 style。半截标签不是合法 HTML,后续文本会被浏览器或消毒器改写,表现为字突然消失、再突然出现。
这三件事的共同点是:当前缓冲区在文法上不完整。按纯文本拼接去「全量渲染」,每一次 token 都在用一份尚未成立的文档生成 DOM。
先切开,再决定什么能进解析器
与其给未闭合围栏补一行假的 ``` 再整篇 parse,不如把缓冲区拆成两段:稳定前缀走 Markdown;未闭合尾巴只当纯文本占位。假闭合会让高亮器和语言探测对半截代码反复分词,颜色会闪。占位则只是等宽文本,语义更老实。
围栏扫描按 CommonMark 的行规则做即可:行首连续至少三个反引号或波浪线打开;闭合行要用同一种字符、长度不少于打开时,且后面不能再跟 info string。不要用全文里 ``` 出现次数的奇偶来判断,缩进代码和四个反引号的围栏会误判。
export type StreamSplit = {
body: string;
pending: { lang: string; code: string } | null;
};
export function splitStreamingMarkdown(source: string): StreamSplit {
const lines = source.split("\n");
let inFence = false;
let fenceChar = "";
let fenceLen = 0;
let openAt = -1;
let lang = "";
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
if (!m) continue;
const marker = m[2];
const info = m[3];
const ch = marker[0];
if (!inFence) {
inFence = true;
fenceChar = ch;
fenceLen = marker.length;
openAt = i;
lang = info.trim().split(/\s+/)[0] ?? "";
continue;
}
const closing =
ch === fenceChar &&
marker.length >= fenceLen &&
info.trim() === "";
if (closing) {
inFence = false;
openAt = -1;
lang = "";
}
}
if (!inFence || openAt < 0) {
return { body: source, pending: null };
}
return {
body: lines.slice(0, openAt).join("\n"),
pending: {
lang,
code: lines.slice(openAt + 1).join("\n"),
},
};
}
表格没有同样干净的「闭合符」。实践中我只对稳定前缀启用 remark-gfm:若最后一行是以 | 开头但列数明显不完整,就把它留在尾巴里,降级成 <pre>。不要发明自动补 |--| 的逻辑,补错列比暂时不成表更难查。
别每个 Token 都全量 parse
需要说清楚边界:标准 remark / react-markdown 没有可用的官方流式 AST。每次送进去的仍是「当前这一份完整字符串」。所谓增量,指的是减少解析次数、缩小送进解析器的文本,而不是在 token 粒度上 patch 语法树。
合并 token 用 requestAnimationFrame 就够。模型输出往往一帧内已经到了若干 token,按帧对齐比固定 setTimeout 更接近绘制节奏。解析结果用 useMemo 包住,components 映射放到模块级或 useMemo,避免父组件一刷新就让 react-markdown 认为整棵自定义组件树变了。
import { useEffect, useState } from "react";
export function useRafValue<T>(value: T): T {
const [shown, setShown] = useState(value);
useEffect(() => {
let id = 0;
id = requestAnimationFrame(() => setShown(value));
return () => cancelAnimationFrame(id);
}, [value]);
return shown;
}
稳定前缀变长时,仍然会整段重新 parse。这通常可以接受:对话气泡里的 Markdown 很少大到需要自己写增量 parser。若单条消息会到几十 KB 的代码,优先做「只把最后一段未闭合代码留在解析器外」,收益比微优化 AST 更直接。
高亮只作用于已闭合的代码块
未闭合代码块不要进 highlight.js。半截 token 上的分词不稳定,关键字颜色会来回跳;个别语言在 ignoreIllegals: false 时还会抛错。闭合之后再用 hljs.highlight,并且指定语言名;语言未知就当纯文本,不要用 highlightAuto 在流式过程中猜——猜测结果本身也会变。
highlight.js 的返回值是 HTML 字符串,这是第二个注入点。代码围栏在 Markdown AST 里是文本,不是 HTML,高亮器只是包了 <span class="hljs-...">。即便如此,也不要直接把模型原文当 HTML 插进 DOM。只允许高亮器那一批标签。
import type { ExtraProps } from "react-markdown";
import DOMPurify from "dompurify";
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
import python from "highlight.js/lib/languages/python";
import typescript from "highlight.js/lib/languages/typescript";
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("js", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("ts", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("py", python);
const HLJS_HOOK = {
ALLOWED_TAGS: ["span"],
ALLOWED_ATTR: ["class"],
} as const;
function highlightClosed(code: string, lang: string): string | null {
if (!lang || !hljs.getLanguage(lang)) return null;
const { value } = hljs.highlight(code, {
language: lang,
ignoreIllegals: true,
});
return DOMPurify.sanitize(value, HLJS_HOOK);
}
export function ClosedCode({
className,
children,
}: ExtraProps & { className?: string; children?: React.ReactNode }) {
const lang = /language-([\w-]+)/.exec(className ?? "")?.[1] ?? "";
const code = String(children ?? "").replace(/\n$/, "");
const html = highlightClosed(code, lang);
if (!html) {
return <code className={className}>{code}</code>;
}
return (
<code
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
按需 registerLanguage,不要默认打进全量语言包。CSS 用 highlight.js 自带的主题即可,例如 import "highlight.js/styles/github.css"。
把切开后的两段拼进 React 时,稳定前缀走 react-markdown,尾巴单独渲染。下面这份可以放到 Vite + React 里直接跑(再写一个按字符 setInterval 的 demo 即可看到围栏不会吞掉后续正文)。
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ClosedCode } from "./ClosedCode";
import { splitStreamingMarkdown } from "./splitStreamingMarkdown";
import { useRafValue } from "./useRafValue";
const remarkPlugins = [remarkGfm];
const components = { code: ClosedCode };
export function StreamingMarkdown({ text }: { text: string }) {
const framed = useRafValue(text);
const { body, pending } = splitStreamingMarkdown(framed);
return (
<div className="md-stream">
{body ? (
<Markdown remarkPlugins={remarkPlugins} components={components}>
{body}
</Markdown>
) : null}
{pending ? (
<pre className="md-stream__pending">
<code data-lang={pending.lang || undefined}>
{pending.code}
<span className="md-stream__caret" />
</code>
</pre>
) : null}
</div>
);
}
注意:react-markdown 默认不会把原文里的 HTML 当 DOM 解析,这是正确起点。不要为了「模型喜欢输出 <br>」去开 rehype-raw,除非同时接上消毒。
消毒器会误伤半截标签
XSS 收口取决于你的管道。
若始终把模型输出当 Markdown,且不加 rehype-raw,原始标签会以文本出现,风险小得多。仍要管链接协议:javascript:、data: 不该变成可点击跳转。react-markdown 的 urlTransform 默认会挡一批危险协议,不要改成恒等函数。
一旦走「Markdown 转 HTML 字符串,再 dangerouslySetInnerHTML」——例如 marked.parse 之后塞进气泡——就必须消毒。DOMPurify.sanitize 内部走 HTML 解析器。流式时缓冲区末尾经常是 <img src="、<span clas 这种未写完的标签。解析器会丢掉不完整节点,或补上它以为缺失的闭合标签。界面上就是:几个字符闪没,等后续 token 到齐又闪回来。
处理方式和围栏一样,先剥不稳定后缀,只消毒前缀;尾巴按文本转义后拼接。
import DOMPurify from "dompurify";
const MD_HTML_HOOK = {
ALLOWED_TAGS: [
"a", "blockquote", "br", "code", "em", "h1", "h2", "h3",
"li", "ol", "p", "pre", "strong", "table", "tbody", "td",
"th", "thead", "tr", "ul",
],
ALLOWED_ATTR: ["href", "title"],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):)/i,
} as const;
export function splitUnstableMarkup(html: string): { prefix: string; tail: string } {
const lt = html.lastIndexOf("<");
const gt = html.lastIndexOf(">");
if (lt > gt) {
return { prefix: html.slice(0, lt), tail: html.slice(lt) };
}
return { prefix: html, tail: "" };
}
export function sanitizeStreamingHtml(dirty: string): string {
const { prefix, tail } = splitUnstableMarkup(dirty);
const safe = DOMPurify.sanitize(prefix, MD_HTML_HOOK);
const escaped = tail
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
return safe + escaped;
}
ALLOWED_URI_REGEXP 按你实际需要收,不要从文档里抄一份过宽的正则再放行。流式过程中也不要开 KEEP_CONTENT 去「保住」半截标签里的文本,再叠加解析器自动补全,结果更难预期。
如果坚持用 rehype-raw,把 rehype-sanitize 放在 raw 之后、高亮之前:先去掉危险节点,再给 code/span 加 class。顺序反了,不是 class 被剥光,就是危险属性活到高亮之后。
总结
消息状态机回答的是传输问题;围栏、表格、标签回答的是文法是否完整。流式渲染层可以很薄:按帧合并 token,把未闭合围栏(以及不完整表行)留在解析器外当占位,高亮只跑闭合代码块,HTML 消毒只作用在稳定前缀上。react-markdown 默认不当 raw HTML 解析,这是安全边界,不要为了观感轻易拆掉。模型输出按不可信内容处理之后,token 不再是往 DOM 上做字符串加法,闪烁和错位会少一截。
评论