流式回答的状态机只能告诉你这段话在生成、取消还是重连。界面真正会被打崩的,是半截围栏、半截表格和半截标签。下面把它当成增量解析和安全边界来做:未闭合标记先占位、高亮只跑在闭合代码块上,并且把模型输出当不可信 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
  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 上做字符串加法,闪烁和错位会少一截。