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 }):多字节字符可能被截断在分块边界,必须用流式解码再拼接(上面循环内每次decode传stream: true,结束时再decoder.decode()一次空 flush,若接口固定 ASCII 可简化,中文场景建议保留 flush 逻辑,下节补全)。signal: ac.signal:与pause()里abort()对应,浏览器会中断fetch与reader.read()。
2.1 解码收尾(避免末尾汉字被拆开)
严谨写法可在 while 结束后执行一次:
// 循环结束后
setText((prev) => prev + decoder.decode());
把 decoder 内部缓冲的尾字节 flush 出来。
3. SSE(text/event-stream)时的解析
若后端是标准 SSE,每行多为 data: {...} 或 data: 文本\n\n。需要在 chunk 上按行缓冲,只把 data: 后的内容拼进 Markdown,并处理服务端心跳 : 行等。
思路:
- 用
let buffer = "",每次chunk接到后buffer += chunk; - 按
\n分割,最后一截可能不完整,留在buffer; - 对完整行解析
data:,若为 JSON 再取choices[0].delta.content一类字段(依实际 API)。
这样 setText 仍然只做字符串追加,Markdown 渲染层不变。
4. Markdown 需单独解析,并防范 XSS
流式阶段只是在拼 Markdown 字符串(变量名仍叫 text 亦可,语义上是 markdownSource)。展示层必须再经过一步「解析」:
| 步骤 | 说明 |
|---|---|
| 1 | 状态里保留 MD 原文(便于复制、重试请求)。 |
| 2 | 用 react-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 备选:marked → DOMPurify.sanitize → dangerouslySetInnerHTML
若团队更习惯先转 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-highlighter或rehype-highlight;未闭合的 ``` 块在流式中途可能短暂无法高亮,可接受。
5. 「暂停」与「重启」语义
5.1 暂停(客户端)
即 AbortController.abort():当前 TCP/HTTP 流结束,已显示的内容保留在 text 状态即可。无需特殊 API。
5.2 重启(再生成)
常见两种产品含义:
| 含义 | 实现 |
|---|---|
| 从头重新生成 | setText("") 后再次 start(),请求体与原问题相同。 |
| 接着后面续写 | 需要后端支持:例如传入 conversation_id、parent_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一处即可联动fetch与read()。 - 重启:本质是新请求;「续写」依赖服务端契约。
- Markdown:流式字符串只存 MD 原文;展示用
react-markdown或marked+DOMPurify,需要内嵌 HTML 时务必rehype-sanitize;可对渲染层做useDeferredValue/ 节流,勿阻断流式追加。
按上述方案,即可在 React 中较稳妥地处理 AI 流式 Markdown(含安全渲染),并清晰实现 暂停与重新发起 两类交互。
