React + Fetch 流式返回 Markdown:AI 数据组装与暂停 / 重启

React + Fetch 流式返回 Markdown:AI 数据组装与暂停 / 重启

大模型接口常见两种形态:一次性 JSON流式文本(SSE 或 chunked text/event-stream / 裸文本分块)。前端用 fetch + ReadableStream 边下边拼,得到的是 Markdown 源码字符串展示时务必单独走「Markdown → 安全 DOM」,不要长期只用 <pre> 当正文,否则既无排版,也易在「自行拼 HTML」时引入 XSS。本文说明在 React 里如何增量更新、如何 react-markdown / 消毒后 HTML、如何用 AbortController 暂停,以及重启时在客户端与接口侧分别要满足什么条件。

1. 目标与名词

目标做法概要
流式拼接response.body.getReader() 循环 read(),把解码后的片段拼到状态字符串。
Markdown 展示状态里存 MD 原文;界面用 react-markdown / 消毒后的 HTML 单独渲染,与流式拼接解耦。
暂停调用 abortController.abort(),终止当前 fetch,不再接收后续分片。
重启停掉旧连接后再发起新请求;若要与「暂停前已生成内容」衔接,需业务约定(见 §5)。

2. 最小可读示例:fetch 读流并写入 React 状态

下面假设后端返回 UTF-8 纯文本分块(许多网关会把 SSE 的 data: 行解析后只把正文转发给前端,按你司接口实际格式调整解析逻辑)。

import { useCallback, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

export function StreamMarkdownPanel({
  url,
  body,
}: {
  url: string;
  body: Record<string, unknown>;
}) {
  const [text, setText] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  const start = useCallback(async () => {
    setError(null);
    setText("");
    setLoading(true);

    const ac = new AbortController();
    abortRef.current = ac;

    try {
      const res = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
        signal: ac.signal,
      });

      if (!res.ok || !res.body) {
        throw new Error(`HTTP ${res.status}`);
      }

      const reader = res.body.getReader();
      const decoder = new TextDecoder("utf-8");

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, { stream: true });
        setText((prev) => prev + chunk);
      }
    } catch (e: unknown) {
      if ((e as Error).name === "AbortError") {
        // 用户主动暂停:不当作错误展示,或单独提示「已停止」
        return;
      }
      setError((e as Error).message);
    } finally {
      setLoading(false);
      abortRef.current = null;
    }
  }, [url, body]);

  const pause = useCallback(() => {
    abortRef.current?.abort();
  }, []);

  return (
    <div>
      <button type="button" onClick={start} disabled={loading}>
        {loading ? "生成中…" : "开始"}
      </button>
      <button type="button" onClick={pause} disabled={!loading}>
        暂停
      </button>
      {/* 正文见 §4:用 react-markdown 渲染 Markdown,勿长期仅用 pre 展示 */}
      <article className="markdown-body">
        <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
      </article>
    </div>
  );
}

依赖:npm i react-markdown remark-gfm。若仍希望调试时看原文,可并列 <details><summary>源码</summary><pre>…</pre></details>用户面向的仍是解析后的 Markdown

要点:

  • TextDecoder(..., { stream: true }):多字节字符可能被截断在分块边界,必须用流式解码再拼接(上面循环内每次 decodestream: true,结束时再 decoder.decode() 一次空 flush,若接口固定 ASCII 可简化,中文场景建议保留 flush 逻辑,下节补全)。
  • signal: ac.signal:与 pause()abort() 对应,浏览器会中断 fetchreader.read()

2.1 解码收尾(避免末尾汉字被拆开)

严谨写法可在 while 结束后执行一次:

// 循环结束后
setText((prev) => prev + decoder.decode());

把 decoder 内部缓冲的尾字节 flush 出来。


3. SSE(text/event-stream)时的解析

若后端是标准 SSE,每行多为 data: {...}data: 文本\n\n。需要在 chunk 上按行缓冲,只把 data: 后的内容拼进 Markdown,并处理服务端心跳 : 行等。

思路:

  1. let buffer = "",每次 chunk 接到后 buffer += chunk
  2. \n 分割,最后一截可能不完整,留在 buffer
  3. 对完整行解析 data:,若为 JSON 再取 choices[0].delta.content 一类字段(依实际 API)。

