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 → DOMPurifyv-html,勿长期只用 <pre> 当正文。
  • 暂停AbortController.abort(),中止当前请求。
  • 重启:中止后再发起新请求;「续写」需后端协议(见 §6)。
  • 卸载安全onUnmountedabort(),避免内存泄漏与控制台警告。

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 配置open in new window 白名单,勿默认放开全标签。
  • 不要用「模板字符串拼 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 场景:缓冲行再解析

若接口为 SSEtext/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 存原文,computedmarked(或 markdown-it)+ DOMPurify.sanitizev-html
  • 暂停 = abort重启 = 新请求 + 业务约定是否续写。

按上述方式,可在统一 axios 生态的同时,稳定处理 AI 流式 Markdown(含安全渲染)与 暂停/重启 交互。

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