在Vue中使用HTTP流接收大模型NDJSON数据并安全渲染
在构建现代Web应用时,处理大模型返回的流式数据并安全地渲染到页面是一个常见需求。本文将介绍如何在Vue应用中通过普通HTTP流接收NDJSON格式的大模型响应,使用marked、highlight.js和DOMPurify等库进行安全渲染。
效果预览

技术栈概览
- Vue 3:现代前端框架
- NDJSON (Newline Delimited JSON):大模型常用的流式数据格式
- marked:Markdown解析器
- highlight.js:代码高亮
- DOMPurify:HTML净化,防止XSS攻击
实现步骤
1. 安装依赖
首先安装必要的依赖:
bash
npm install marked highlight.js dompurify
2. 创建流式请求工具函数
创建一个工具函数来处理NDJSON流,我使用axios,但更推荐直接是使用fetch,由于本地部署的大模型,采用的是普通HTTP的流(chunked),目前采用SSE方式的更多:
javascript
// utils/request.js
import axios from "axios"
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 0
});
// 存储所有活动的 AbortController
const activeRequests = new Map();
// 生成唯一请求 ID 的函数
export function generateRequestId(config) {
// 包含请求 URL、方法、参数和数据,确保唯一性
const params = JSON.stringify(config.params || {});
const data = JSON.stringify(config.data || {});
return `${config.url}-${config.method.toLowerCase()}-${params}-${data}`;
}
// 请求拦截器
request.interceptors.request.use((config) => {
const requestId = generateRequestId(config);
// 如果已有相同请求正在进行,则取消前一个
if (activeRequests.has(requestId)) {
activeRequests.get(requestId).abort('取消重复请求');
}
// 创建新的 AbortController 并存储
const controller = new AbortController();
activeRequests.set(requestId, controller);
// 绑定 signal 到请求配置
config.signal = controller.signal;
return config;
});
// 响应拦截器
request.interceptors.response.use((response) => {
const requestId = generateRequestId(response.config);
activeRequests.delete(requestId); // 请求完成,清理控制器
return response;
}, (error) => {
if (axios.isCancel(error)) {
console.log('over');
} else {
// 修正 ElMessage 的使用,正确显示错误信息
ElMessage({
type: 'error',
message: error.message || '请求发生错误'
});
}
// 返回失败的 promise
return Promise.reject(error);
});
/**
* 手动取消请求
* @param {string} requestId 请求 ID
*/
export function cancelRequest(requestId) {
if (activeRequests.has(requestId)) {
activeRequests.get(requestId).abort('用户手动取消');
activeRequests.delete(requestId);
} else {
console.log(`未找到请求 ID: ${requestId},可能已完成或取消`);
}
}
// 导出请求实例
export default request;
通过请求封装,提升模块化能力
javascript
// apis/stream.js
import request, { cancelRequest, generateRequestId } from '@/utils/request.js'
// 全局缓冲不完整的行
let buffer = '';
let currentRequestConfig = null; // 存储当前请求的配置
let lastPosition = 0;
/**
* qwen对话
* @param {*} data 对话数据
*/
export function qwenTalk(data, onProgress) {
const config = {
url: '/api/chat',
method: 'POST',
data,
responseType: 'text'
};
currentRequestConfig = config;
// 重置 buffer
buffer = '';
lastPosition = 0
return request({
...config,
onDownloadProgress: (progressEvent) => {
const responseText = progressEvent.event.target?.responseText || '';
const newText = responseText.slice(lastPosition);
lastPosition = responseText.length;
parseStreamData(newText, onProgress);
},
})
}
/**
* 解析流式 NDJSON 数据
* @param {string} text 原始流文本
* @param {function} onProgress 回调函数,用于处理解析后的 JSON 数据
*/
function parseStreamData(text, onProgress) {
// 将新接收到的文本追加到全局缓冲 buffer 中
buffer += text;
const lines = buffer.split('\n');
// 处理完整的行
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (line) {
try {
const data = JSON.parse(line);
onProgress(data);
} catch (err) {
console.error('JSON 解析失败:', err, '原始数据:', line);
}
}
}
// 保留最后一行作为不完整的部分
buffer = lines[lines.length - 1];
}
/**
* 取消请求
*/
export function cancelQwenTalk() {
if (currentRequestConfig) {
const requestId = generateRequestId(currentRequestConfig);
cancelRequest(requestId);
currentRequestConfig = null;
}
}
3. 创建Markdown渲染工具
配置marked、highlight.js和DOMPurify:
javascript
// utils/markdown.js
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css'; // 选择一个高亮主题
// 配置 marked
marked.setOptions({
langPrefix: 'hljs language-', // 高亮代码块的class前缀
breaks: true,
gfm: true,
highlight: (code, lang) => {
// 如果指定了语言,尝试使用该语言高亮
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.warn(`代码高亮失败 (${lang}):`, e);
}
}
// 否则尝试自动检测语言
try {
return hljs.highlightAuto(code).value;
} catch (e) {
console.warn('自动代码高亮失败:', e);
return code; // 返回原始代码
}
}
});
// 导出渲染函数
export function renderMarkdown(content) {
const html = marked.parse(content);
const sanitizedHtml = DOMPurify.sanitize(html);
// 确保 highlight.js 应用样式
setTimeout(() => {
if (typeof window !== 'undefined') {
document.querySelectorAll('pre code').forEach((block) => {
// 检查是否已经高亮过
if (!block.dataset.highlighted) {
hljs.highlightElement(block);
block.dataset.highlighted = 'true'; // 标记为已高亮
}
});
}
}, 0);
return sanitizedHtml;
}
4. 在Vue组件中使用
创建一个Vue组件来处理流式数据并渲染:
javascript
<template>
<div class="chat-container">
<!-- 对话消息展示区域,添加 ref 属性 -->
<div ref="chatMessagesRef" class="chat-messages">
<div v-for="(message, index) in messages" :key="index" :class="['message', message.type]">
<el-avatar :src="message.avatar" :size="48" class="avatar"></el-avatar>
<div class="markdown-container">
<div class="markdown-content" v-html="message.content"></div>
<div v-if="message.loading" class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="chat-input">
<el-input v-model="inputMessage" type="textarea" :rows="2" placeholder="请输入您的问题..."
@keyup.enter="canSend && sendMessage()"></el-input>
<el-button type="primary" @click="sendMessage" :disabled="!canSend">发送</el-button>
<!-- 添加请求状态图标 -->
<el-icon v-if="currentAIReply" @click="cancelRequest">
<Close />
</el-icon>
<el-icon v-else>
<CircleCheck />
</el-icon>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue';
import { qwenTalk, cancelQwenTalk } from "@/api/aiAgent.js";
import { ElMessage } from 'element-plus';
// 引入图标
import { Close, CircleCheck } from '@element-plus/icons-vue';
import md from '@/utils/markdownRenderer'
import { renderMarkdown } from '@/utils/markedRenderer';
const chatMessagesRef = ref(null);
const messages = ref([
{
type: 'assistant',
content: '您好!有什么我可以帮助您的?',
avatar: 'https://picsum.photos/48/48?random=2'
}
]);
const inputMessage = ref('');
const canSend = computed(() => {
return inputMessage.value.trim().length > 0;
});
const currentAIReply = ref(null);
// 添加请求取消标志位
const isRequestCancelled = ref(false);
const scrollToBottom = () => {
nextTick(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
}
});
};
const sendMessage = () => {
if (!canSend.value) return;
isRequestCancelled.value = false;
messages.value.push({
type: 'user',
content: inputMessage.value,
avatar: 'https://picsum.photos/48/48?random=1'
});
messages.value.push({
type: 'assistant',
content: '',
avatar: 'https://picsum.photos/48/48?random=2',
loading: true
});
const aiMessageIndex = messages.value.length - 1;
currentAIReply.value = {
index: aiMessageIndex,
content: ''
};
scrollToBottom();
let accumulatedContent = '';
qwenTalk({
"model": "qwen2.5:32b",
"messages": [
{
"role": "user",
"content": inputMessage.value,
"currentModel": "qwen2.5:32b"
},
{
"role": "assistant",
"content": "",
"currentModel": "qwen2.5:32b"
}
],
"stream": true,
}, (data) => {
// 如果请求已取消,不再处理后续数据
if (isRequestCancelled.value) return;
if (data.message?.content !== undefined) {
accumulatedContent += data.message.content;
try {
// 实时进行 Markdown 渲染
const renderedContent = renderMarkdown(accumulatedContent);
messages.value[aiMessageIndex].content = renderedContent;
} catch (err) {
console.error('Markdown 渲染失败:', err);
messages.value[aiMessageIndex].content = accumulatedContent;
}
scrollToBottom();
}
if (data.done) {
messages.value[aiMessageIndex].loading = false;
currentAIReply.value = null;
}
})
.catch(error => {
messages.value[aiMessageIndex].loading = false;
currentAIReply.value = null;
scrollToBottom();
});
inputMessage.value = '';
};
const cancelRequest = () => {
if (currentAIReply.value) {
cancelQwenTalk();
const aiMessageIndex = currentAIReply.value.index;
messages.value[aiMessageIndex].loading = false;
currentAIReply.value = null;
ElMessage.warning('请求已取消');
// 设置请求取消标志位
isRequestCancelled.value = true;
scrollToBottom();
}
};
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 80vh;
width: 100%;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.chat-messages {
flex: 1;
/* 消息区域占据剩余空间 */
overflow-y: auto;
/* 内容超出时垂直滚动 */
padding: 20px;
background-color: #ffffff;
}
.message {
display: flex;
margin-bottom: 20px;
align-items: flex-start;
}
.user {
flex-direction: row-reverse;
}
.avatar {
margin: 0 12px;
}
/* 添加基本的 Markdown 样式 */
.markdown-container {
max-width: 70%;
padding: 8px;
border-radius: 8px;
font-size: 16px;
line-height: 1.6;
}
.markdown-container h1,
.markdown-container h2,
.markdown-container h3 {
margin-top: 1em;
margin-bottom: 0.5em;
}
.markdown-container p {
margin-bottom: 1em;
}
.user .markdown-container {
background-color: #409eff;
color: white;
}
.assistant .markdown-container {
background-color: #eeecec;
color: #333;
text-align: left;
}
.chat-input {
display: flex;
gap: 12px;
padding: 20px;
background-color: #ffffff;
border-top: 1px solid #ddd;
}
/* 代码样式---------------| */
.markdown-content {
line-height: 1.6;
}
.markdown-container pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
border-radius: 10px;
}
.markdown-container code {
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Andale Mono', monospace;
font-size: 14px;
line-height: 1.5;
}
.chat-input .el-input {
flex: 1;
/* 输入框占据剩余空间 */
}
/* 添加禁用状态样式------------------- */
.chat-input .el-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-dots {
display: inline-flex;
align-items: center;
height: 1em;
margin-left: 8px;
}
.loading-dots span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #999;
margin: 0 2px;
animation: bounce 1.4s infinite ease-in-out both;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.chat-input .el-icon {
font-size: 24px;
cursor: pointer;
color: #409eff;
}
.chat-input .el-icon:hover {
color: #66b1ff;
}
</style>
高级优化
1. 节流渲染
对于高频更新的流,可以使用节流来优化性能:
javascript
let updateTimeout;
const throttledUpdate = (newContent) => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
this.content = newContent;
}, 100); // 每100毫秒更新一次
};
// 在onData回调中使用
(data) => {
if (data.content) {
throttledUpdate(this.content + data.content);
}
}
2. 自动滚动
保持最新内容可见:
javascript
scrollToBottom() {
this.$nextTick(() => {
const container = this.$el.querySelector('.content');
container.scrollTop = container.scrollHeight;
});
}
// 在适当的时候调用,如onData或onComplete
3. 中断请求
添加中断流的能力,取消请求,详见上篇文章:
javascript
const cancelRequest = () => {
if (currentAIReply.value) {
cancelQwenTalk();
const aiMessageIndex = currentAIReply.value.index;
messages.value[aiMessageIndex].loading = false;
currentAIReply.value = null;
ElMessage.warning('请求已取消');
// 设置请求取消标志位
isRequestCancelled.value = true;
scrollToBottom();
}
};
安全注意事项
- 始终使用DOMPurify:即使你信任数据来源,也要净化HTML
- 内容安全策略(CSP):设置适当的CSP头来进一步保护应用
- 避免直接使用v-html:虽然我们这里使用了,但确保内容已经过净化
- 限制数据大小:对于特别大的流,考虑设置最大长度限制
总结
通过结合Vue的响应式系统、NDJSON流式处理、Markdown渲染和安全净化,我们构建了一个能够高效处理大模型流式响应的解决方案。这种方法特别适合需要实时显示大模型生成内容的场景,如AI聊天、代码生成或内容创作工具。
关键点在于:
- 使用NDJSON格式高效传输流数据
- 正确解析和处理流式响应
- 安全地渲染Markdown内容
- 提供良好的用户体验和性能优化