Vue + Axios 处理 AI 流式 Markdown:组合式封装与暂停 / 重启
Vue + Axios 处理 AI 流式 Markdown:组合式封装与暂停 / 重启
在 Vue 3 项目里,业务接口常用 axios 统一 BaseURL、拦截器与错误处理。大模型 流式返回 Markdown 时,浏览器端需要能逐段读取响应体——而 axios 默认基于 XHR 时,往往拿不到 ReadableStream,难以像 fetch 一样做标准的 reader.read() 循环。下面给出一套常见实践:常规请求继续走 axios;流式接口单独用 fetch(或 Node 侧 responseType: 'stream');展示时 Markdown 必须单独解析,并对最终 HTML 做 XSS 消毒(如 DOMPurify)。文中并与 暂停/重启 语义对齐。
1. 为何「流式」常和 axios 拆开
| 环境 | 说明 |
|---|---|
| 浏览器 | XHR 版 axios 对流式响应体支持有限;fetch + ReadableStream 是主流方案。 |
| Node(SSR / 网关) | axios 可设 responseType: 'stream',得到 Node 可读流,再按行解析 SSE。 |
因此本文浏览器示例以 fetch 读流 为主,axios 负责鉴权头、BaseURL 的复用(见 §3)。
2. 目标能力清单
- 增量拼接:流式片段追加到
ref(存 MD 原文);模板用computed:Markdown → HTML → DOMPurify 再v-html,勿长期只用<pre>当正文。 - 暂停:
AbortController.abort(),中止当前请求。 - 重启:中止后再发起新请求;「续写」需后端协议(见 §6)。
- 卸载安全:
onUnmounted里abort(),避免内存泄漏与控制台警告。
3. 复用 axios 的鉴权:流式请求带同一套 Header
项目里通常已有:
// api.ts
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
timeout: 30_000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
流式请求用 fetch 时,不要再手写一遍 token,可从 api.defaults.headers.common 或封装好的 getAuthHeaders() 取:
function getAuthHeaders(): HeadersInit {
const h = api.defaults.headers.common;
const Authorization = h.Authorization ?? h["Authorization"];
return {
"Content-Type": "application/json",
...(Authorization ? { Authorization: String(Authorization) } : {}),
};
}
这样 axios 与流式 fetch 共用一套鉴权逻辑,避免两套 Token。
4. 组合式函数:useAiMarkdownStream(Vue 3 + <script setup>)
下面用 fetch + AbortController 实现流式拼接;暂停即 abort()。
// composables/useAiMarkdownStream.ts
import { onUnmounted, ref, shallowRef } from "vue";
import { api } from "@/api";
export function useAiMarkdownStream() {
const markdown = ref("");
const loading = ref(false);
const error = ref<string | null>(null);
const controller = shallowRef<AbortController | null>(null);
function getAuthHeaders(): HeadersInit {
const h = api.defaults.headers.common;
const Authorization = h.Authorization ?? h["Authorization"];
return {
"Content-Type": "application/json",
...(Authorization ? { Authorization: String(Authorization) } : {}),
};
}
async function start(payload: Record<string, unknown>) {
error.value = null;
markdown.value = "";
loading.value = true;
const ac = new AbortController();
controller.value = ac;
const url = `${api.defaults.baseURL}/ai/chat/stream`;
try {
const res = await fetch(url, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(payload),
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;
markdown.value += decoder.decode(value, { stream: true });
}
markdown.value += decoder.decode();
} catch (e: unknown) {
const err = e as Error;
if (err.name === "AbortError") return;
error.value = err.message;
} finally {
loading.value = false;
controller.value = null;
}
}
function pause() {
controller.value?.abort();
}
onUnmounted(() => {
controller.value?.abort();
});
return { markdown, loading, error, start, pause };
}
4.1 组件中:单独解析 Markdown + 防 XSS
流式数据写入 markdown 的只是 Markdown 源码。展示时需再解析为 HTML,并在写入 DOM 前用 DOMPurify 消毒,避免模型输出中的 <script>、事件属性等导致 XSS。
pnpm add marked dompurify
(dompurify 新版本多自带类型;若 TS 报错再按需安装 @types/dompurify。)
<script setup lang="ts">
import DOMPurify from "dompurify";
import { marked } from "marked";
import { computed } from "vue";
import { useAiMarkdownStream } from "@/composables/useAiMarkdownStream";
const { markdown, loading, error, start, pause } = useAiMarkdownStream();
/** 解析 + 消毒后再 v-html;切勿对未消毒的 HTML 使用 v-html */
const safeHtml = computed(() => {
if (!markdown.value) return "";
const raw = marked.parse(markdown.value, { async: false }) as string;
return DOMPurify.sanitize(raw);
});
function onStart() {
start({ prompt: "用 Markdown 写一段 Vue 简介" });
}
</script>
<template>
<div>
<button type="button" :disabled="loading" @click="onStart">开始</button>
<button type="button" :disabled="!loading" @click="pause">暂停</button>
<p v-if="error">{{ error }}</p>
<article class="markdown-body" v-html="safeHtml" />
<!-- 调试需要时可并列:<pre>{{ markdown }}</pre> -->
</div>
</template>
说明:
marked.parse:把 MD 转为 HTML 字符串;与markdown-it二选一即可,团队统一即可。DOMPurify.sanitize:必须在v-html之前执行;若还需允许部分标签,使用 DOMPurify 配置 白名单,勿默认放开全标签。- 不要用「模板字符串拼 HTML +
v-html」且跳过消毒。 - 若希望完全不经过 HTML 字符串(更安全),可使用
markdown-it+ 自定义 Vue 渲染器把 AST 映射成组件,工程量较大,一般中小项目marked+ DOMPurify 已够用。
5. 与 axios 的「取消」对照
非流式 axios 请求若也要统一取消,可使用 同一 AbortController 或 axios 支持的 signal:
const ac = new AbortController();
await api.get("/user", { signal: ac.signal });
流式段落已用 fetch + signal,暂停语义一致:都是 abort 当前 HTTP。
6. 暂停与重启(产品语义)
| 操作 | 实现 |
|---|---|
| 暂停 | pause() → abort(),已生成内容保留在 markdown。 |
| 重新生成 | markdown 清空后再次 start(同 payload)。 |
| 续写 | 需后端支持(会话 id、上下文、或 continue_from);不能在 abort 后恢复同一条 TCP 流。 |
7. SSE 场景:缓冲行再解析
若接口为 SSE(text/event-stream),应在 chunk 上按行缓冲,解析 data: 行后再拼进 markdown,逻辑与 《React + Fetch 流式 Markdown》 一致,只是把 setText 换成 markdown.value +=。
8. Node / 服务端:axios 的 responseType: 'stream'
在 Node 环境(如自建 BFF 转发流),可使用:
import axios from "axios";
const res = await axios.get("https://example.com/api/stream", {
responseType: "stream",
headers: { Authorization: token },
});
res.data.on("data", (chunk: Buffer) => {
/* 按行切 SSE 或按协议解析 */
});
这与浏览器 Vue 页面是两条线,不要把 Node 流直接塞给浏览器 axios。
9. 小结
- Vue 里用
ref+ 组合式函数 管理流式 Markdown 与AbortController生命周期。 - axios 在浏览器端适合继续服务 JSON API;流式响应用
fetch+ 复用 axios 的鉴权头 是常见折中。 - Markdown 与 展示分离:
markdown存原文,computed内marked(或 markdown-it)+DOMPurify.sanitize再v-html。 - 暂停 =
abort;重启 = 新请求 + 业务约定是否续写。
按上述方式,可在统一 axios 生态的同时,稳定处理 AI 流式 Markdown(含安全渲染)与 暂停/重启 交互。
