之前的文章里,我写到了关于怎么获取sse中的流,但是缺少逐句展示的效果,这次来补齐。
比如这种,实现难点在于,当返回的markdown语法,不是完整的语句时,展示的代码块会错乱。
实现代码
app.vue
<template><div><input type="text" v-model="prompt"><button @click="fetchStream">第三方请求流数据</button><hr><p v-if="!streamContent && !loading">请开始您的提问</p><div v-for="(block, index) in contentBlocks" :key="index"><div v-if="block.type === 'html'" v-html="block.html"></div><CodeBlockv-else-if="block.type === 'code'":code="block.code":language="block.language"/></div><p v-if="loading">加载中...</p></div>
</template>
<script setup lang="ts">
import {ref, computed} from "vue";
import {marked} from 'marked';
import CodeBlock from "./components/CodeBlock.vue";const loading = ref(false);
const prompt = ref('写一段js快排')
const streamContent = ref('');// 渲染markdown内容
const contentBlocks = computed(() => {const renderer = new marked.Renderer();let blockIndex = 0; // 用于跟踪代码块的索引const blocks = []; // 存储所有块的数组renderer.code = (code, lang) => {const index = blockIndex++; // 获取当前代码块的索引// 将代码块信息存储到 blocks 数组中blocks.push({type: 'code',code: code,language: lang,index: index, // 存储索引,用于后续替换});// 返回一个特殊的占位符,包含当前代码块的索引return `<!--codeblock-${index}-->`;};// 使用自定义的 renderer 解析 Markdownconst html = marked(streamContent.value, {renderer});let codeBlockRegex = /<!--codeblock-(\d+)-->/;// 将解析后的 HTML 分割成块,并存储到 blocks 数组中const list = html.split(/(<!--codeblock-\d+-->)/).map((part, index) => {if (codeBlockRegex.test(part)) {let match = part.match(codeBlockRegex);return blocks[match?.[1]];} else {return {type: 'html',html: part,index: index, // 存储索引,用于后续替换};}});return list;
});document.cookie = `token=Bearer%20eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMTExNCIsImV4cCI6MTcxNjAxNzg1NSwiYml6VHlwZSI6Im1vZGIiLCJyb2xlTmFtZSI6IlJPTEVfbXZwIiwicGVybWlzc2lvbnMiOlsidmlkZW8iLCJjb21wYW55Il19.bQJ9WaT0BuczcW_8HRJoEUpyy_fM42wMoUd8amqOpmgo_PQ5sQoolGtvZIhwBe_W_BbGge5SmHhB677Wf0oH7w; userID=111xxx`async function fetchStream() {if (loading.value) return;loading.value = true;streamContent.value = '';const url = "http://rexxxx";const data = {select_param: "",chat_mode: "chat_normal",model_name: "qwen_proxyllm",user_input: prompt.value || '你好',conv_uid: "xxxx",};try {const response = await fetch(url, {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify(data)});const reader = response.body.getReader();const decoder = new TextDecoder();while (true) {const {done, value} = await reader.read();if (done) break;const textChunk = decoder.decode(value, {stream: true});// 找到最后一个"data:"的位置 并从那里开始截取到字符串结束const lastIndex = textChunk.lastIndexOf('data:');if (lastIndex !== -1) {// 避免重复数据const text = textChunk.substring(lastIndex + 'data:'.length).replace(/\\n/g, '\n');// 判断text的长度是否小于streamContent.value的长度 如果小于则不更新 避免数据错乱导致页面闪动if (text.length >= streamContent.value.length) {console.log(text, "------------------text")streamContent.value = text;}}}} catch (error) {console.error('请求失败', error);} finally {loading.value = false;}
}
</script>
CodeBlock.vue
<template><div class="code-enhance light"><div class="code-enhance-header"><span class="code-enhance-title">{{ language }}</span><span class="code-enhance-copy" @click="copyCode"><span>复制</span></span></div><preclass="code-enhance-content"><code :class="['language-' + language]" v-html="highlightedCode"></code></pre></div>
</template>
<script setup lang="ts">
import {ref, watchEffect} from 'vue';
import hljs from 'highlight.js';// 定义props
const props = defineProps<{code: string;language?: string;
}>();const highlightedCode = ref('');// 使用watchEffect来处理代码高亮
watchEffect(() => {const validLanguage = hljs.getLanguage(props.language);if (validLanguage) {highlightedCode.value = hljs.highlight(props.code, {language: props.language}).value;} else {highlightedCode.value = hljs.highlightAuto(props.code).value;}
});// 定义方法
const copyCode = () => {console.log(props.code);
};
</script>
<style scoped lang="scss">
.code-enhance {width: 100%;display: flex;flex-direction: column;border-radius: 7px;overflow: hidden;.code-enhance-content {width: 100%;overflow: auto;padding: 10px;background-color: #ececee;border-radius: 0 0 6px 6px;code {display: block;overflow: auto;padding: 10px;}pre code.hljs {display: block;overflow-x: auto;white-space: pre; // 保持空白符的处理padding: 0;}}.code-enhance-header {height: 32px;box-sizing: border-box;padding: 0 16px;font-size: 12px;display: flex;justify-content: space-between;align-items: center;gap: 8px;.code-enhance-title {-webkit-user-select: none;user-select: none;}.code-enhance-copy {display: inline-flex;cursor: pointer;align-items: center;gap: 6px;font-size: 12px;word-spacing: -4px;}}&.light {.code-enhance-header {background: #e2e6ea;color: #333;}.hljs {color: #24292e;background: none;}pre code.hljs {display: block;overflow-x: auto;padding: 0;}}
}
</style>
package.json
{"name": "stream-vue-demo","private": true,"version": "0.0.0","type": "module","scripts": {"dev": "vite","build": "vite build","preview": "vite preview"},"dependencies": {"github-markdown-css": "^5.5.1","highlight.js": "^11.9.0","marked": "^4.0.0","sass": "^1.76.0","vue": "^3.4.21"},"devDependencies": {"@types/marked": "^4.0.0","@vitejs/plugin-vue": "^5.0.4","vite": "^5.2.0"}
}
实现思路
这段代码是一个使用Vue.js框架的单文件组件(.vue文件),它由三个主要部分组成:<template>
、<script>
和<style>
。下面我将逐句解释这段代码的意思。
template
部分:
<div><input type="text" v-model="prompt"><button @click="fetchStream">第三方请求流数据</button><hr><p v-if="!streamContent && !loading">请开始您的提问</p><div v-for="(block, index) in contentBlocks" :key="index"><div v-if="block.type === 'html'" v-html="block.html"></div><CodeBlockv-else-if="block.type === 'code'":code="block.code":language="block.language"/></div><p v-if="loading">加载中...</p>
</div>
<input type="text" v-model="prompt">
:一个文本输入框,其值与变量prompt
双向绑定。<button @click="fetchStream">第三方请求流数据</button>
:一个按钮,点击时会触发fetchStream
方法。<hr>
:水平分割线。<p v-if="!streamContent && !loading">请开始您的提问</p>
:当没有流内容且不在加载状态时,显示提示信息“请开始您的提问”。<div v-for="(block, index) in contentBlocks" :key="index">
:遍历contentBlocks
数组,为每个块创建一个div
元素,并使用index
作为唯一键。<div v-if="block.type === 'html'" v-html="block.html"></div>
:如果块的类型是html
,则使用v-html
指令将其内容渲染为HTML。<CodeBlock v-else-if="block.type === 'code'" :code="block.code" :language="block.language"/>
:如果块的类型是code
,则使用自定义组件CodeBlock
来渲染代码块,传递code
和language
作为属性。<p v-if="loading">加载中...</p>
:如果处于加载状态,显示“加载中…”。
script setup lang="ts"
部分:
这部分使用了TypeScript语言。
- 引入Vue的
ref
和computed
函数,以及marked
库和CodeBlock
组件。 - 定义了一些响应式变量:
loading
、prompt
和streamContent
。 - 定义了
contentBlocks
计算属性,用于解析Markdown内容并创建一个内容块数组。 - 设置了一个cookie,其中包含了一个模拟的token和userID。
- 定义了
fetchStream
异步函数,用于发送POST请求到一个URL,并处理流式响应数据。
CodeBlock.vue
部分:
这是一个子组件,用于渲染代码块并提供复制功能。
<template>
部分定义了组件的HTML结构,包括代码标题、复制按钮和代码内容。<script setup lang="ts">
部分定义了组件的逻辑,包括接收code
和language
属性,使用highlight.js
库对代码进行高亮处理,并定义了复制代码的方法。<style scoped lang="scss">
部分定义了组件的样式,使用了SCSS语法,并且是作用域限定的,只影响当前组件。
整体来看,这个Vue组件是一个简单的Markdown编辑器,它可以接收用户输入的Markdown文本,发送到服务器获取解析后的流数据,并将Markdown解析为HTML和代码块来展示。同时,它还包含了一个子组件CodeBlock
用于渲染和复制代码块。