Vue 3 入门实战:从零搭建大模型 Stream 流式响应小项目

Vue 3 入门实战:从零搭建大模型 Stream 流式响应小项目
Vue 3 入门实战从零搭建大模型 Stream 流式响应小项目前言1. Vue 与 Stream 基础1.1 Vue 单文件组件1.2 ref、数据绑定与 v-model1.3 非流式与流式响应2. 初始化 Vue 项目2.1 使用 npm init vite 创建项目2.2 初始化文件分别有什么用3. 连接 index.html、main.js 与全局样式3.1 index.html 提供挂载点3.2 main.js 创建 Vue 应用3.3 style.css 清除默认间距4. 编写 App.vue 页面4.1 先写 template 页面结构4.2 声明 question、stream 与 content4.3 添加 App.vue 中的组件样式5. 配置 API 并实现非流式请求5.1 创建 .env.local5.2 使用 fetch 发送请求5.3 解析非流式回答6. 认识流式响应的读取过程6.1 response.body 与 getReader6.2 TextDecoder、done 与 buffer6.3 本阶段能够看到什么7. 当前阶段完整 App.vue 与流程复盘7.1 完整 App.vue7.2 文件与业务流程复盘总结前言大模型生成较长回答时通常需要等待几秒。如果前端等服务端生成全部内容后再一次性显示用户会经历一段没有反馈的空白时间如果服务端生成一部分就发送一部分前端也同步读取并展示页面就会呈现类似打字机的效果。这个小项目的重点不是搭建一个功能复杂的聊天系统而是认识 Vue 3 页面如何与大模型接口连接并初步理解浏览器怎样读取流式响应。项目会涉及 Vue 单文件组件、ref、v-model、fetch、ReadableStream、getReader()和TextDecoder等基础知识。文章将先讲清 Vue 与 Stream 的概念再使用npm init vite创建项目随后按照index.html → main.js → App.vue的加载顺序逐步编写代码。非流式模式会把完整回答显示到页面流式模式则先完成“获取响应流、循环读取二进制数据、解码为文本”的入门练习。本项目处于流式响应的入门阶段重点是看懂浏览器怎样拿到并读取数据流不继续扩展完整 SSE 解析、多轮对话、停止生成等进阶功能。1. Vue 与 Stream 基础1.1 Vue 单文件组件Vue 使用组件组织页面。一个.vue文件就是一个组件通常包含三个部分template !-- 页面结构 -- /template script setup // 页面状态与业务逻辑 /script style /* 当前组件使用的样式 */ /style三部分的职责如下区域作用template编写输入框、按钮等页面结构script setup声明数据、编写点击事件和网络请求style设置组件的尺寸、颜色等样式组件化把一组相关的 HTML、JavaScript 和 CSS 放到同一个业务单元中。页面规模变大后还可以继续把根组件拆成输入组件、消息组件等当前小项目只有一个简单页面主要代码写在App.vue中即可。1.2 ref、数据绑定与 v-model普通变量发生变化时Vue 不一定知道页面需要更新。ref()用于创建响应式状态import{ref}fromvueconstquestionref(讲一个关于中国龙的故事)conststreamref(false)constcontentref()这三个状态分别保存question用户输入的问题。stream是否开启流式响应。content模型返回的内容。在 JavaScript 中访问ref需要使用.valuequestion.value请介绍一下 Vuecontent.value思考中……在模板中Vue 会自动解包ref所以直接写变量名input v-modelquestion typetext / div{{ content }}/divv-model用于双向绑定。用户修改输入框时question会同步变化代码修改question.value时输入框也会同步更新。{{ content }}是插值表达式只要content.value改变页面内容就会自动刷新不需要手动操作 DOM。1.3 非流式与流式响应非流式请求会等待模型生成完整回答然后返回一个完整 JSON。前端可以直接调用constdataawaitresponse.json()流式请求不会等待所有内容生成完毕而是让响应体保持打开。浏览器通过response.body获得可读流再一批一批地读取数据constreaderresponse.body.getReader()const{value,done}awaitreader.read()对比项非流式流式请求参数stream: falsestream: true返回方式完整生成后一次返回生成一部分就发送一部分读取方式response.json()response.body.getReader()当前项目结果页面显示完整回答控制台观察解码后的原始流文本模型生成的 token 和浏览器每次读取到的数据块并不一定一一对应。reader.read()得到的是网络传输的一批二进制字节。2. 初始化 Vue 项目2.1 使用 npm init vite 创建项目在准备存放项目的目录中执行npminit vite进入交互选择后依次设置项目名称、框架和开发语言Project name: stream-demo Select a framework: Vue Select a variant: JavaScript进入项目目录并安装依赖cdstream-demonpminstall最后启动开发服务器npmrun devnpm run dev的作用是执行package.json中的vite命令启动一个本地 HTTP 开发服务器。终端通常会显示类似地址http://localhost:5173/如果5173已被占用Vite 会自动选择其他端口因此应以终端实际显示的地址为准。2.2 初始化文件分别有什么用npm init vite和npm install会生成多个文件。它们的作用不同并不需要逐行讲解所有脚手架内容。文件或目录作用是否直接影响当前页面index.html浏览器入口提供 Vue 挂载点是src/main.js创建 Vue 应用并加载根组件是src/App.vue当前问答页面的结构与逻辑是src/style.css全局样式是被main.js导入.env.local保存本地 API Key是代码会读取vite.config.js注册 Vue 编译插件是package.json保存依赖和 npm 脚本是package-lock.json锁定 npm 依赖版本间接影响node_modules存放已安装依赖间接影响HelloWorld.vueVite 示例组件否当前入口没有引用hero.png、Vue/Vite Logo示例组件使用的图片否public/favicon.svg浏览器标签页图标是public/icons.svg脚手架示例图标否本项目使用 npm所以生成的是package-lock.json。只有改用 pnpm 安装依赖时才会生成pnpm-lock.yaml。3. 连接 index.html、main.js 与全局样式3.1 index.html 提供挂载点浏览器访问开发服务器时首先加载项目根目录的index.html!doctypehtmlhtmllangenheadmetacharsetUTF-8/linkrelicontypeimage/svgxmlhref/favicon.svg/metanameviewportcontentwidthdevice-width, initial-scale1.0/titlestream-demo/title/headbody!-- Vue 应用挂载点 --dividapp/div!-- 加载 JavaScript 入口 --scripttypemodulesrc/src/main.js/script/body/htmldiv idapp/div本身没有页面内容它只是为 Vue 准备一个挂载位置。typemodule允许main.js使用import语法。3.2 main.js 创建 Vue 应用src/main.js负责把全局样式和根组件连接起来import{createApp}fromvueimport./style.cssimportAppfrom./App.vue// 把 App 组件挂载到 index.html 的 #appcreateApp(App).mount(#app)代码执行顺序可以理解为加载 Vue 的 createApp ↓ 加载全局 style.css ↓ 加载根组件 App.vue ↓ 把 App 渲染到 #appmount(#app)必须与index.html中的idapp对应。如果两边名称不同Vue 就找不到挂载位置。3.3 style.css 清除默认间距src/style.css中只有一条全局规则*{margin:0;padding:0;}*是通配选择器表示选中页面中的所有元素。浏览器会为body、标题等元素设置默认间距这段代码把默认margin和padding清零方便后续自己控制布局。这份样式之所以生效是因为main.js中执行了import./style.css到这里完成的是项目入口和全局样式还没有开始编写具体的问答页面。4. 编写 App.vue 页面4.1 先写 template 页面结构打开src/App.vue先编写模板template div classcontainer div label输入/label input v-modelquestion classinput typetext / button clickupdate提交/button /div div classoutput labelStreaming/label input v-modelstream typecheckbox / div v-ifstream labelStreaming/label /div div{{ content }}/div /div /div /template这一部分涉及四个 Vue 模板语法v-modelquestion输入框与问题状态双向绑定。clickupdate点击按钮时调用update。v-modelstream复选框与布尔状态双向绑定。v-ifstream只有stream为true时才渲染内部标签。最后的{{ content }}用于显示模型回答。此时状态和函数还没有声明下一步补充script setup。4.2 声明 question、stream 与 content在模板下方添加script setup import { ref } from vue const question ref(讲一个关于中国龙的故事) const stream ref(false) const content ref() const update async () { if (!question.value) return content.value 思考中…… } /script先不要急着请求接口。保存后点击提交如果页面出现“思考中……”说明模板事件、ref状态和插值渲染已经连接成功。这一步的判断逻辑也很简单if(!question.value)return如果输入框为空函数直接结束不发送无意义请求。4.3 添加 App.vue 中的组件样式继续在App.vue底部添加style .container { height: 100px; width: 100px; background-color: pink; } /style这段样式把.container设置为100px × 100px背景颜色为粉色。保存后页面左上角会出现粉色区域输入框、按钮和 Streaming 开关也会显示出来。图 1完成模板、状态与组件样式后的页面右侧 Vue Devtools 可以看到content、question和stream都是Ref。修改输入框或勾选复选框时对应状态会同步变化。5. 配置 API 并实现非流式请求5.1 创建 .env.local在stream-demo根目录创建.env.local它应与package.json位于同一级VITE_DEEPSEEK_API_KEYsk-请替换为自己的密钥代码通过下面的方式读取import.meta.env.VITE_DEEPSEEK_API_KEYVite 只会把以VITE_开头的环境变量提供给前端代码。创建或修改.env.local后需要重新启动npm run dev。.env.local被.gitignore忽略只能避免密钥被提交到 Git。以VITE_开头的变量仍会进入浏览器代码因此这种写法只适合本地学习正式项目应通过后端保存密钥。5.2 使用 fetch 发送请求在update中准备接口地址和请求头constendpointhttps://api.deepseek.com/chat/completionsconstheaders{Content-Type:application/json,Authorization:Bearer import.meta.env.VITE_DEEPSEEK_API_KEY}随后使用fetch发起 POST 请求constresponseawaitfetch(endpoint,{method:POST,headers,body:JSON.stringify({model:deepseek-v4-flash,messages:[{role:user,content:question.value}],stream:stream.value})})各字段作用如下字段作用method: POST使用请求体提交数据Content-Type告诉服务端请求体是 JSONAuthorization携带 Bearer Tokenmodel指定模型messages保存本次对话消息stream决定是否启用流式返回POST 本身并不代表加密真正保护传输内容的是 HTTPS。模型名称也必须是当前账号可用的标识如果接口返回模型不存在应替换为控制台中实际支持的模型名。5.3 解析非流式回答当 Streaming 没有勾选时stream.value为false。接口生成完整回答后使用response.json()解析if(!stream.value){constdataawaitresponse.json()content.valuedata.choices[0].message.content}完整过程是输入问题 → 点击提交 → fetch 发送请求 → 等待完整 JSON → 读取 message.content → 修改 content.value → Vue 更新页面到这里非流式模式已经完成。6. 认识流式响应的读取过程6.1 response.body 与 getReader勾选 Streaming 后接口返回流式响应。首先清空“思考中……”并获得读取器content.valueconstreaderresponse.body?.getReader()if(!reader){content.value当前浏览器无法读取响应流return}response.body是一个ReadableStream。?.是可选链如果body不存在不会立刻报错而是得到undefined。随后通过if (!reader)做一次判断避免继续调用不存在的读取器。6.2 TextDecoder、done 与 buffer读取器得到的是二进制字节需要先创建文本解码器constdecodernewTextDecoder()letdonefalseletbuffer三个变量分别表示变量作用decoder把二进制字节转换成文本done记录响应流是否已经读取结束buffer保存已经解码的文本接着循环读取while(!done){const{value,done:doneReading}awaitreader.read()// 必须更新外层 done否则循环不会结束donedoneReadingif(value){bufferdecoder.decode(value,{stream:true})}}// 取出解码器内部可能保留的最后几个字节bufferdecoder.decode()// 输出当前阶段读取到的原始流文本console.log(buffer)reader.read()返回value和done。因为外层已经声明了done解构时把返回值重命名为doneReading再执行done doneReading。这一行是必要的donedoneReading如果只读取doneReading却不更新外层donewhile (!done)的条件会一直为真循环无法正常结束。value通常是Uint8Array。decoder.decode(value, { stream: true })将其解码为文本并追加到buffer。设置stream: true可以让解码器正确处理跨数据块的多字节字符。循环结束后的decoder.decode()不再传入新数据它用于取出解码器内部可能保留的最后几个字节。6.3 本阶段能够看到什么完成上述代码后关闭 Streaming页面会显示完整模型回答。开启 Streaming浏览器会读取完整响应流并在控制台打印解码后的原始文本。原始文本中可能包含data:、JSON 内容和[DONE]等协议数据。当前阶段还没有把原始 SSE 文本进一步解析成delta.content因此流式模式不会像成熟聊天应用一样把回答逐段追加到页面。这不是 Vue 响应式失效而是项目目前只完成了“读取和解码流”的基础步骤。学习到这里应该先确保能够看懂response.body、reader.read()、Uint8Array、TextDecoder、done和buffer。把 SSE 事件拆成 JSON 并提取增量文本属于下一阶段内容。7. 当前阶段完整 App.vue 与流程复盘7.1 完整 App.vue将前面的代码合并后App.vue如下template!-- 问答组件的最外层容器 --divclasscontainerdivlabel输入/label!-- v-model 将输入框内容同步到 question --inputv-modelquestionclassinputtypetext/!-- 点击按钮后调用 update 函数 --buttonclickupdate提交/button/divdivclassoutputlabelStreaming/label!-- 复选框控制是否请求流式响应 --inputv-modelstreamtypecheckbox/!-- 只有 stream 为 true 时才渲染 --divv-ifstreamlabelStreaming/label/div!-- 展示等待提示或模型回答 --div{{ content }}/div/div/div/templatescriptsetupimport{ref}fromvue// 用户输入的问题constquestionref(讲一个关于中国龙的故事)// false 表示非流式true 表示流式conststreamref(false)// 保存页面需要展示的模型内容constcontentref()// 点击提交按钮后执行异步请求constupdateasync(){// 输入为空时直接结束不发送请求if(!question.value)return// 请求返回前先给用户一个等待提示content.value思考中……// Chat Completions 接口地址constendpointhttps://api.deepseek.com/chat/completions// 请求体使用 JSON并从 .env.local 读取 API Keyconstheaders{Content-Type:application/json,Authorization:Bearer import.meta.env.VITE_DEEPSEEK_API_KEY}// 向大模型接口提交问题和流式开关constresponseawaitfetch(endpoint,{method:POST,headers,body:JSON.stringify({// 模型名必须是当前账号实际可用的标识model:deepseek-v4-flash,messages:[{role:user,content:question.value}],// 将复选框状态传递给模型接口stream:stream.value})})// 勾选 Streaming 时进入流式读取分支if(stream.value){// 清除“思考中……”提示准备接收流数据content.value// 从响应体中获取可读流的读取器constreaderresponse.body?.getReader()// response.body 不存在时无法继续读取if(!reader){content.value当前浏览器无法读取响应流return}// TextDecoder 负责把二进制数据解码成文本constdecodernewTextDecoder()// done 记录流是否结束buffer 累积解码后的原始文本letdonefalseletbuffer// 不断读取直到服务端关闭响应流while(!done){const{value,done:doneReading}awaitreader.read()// 更新外层状态保证流结束后能够退出循环donedoneReadingif(value){// value 是 Uint8Array解码后追加到 bufferbufferdecoder.decode(value,{stream:true})}}// 取出解码器内部可能残留的最后几个字节bufferdecoder.decode()// 当前阶段先在控制台观察原始流文本console.log(buffer)}else{// 非流式响应可以直接解析完整 JSONconstdataawaitresponse.json()// 取出完整回答修改响应式状态并更新页面content.valuedata.choices[0].message.content}}/scriptstyle/* 设置当前问答组件的尺寸和背景色 */.container{height:100px;width:100px;background-color:pink;}/style完整代码只在流读取循环中做了一个必要修正把doneReading赋值给外层done保证循环能够在响应结束后退出。除此之外没有增加新的页面状态、错误组件或 SSE 解析函数。7.2 文件与业务流程复盘顺序文件或代码本阶段作用1index.html提供#app和main.js入口2main.js加载样式、根组件并挂载 Vue3style.css清除所有元素的默认间距4App.vue template创建输入框、按钮、开关和输出区域5ref与v-model建立页面状态和双向绑定6.env.local为本地请求提供 API Key7fetch向大模型接口提交问题8Aresponse.json()处理非流式完整回答8BgetReader()读取流式二进制数据9TextDecoder把二进制流转换为原始文本常见问题可以按下面的顺序排查现象可能原因页面空白#app与mount(#app)不对应环境变量为undefined.env.local位置或VITE_前缀错误修改密钥后仍无效没有重新执行npm run dev返回 401API Key 缺失或无效返回 400模型名或请求体不符合接口要求浏览器提示 CORS接口不允许当前网页跨域访问流式循环不结束没有执行done doneReading控制台是 JSON 和data:当前只完成原始流读取尚未解析 SSE总结这个入门项目首先通过npm init vite创建 Vue 工程再由index.html加载main.jsmain.js将App.vue挂载到#app同时引入style.css。App.vue使用ref保存问题、流式开关和回答内容通过v-model完成表单绑定再使用fetch调用大模型接口。非流式分支用response.json()获取完整回答流式分支则通过ReadableStream、getReader()和TextDecoder完成原始数据流的读取与解码。掌握这些基础后再继续学习 SSE 解析会更容易理解。