解密AI流式输出和网站页面呈现
效果速看
1. SSE简介
- SSE(Server-Sent Events)译为服务器推送事件,通过EventSource接口实现服务器推送通信。与webSocket不同的是,SSE基于http连接,为单向通信,在单向推送场景下(如各大AI网站)得到很好的应用(有关ws和SSE的对比本文不再深入)
- 一个 EventSource 实例会对 HTTP 服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。客户端开启EventSource连接后通过监听特定的事件(如notice,update,meessage)来处理相关的逻辑
2. 原生EventSource不足
信息推送的简单代码示例如下 客户端,通过message事件来监听data类型的信息,然后对信息进行处理
javascript
let count = ref("");
const sse = new EventSource("http://localhost:8080/sse");
sse.addEventListener("message", (e) => {
console.log("---数据", e.data);
count.value += e.data + " ";
//页面渲染
});
服务器端,通过配置请求头Content-Type
为text/event-stream
来开启SSE,另外实时推送需要设置Cache-Control
为no-cahce
停用缓存,连接状态Connection
为keep-alive
。
javascript
const express = require('express')
const cors = require('cors')
const app = express()
// 允许跨域
app.use(cors())
// SSE端点
app.get('/sse', (req, res) => {
// 设置SSE需要的headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
})
// 定时发送消息
let counter = 0
const intervalId = setInterval(() => {
counter++
res.write(`event: message\n`)
res.write(`data: 这是第${counter}次推送\n\n`)
if (counter > 3) {
clearInterval(intervalId);
}
}, 1000)
// 客户端断开连接时清理
req.on('close', () => {
clearInterval(intervalId)
console.log('客户端断开连接')
})
})
// 启动服务器
const PORT = 8080
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
效果如下
通过上面简单的示例代码细心的小伙伴可以发现:不对呀,别人的AI网站是post请求,可以携带参数信息,你的怎么是get请求。
是的,post请求可以携带大量的信息,比如问的问题,理论上get请求也可以通过query参数携带,一般服务器和浏览器会限制你的url字符,但是你想想你问一大堆问题,万一超过了url字符限制怎么办,另外从携带信息便捷性和安全性来说,在这种场景下使用post无疑是最优解,但是我们的EventSoure没有支持post 请求怎么办,于是我们解密用别的思路,也就是本文主角:用fetch发送post请求,使用ReadableStream和mardown实现流式传输和页面渲染
3.fetch创建可读流
3.1 使用ReadableStream
在fetch的response.body中,暴露了一个可读流的reader,调用response.body.getReader
这个方法创建一个reader
同时进行锁定,接着通过reader.read
这个方法就可以读取到流。
javascript
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(data),
}).catch((err) => {
console.log("err报错了", err);
});
// 获取 ReadableStream 并创建读取器
const reader = response.body.getReader();
}
3.2 使用TextDecoder 解码
EventSource发送来的是utf-8
编码的信息,通过reader.read()
得到的value
为字节流,我们还需要使用TextDecoder
解码器器将字节流作为输入,并提供码位流作为输出,这个过程可以称为翻译
javascript
const decoder = new TextDecoder();
// 持续读取流数据
while (true) {
const { done, value } = await reader.read();
if (done) {
reader.releaseLock();
break;
} // 流结束
console.log("字节流", value);
const chunk = decoder.decode(value);
console.log("解码后数据为", chunk);
}
然后我们将解析出来的数据做一些处理拼接就可以
4. markdown页面渲染
在各种AI网站上,我们通常会看到AI回答的内容有列表,有代码块,还有表格等等,难道这是自己手搓生成的嘛?no
,通过观察他们的数据格式 这不是markdown语法吗,通过引入
markdown
库来对返回信息进行解析,再实时更新页面,这就有了页面打字机和丰富的排版效果。
5.实战模拟流式传输和页面呈现(含全部代码)
我们先来看一下最终效果(vue3+node.js ) 怎么样,看着效果图是不是有那味了
5.1 服务器端实现(node.js)
为了简化数据获取和处理流程和简单模拟流式返回markdown语法,我们写死每次返回的内容。简单来说就是每次请求我们都先读取同目录下的b.txt
这个文件,这个文件里面存储了原始markdown语法的文本。然后通过随机slice
文件块大小,和 setTimeout
随机间隔发送读取出来的块给客户端来模拟流式传输效果。需要注意的是,SSE需要注意通信格式:每条信息后面需以\n\n
结束(SSE协议规定的)。
r
res.write(`data: 你好呀\n\n`)
当然,如果你不遵守,服务端和客户端维护一套自定义通信也可以,但是在查看请求时候这里面的EventStream浏览器将识别不到,内容将为空(下图就是将\n\n
换成 \n
所导致) 具体代码如下:
javascript
const express = require('express');
const fs = require('fs');
const app = express();
const PORT = 8080;
const cors = require('cors');
// 允许跨域
app.use(cors())
// 发送文件内容的分块逻辑
function sendFileChunks(res, fileContent) {
let index = 0;
const sendChunk = () => {
if (index >= fileContent.length) {
// res.write('event: end\ndata: \n\n'); // 发送结束事件
res.end();
return;
}
// 随机生成 3-5 的块大小
const chunkSize = 3 + Math.floor(Math.random() * 3);
const chunk = fileContent.slice(index, index + chunkSize);
// SSE 格式要求
res.write(`data: ${JSON.stringify({text:chunk})}\n`);
index += chunkSize;
// 随机间隔发送(模拟实时效果)
setTimeout(sendChunk, 50 + Math.random() * 50);
};
sendChunk();
}
app.post('/sse', (req, res) => {
try {
//SSE需要
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
//读取同同级下的b.txt(里面存放着markdown格式的文档)
const fileContent = fs.readFileSync('./b.txt', 'utf-8');
//分块发送
sendFileChunks(res, fileContent);
// 处理连接关闭
req.on('close', () => {
console.log('Client disconnected');
res.end();
});
} catch (error) {
console.log('event: error\ndata: File not found\n\n');
res.end();
}
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
5.2 前端实现
先要装markdown包将markdown语法转html
npm install marked
通过mark.parse("具体内容")
来实现文本转html。另外我们除了实现fetch创建可读流外还需要实现中断输出效果,这个我们只需要使用AbortController
即可,通过abort()
来中断请求,实现打断输出,具体代码如下:
javascript
<template>
<div>
<button @click="fetchSSE">发送</button>
<button @click="pause" style="color: red; margin-left: 30px">停止</button>
</div>
<div class="markdown-container" v-html="renderedContent"></div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { marked } from "marked";
const rawContent = ref("");
const renderedContent = ref("");
const controller = new AbortController();
// 配置Markdown解析
// 解析Markdown内容
const parseMarkdown = (raw) => {
return marked.parse(raw);
};
async function fetchSSE() {
//请求地址
let url = "http://localhost:8080/sse";
let data = { userId: 123, content: "介绍一下vue3", date: "XXXXX" };
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(data),
signal: controller.signal,
}).catch((err) => {
console.log("err报错了", err);
});
// 获取 ReadableStream 并创建读取器
const reader = response.body.getReader();
//创建解码器
const decoder = new TextDecoder();
// 持续读取流数据
while (true) {
const { done, value } = await reader.read();
if (done) {
reader.releaseLock();
break;
} // 流结束
const chunk = decoder.decode(value);
const events = chunk.split("\n\n"); // SSE 事件以双换行分隔
for (const event of events) {
if (event.trim() === "") continue;
parseSSEEvent(event);
}
}
}
function pause() {
controller.abort();
}
// 解析单个 SSE 事件
function parseSSEEvent(event) {
const lines = event;
let dataObj = JSON.parse(lines.split(": ")[1]);
//dtaObj里面为信息对象{text:XXX}
rawContent.value += dataObj.text;
//解析语法
renderedContent.value = parseMarkdown(rawContent.value);
}
</script>
<style scoped>
.markdown-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
text-align: left;
}
</style>
5.4 b.txt里面markdown内容
markdown
# Vue 3 简介
Vue 3 是 Vue.js 框架的第三代版本,于 2020 年 9 月正式发布。它在多个方面进行了优化和改进,包括内部架构的重构、性能的提升以及新特性的引入。
## 主要特性
### 1. **Composition API**
Vue 3 引入了 Composition API,这是一种新的代码组织方式,允许开发者通过函数组合的方式来构建组件逻辑。它提供了更高的灵活性和代码复用性,特别适合处理复杂组件的逻辑。
### 2. **Fragment 和 Teleport**
- **Fragment**:允许组件返回多个根节点,解决了 Vue 2 中组件必须有一个根节点的限制。
- **Teleport**:可以将组件的内容渲染到父组件之外的 DOM 结构中,非常适合实现模态框、弹窗等场景。
### 3. **性能优化**
Vue 3 在性能上进行了大幅优化,包括更小的体积、更快的渲染速度和更低的内存占用。它还引入了 Proxy API,替代了 Vue 2 中的 `defineProperty`,从而提升了响应式系统的性能。
### 4. **更好的 TypeScript 支持**
Vue 3 对 TypeScript 提供了原生支持,使得开发者可以更方便地结合 TypeScript 使用 Vue,提升代码的可维护性和开发体验。
## 使用场景
Vue 3 适用于各种规模的前端项目,无论是小型的单页面应用还是大型的企业级系统。它的灵活性和高性能使其成为现代前端开发的热门选择。
## 总结
Vue 3 是一个功能强大且性能卓越的前端框架。它通过引入 Composition API、Fragment 和 Teleport 等新特性,极大地提升了开发效率和代码可维护性。同时,它对 TypeScript 的支持也使得开发体验更加流畅。如果你正在寻找一个现代化的前端框架,Vue 3 绝对值得尝试。
总结
本文还有诸多不清楚或者带待改进的地方,希望大家积极提出和共同进步