这样 setText 仍然只做字符串追加,Markdown 渲染层不变。


4. Markdown 需单独解析,并防范 XSS

流式阶段只是在拼 Markdown 字符串(变量名仍叫 text 亦可,语义上是 markdownSource)。展示层必须再经过一步「解析」:

步骤说明
1状态里保留 MD 原文(便于复制、重试请求)。
2react-markdown(或「先 marked 再消毒」)生成 React 可渲染的节点 / 安全 HTML。
3禁止把模型输出直接 dangerouslySetInnerHTML 而不消毒——模型可能被诱导输出 <script>、事件属性等。

4.1 推荐:react-markdown + remark-gfm

  • 默认会把 Markdown 里的 HTML 当作文本处理,不执行内嵌 HTML(行为以版本为准,升级时请看变更说明)。
  • 若你显式开启了能解析 HTML 的插件(如 rehype-raw),则必须配套 rehype-sanitize,只保留安全标签/属性。
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

// 仅在确需渲染 MD 内 HTML 时使用,并必须 sanitize
<ReactMarkdown
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
>
  {text}
</ReactMarkdown>

4.2 备选:markedDOMPurify.sanitizedangerouslySetInnerHTML

若团队更习惯先转 HTML 字符串:

import DOMPurify from "dompurify";
import { marked } from "marked";

const html = DOMPurify.sanitize(marked.parse(text) as string);
// return <div dangerouslySetInnerHTML={{ __html: html }} />;

务必使用 DOMPurify(或同类库)再写入 DOM;marked 单独使用不足以防 XSS。

4.3 性能与体验

  • 流式过程中 react-markdown 会随 text 变长反复解析,属正常;超长内容可对渲染结果useDeferredValue(text) 或节流,减轻主线程压力(不要对原始流式追加做防抖,否则打字机卡顿)。
  • 代码高亮:可配合 react-syntax-highlighterrehype-highlight;未闭合的 ``` 块在流式中途可能短暂无法高亮,可接受。

5. 「暂停」与「重启」语义

5.1 暂停(客户端)

AbortController.abort():当前 TCP/HTTP 流结束,已显示的内容保留在 text 状态即可。无需特殊 API。

5.2 重启(再生成)

常见两种产品含义:

含义实现
从头重新生成setText("") 后再次 start(),请求体与原问题相同。
接着后面续写需要后端支持:例如传入 conversation_idparent_message_id,或把已生成部分作为上下文再请求「续写」。纯前端无法从已 abort 的连接里「续传」同一条 HTTP 流。

若接口提供 Resume,一般是新请求带 cursor / offset,而不是恢复旧 fetch

5.3 按钮上的「暂停 / 继续」

  • 继续 = 同一条流:HTTP 层不能在 abort 后继续同一条连接,只能新请求
  • 产品文案建议:「停止」(abort)+ 「重新生成」「续写」(新请求 + 后端协议),避免用户以为能无缝续同一条 TCP 流。

6. 错误与竞态

  • 快速连点「开始」:应用 loading 禁用按钮,或在新 start 前对上一次 abort(),避免多个 reader 同时写同一状态。
  • setText 闭包:用函数式更新 setText((p) => p + chunk),避免陈旧状态。
  • 组件卸载:在 useEffect 清理里 abort(),防止卸载后仍 setState
useEffect(() => {
  return () => abortRef.current?.abort();
}, []);

7. 性能与体验小结

  • 解码TextDecoder + stream: true + 最后 flush
  • 暂停AbortController 一处即可联动 fetchread()
  • 重启:本质是新请求;「续写」依赖服务端契约
  • Markdown:流式字符串只存 MD 原文;展示用 react-markdownmarked + DOMPurify,需要内嵌 HTML 时务必 rehype-sanitize;可对渲染层做 useDeferredValue / 节流,勿阻断流式追加。

按上述方案,即可在 React 中较稳妥地处理 AI 流式 Markdown(含安全渲染),并清晰实现 暂停与重新发起 两类交互。

Last Updated 4/10/2026, 3:36:26 AM