资讯动态

V1封装实战:SSE流式接口与axios二次封装的工程总结

发布时间:2026/9/27 23:25:58 来源:尧图企业网站定制
1. 项目概述V1 封装到底在做什么前段时间把一个 V1 项目收尾名字就叫“封装与总结”。立项时其实有点含糊是把代码抽象一层还是把硬件封装库整理一遍后来发现两个方向都有道理因为无论是软件里的接口封装、组件封装还是硬件里的芯片封装、PCB封装库底层逻辑惊人一致——把内部复杂度关起来对外只暴露清晰、稳定的接口。这个项目正好就是把一个 AI 交互模块从“能用”走向“好用”的过程顺带把散落在各个页面里的请求逻辑、渲染逻辑和组件逻辑全部收敛起来。这个 V1 项目适合谁参考如果你是前端或者全栈开发者正在做 AI 流式对话、H5 多域名适配、接口请求层重构那里面 SSE 封装和 abort 中断部分的代码可以直接落地如果你平时接触硬件设计碰到 emmc 封装引脚、SOP20W 封装尺寸这些词也能在“封装库管理”和“焊盘编号”的思路上找到共通点。项目解决的问题很实际大模型回答需要实时渲染但普通 fetch 拿不到增量WebSocket 又太重于是用 SSE 流式输出同时用户随时可能中断提问必须把请求取消、界面清理、资源释放一次性做干净。V1 如果不做这层封装后续接新模型、新域名、新端都会非常痛苦。1.1 “封装”两个字藏着的真实需求刚开始我看到热搜词里一堆“封装”第一反应是大家想的不是一件事。软件那边在搜 axios 二次封装、uniapp 封装 H5 指向两个域名、接口封装硬件那边在搜 0603 封装尺寸、0805 封装尺寸、芯片封装设计。但本质上是同一个问题边界在哪引脚怎么定义外部怎么对接。软件封装里我最常听到的说法是“不要让业务代码直接碰细节”。比如请求层如果每个页面都自己写 fetch一旦接口地址切换、超时时间调整、错误码改格式就要全局搜索逐个替换。封装之后变更被限制在一个文件里。硬件也一样一个封装库如果焊盘尺寸不对、引脚编号乱PCB 打样回来就是废板。所以这个项目的总结阶段我给自己定了一条规则所有封装必须回答三个问题——对外提供什么内部隐藏什么未来哪些地方最容易变。1.2 这次 V1 封装主要做了什么整个项目分成三层来做。第一层是请求层封装统一处理 baseURL、超时、token、错误码以及 AI 对话专用的 SSE 流式请求带 abort 控制。第二层是组件层封装把弹窗、列表、表单这类高频组件做成了一个最小可用版本不需要引入重型 UI 库也能满足 V1。第三层是工具函数层把流式解析、文本增量渲染、消息历史管理、域名配置这些散落逻辑抽成独立模块。做完之后最大的感受是封装不是越抽象越好而是要让“变化的成本”和“理解的成本”都降下来。比如 H5 需要指向两个域名一个主域名一个容灾域名我直接在封装层做了配置化处理页面里不需要关心当前用的是哪个域名。这个决策在 V1 阶段看起来有点“过度设计”但后来真遇到域名切换时只改了一行配置省了很多事。2. 整体设计与封装思路拆解2.1 先定边界再写代码对外接口就是“引脚”我在这个项目里学到最值钱的一课封装之前先画边界而不是边写边抽。硬件设计里一颗芯片的引脚功能是 datasheet 提前定义好的外部电路只能按这个引脚图来连软件封装也是一样对外暴露的方法名、参数、返回值、错误码就是我们的 datasheet。所以我在设计 AI 交互模块的对外接口时先写了一个预期用法再回头实现内部// 调用方只需要关心这三个回调 const chat createAIChat({ url: /api/chat/stream, onDelta: (text) updateUI(text), onDone: (fullText) saveHistory(fullText), onError: (error) showToast(error.message) }); // 用户点击停止时 chat.abort();这个接口设计参考了硬件封装里的“引脚定义”输入参数是电源和地输出回调是信号线abort 是复位引脚。调用方不需要知道内部用的是 fetch 还是 axios不需要管 SSE 怎么解析也不需要记得释放 listener。边界清晰之后内部实现随便改。2.2 技术选型为什么用 SSE 而不是 WebSocket 或轮询AI 大模型回答天然是流式的但选型时我比较过 WebSocket、SSE 和普通轮询三套方案。WebSocket 是全双工理论上更强但 V1 项目只需要服务端单向推送文本增量用 WebSocket 意味着要维护连接状态、心跳、重连机制复杂度翻倍。轮询最简单但每隔几秒拉一次全文既不实时又浪费流量做打字机效果非常牵强。SSEServer-Sent Events恰好是单工、基于 HTTP、服务端推送浏览器原生支持 EventSource。但原生 EventSource 有个硬伤无法自定义请求头比如带 token 就得用 fetch 手动解析流。所以我在封装层用了 fetch ReadableStream同时顺便解决了自定义 header、abort 控制、错误状态码识别这几个原生 EventSource 做不到的事。2.3 硬件封装思维的借用引脚、封装库与焊盘顺序这个项目虽然是软件项目但我在总结阶段翻了不少硬件封装的内容因为很多概念可以直接映射。比如 emmc 封装引脚一颗 emmc 芯片可能有 153 个 ball每个 ball 的功能在规格书里是固定的封装设计时不能图省事改变引脚顺序。软件里的“接口参数”就是我们的 ball参数名、类型、默认值一旦对外发布也不能随便改否则调用方全部要跟着动。另一个例子是 AD 软件里“封装焊盘顺序需要重新编号”这个问题。PCB 封装库里焊盘编号必须和原理图符号的引脚号对应否则导网表之后连接关系全乱。软件封装里对应的是“参数顺序错位”或者“解构时字段名写错”——比如后端返回 {code, data, msg}前端解构成 {data, code, msg}看着没啥实际用起来就是隐形的焊盘错位。封装的意义恰恰是提前把这些对应关系固定下来。2.4 什么时候不该封装我也踩过反向的坑就是过度封装。V1 阶段最忌讳的是为了“看起来很专业”把简单逻辑包三层。早期我把一个只有两行代码的格式化函数也抽成工具模块结果目录结构膨胀阅读成本反而更高。后来定了一个原则同一个逻辑至少出现两次或者你明确知道下一个迭代一定会扩展才值得封装。比如轻触开关 4.5x4.5 的封装你的原理图符号和 PCB 封装只要一一对应一次做对就够了不需要先搭一套“通用封装生成框架”。软件同理弹窗组件如果只有两个页面用直接用简单的状态控制就行等第三次出现再抽象成组件也不迟。3. 核心实现SSE 流式接口封装与实时渲染3.1 从一行 fetch 到可中断的流式客户端SSE 的核心是服务端返回text/event-stream数据按data: ...的格式分行推过来。原生 EventSource 自动处理这些但没法带 Authorization header也没法手动 abort。所以我在封装层直接用 fetch AbortController 实现。这是我最终用的核心代码去掉业务修饰后大概长这样class SSEStream { constructor({ url, params, headers {}, onDelta, onDone, onError }) { this.controller new AbortController(); this.url url; this.params params; this.headers headers; this.onDelta onDelta; this.onDone onDone; this.onError onError; this.reader null; } async start() { const query new URLSearchParams(this.params).toString(); const target this.url (query ? ?${query} : ); try { const response await fetch(target, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, ...this.headers }, signal: this.controller.signal }); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); if (data [DONE]) { this.onDone?.(); return; } try { const parsed JSON.parse(data); this.onDelta?.(parsed); } catch (err) { // 半包情况留在 buffer 里继续拼 } } } } catch (err) { if (err.name AbortError) { this.onError?.({ name: AbortError, message: 用户中断 }); } else { this.onError?.(err); } } finally { this.close(); } } abort() { this.controller.abort(); } close() { if (this.reader) { this.reader.releaseLock(); this.reader null; } this.controller null; } }这里有一个关键细节reader.releaseLock()一定要在 finally 里做否则流资源一直被占着下一次创建连接可能拿不到底层流。实测下来如果只调用 abort 不释放 readerChrome 里会看到 pending 请求迟迟不消失严重时会触发浏览器并发连接数上限。3.2 解析 event-stream 的细节缓冲、粘包与 [DONE]SSE 的数据是按行传输的但网络层会拆包、粘包你拿到的 chunk 不一定正好是一行结束。所以必须先做buffer ...再按\n切分把切剩下的半行留到下一次拼接。我第一次写的时候没做缓冲结果回答经常在中间断掉后来才想起这是 TCP 流的经典问题。另外要注意 data 的格式。很多大模型网关不会直接给你纯文本而是返回 JSON里面包含 delta、index、finish_reason 这些字段。我封装的时候把所有解析都收敛在 SSEStream 内部对外回调统一给{ delta, fullText }渲染层就不用关心协议细节。如果服务端支持多行 data或者带 event 字段也可以在 parse 阶段做 switch 分发但 V1 阶段保持单格式最简单。还有一个容易忽略的点[DONE] 标记。有些服务端会主动断流来表示结束有些会发一个[DONE]字符串还有的可能直接发完最后一条 data 就关连接。我的代码里同时处理了两种读到 [DONE] 就回调 onDone连接正常结束也回调 onDone但用一个标志位防止重复触发。3.3 渲染层的增量更新与并发控制流式数据拿到之后渲染层怎么更新都要考虑清楚。如果每次拿到 delta 就把整个消息历史重渲染一遍V1 消息一长就会出现明显卡顿。我这边采用的是“一个消息块只维护一个文本节点”每次拿到 delta 就 append 到当前节点后而不是重新innerHTML整个列表。还有一个并发控制问题用户可能连续提问上一个请求还没结束就发起新请求或者点“重新生成”按钮。这时候必须先 abort 掉旧请求再开新请求否则两个流同时往同一个消息块里写页面就乱了。我封装了一个简单的 token 机制let requestId 0; function sendMessage(text) { const current requestId; chat.abort(); // 先停掉上一次 chat createAIChat({ ... }); chat.onDelta (delta) { if (current ! requestId) return; // 过期回调直接丢弃 appendToMessage(delta); }; }这种“请求号校验”比单纯依赖 abort 更保险因为 abort 只能保证不再继续读取已经触发过的异步回调无法完全取消。3.4 abort 语义请求中断、界面清理与资源释放abort 这个动作看起来就一行代码但实际涉及三层网络请求层、界面层、状态层。网络层调用controller.abort()后fetch 的 promise 会 reject 一个 AbortError我的代码里捕获并转成统一的错误对象界面层要立刻把“停止”按钮换成可再次发送的状态把光标动画停掉状态层要标记当前消息为“已完成”或“已中断”避免后续逻辑再对这条消息做增量追加。我最初只做了网络层 abort结果用户点停止后界面上的 loading 图标还在转过了几秒才停。原因是回调链里还有 setTimeout 或者 animation frame 在跑。后来我把所有和这次请求相关的定时器、渲染帧、DOM 引用都收集起来abort 时统一清理才算真正干净。4. 更多封装实践请求层、组件层与工具层4.1 axios 二次封装拦截器、错误码归一化与多域名 baseURL这个项目的普通接口走的是 axios我把 axios 封装成统一实例并解决了一个实际问题H5 需要指向两个域名。热搜词里有个 uniapp 封装 H5 指向 2 个域名的问题我的做法不是写死两个环境变量而是做一个域名配置表当前主域名请求失败后可以自动切换到备用域名。先看最基础的二次封装const http axios.create({ timeout: 15000 }); http.interceptors.request.use((config) { config.baseURL getCurrentDomain(); // 动态取域名 config.headers.Authorization Bearer ${getToken()}; return config; }); http.interceptors.response.use( (response) { const { code, data, message } response.data; if (code ! 0) { // 业务错误统一处理 showToast(message); return Promise.reject(new Error(message)); } return data; }, (error) { if (error.code ECONNABORTED) { return handleTimeoutRetry(error.config); // 自动重试或切换备用域名 } if (error.response?.status 401) { redirectToLogin(); } return Promise.reject(error); } );这里有个很容易被忽略的点响应拦截器里return data之后类型上所有接口返回值都直接是data不是{code, data, msg}包一层。如果团队有人不熟悉这个约定很容易写出res.data.data。我在项目总结里特意在 README 里加了一条“所有接口函数的返回结果已经是业务数据本体”。封装一定要配套文档否则封装越深调用方越迷。多域名切换我单独做了个模块核心逻辑是维护一个域名池重试时把 baseURL 换成下一个域名并记录失败次数。切换的阈值不能太低否则一次网络抖动就把所有请求切过去也不能太高否则容灾域名永远用不上。实测下来连续失败 2 次再切换比较合适。4.2 组件封装一个适合 V1 的弹窗/列表组件抽象组件封装这块我没有引入 Element Plus 或者 Ant Design而是自己封装了几个轻量组件。原因不是“去组件库化”而是 AI 对话这种定制界面组件库根本覆盖不了弹窗也只是为了消息详情、设置面板这些简单场景没必要把整个组件库的体积拖进来。拿弹窗来说V1 只封装了两个能力通过配置项控制开合通过插槽定制内容。核心是让业务页面不直接操作 DOM 层级也不需要在每个页面重复写 v-model 和 teleport。// 用法 AppDialog :visiblevisible title对话详情 width420px closevisible false template #body MessageDetail :messagecurrentMessage / /template /AppDialog组件内部的 teleport 挂载点、遮罩点击关闭、ESC 关闭、滚动锁定这些逻辑全部收敛在组件里。这个封装的收益在后期很明显后来产品要求所有弹窗加统一的右上角关闭按钮和底部操作栏我只改了一个组件。列表组件也是一个道理。V1 的消息列表涉及滚动加载、空状态、加载中、底部占位四种状态封装成一个MessageList组件后页面只需要传数据和渲染模板翻页判断放在组件内。但这里有个教训不要把数据请求也放进组件里。数据请求应该由页面或 store 管理组件只负责接收数据和触发事件否则组件没法复用。4.3 工具函数封装与目录结构划分工具函数这块我主要抽的是和领域强相关的逻辑比如消息流解析、文本截断、时间格式化、防抖。通用工具像debounce、throttle直接用 lodash-es 按需加载就行不要自己造轮子。目录结构我最后调整成这样src/ ├── api/ # 接口层封装 axios 实例和各业务接口 ├── components/ # 通用组件库 ├── composables/ # 可复用逻辑比如 useChatStream ├── utils/ # 工具函数纯函数为主 ├── config/ # 域名配置、环境配置 └── views/ # 页面一个体验不错的原则是api/里的接口函数只负责数据请求不碰 UIcomposables/里的useChatStream负责把 SSE 连接、abort、生命周期串起来utils/里不依赖任何业务上下文所以可以大方地写单元测试。层次清楚之后排查问题就顺着“页面 - composable - api - 网络层”一路往下找基本不会迷路。5. 常见问题与排查技巧实录5.1 SSE 流式回答中断、乱码、重复渲染流式回答中断90% 是解析层没处理半包。表现是文字卡在某句话一半然后停止了。解决方式就是我前面说的 buffer 拼接不能直接用读到的 chunk 去JSON.parse必须按\n切分。乱码问题一般是 TextDecoder 的编码不对。有些服务端返回 UTF-8但响应头没写 charset浏览器默认可能是其他编码。我在 fetch 这里强制用new TextDecoder(utf-8)同时在解析时用decoder.decode(value, { stream: true })这个 stream 参数很关键不用它的话多字节字符被拆到两个 chunk 时会丢字。重复渲染这个问题最有迷惑性。现象是浏览器显示的字时不时跳回来一段像是回退了一样。后来排查发现是onDelta回调没有做“追加”还是“全量替换”的区分有一段逻辑用了fullText去覆盖另一段用了delta去追加两段代码打架。最终统一成所有回调只给增量delta内部自己维护fullText渲染层永远只做 append。5.2 abort 不生效与内存泄漏abort 不生效最常见原因是 fetch 的响应体已经开始流式读取但reader.cancel()没调用。AbortController.abort()会中断 fetch 的接收但不一定会立即释放流的锁。我的代码里在 abort 时先调用this.abort()然后在 finally 里reader.releaseLock()两个配合才不会留下僵尸请求。内存泄漏的坑在回调引用上。如果onDelta闭包引用了页面的大对象而页面已销毁但 SSE 实例还活着这个对象一辈子也释放不掉。我的做法是在页面onUnmounted里强制调用chat.abort()并置空引用同时在close()里把所有回调致空。V1 阶段不用做太重的 GC 优化但“随生命周期销毁”这条必须做对。5.3 多域名打包后 404 与跨域uniapp 封装 H5 指向两个域名时会遇到一个经典问题index.html 默认使用相对路径去加载 JS第一次打开是好的切域名刷新后 404。原因是打包后资源路径写死了/assets/xxx.js而备用域名部署在子目录下。解决方式是修改manifest.json里的 H5 路由 base或者用相对路径./打包但相对路径在嵌 WebView 和微信浏览器里也可能有坑。跨域问题更直接两个域名如果响应头没有Access-Control-Allow-Origin前端根本拿不到数据和封装无关但会反复阻塞联调。这里我给两条实测经验第一条后端允许某个固定域名集合时前端不要用withCredentials模式除非你确定服务端支持跨域带 cookie第二条出现 OPTIONS 预检请求时确认你的请求头里没带非简单头比如自定义X-Client-Version就会触发预检业务上尽量收进公共 header 里减少预检次数。5.4 版本管理与 git 差异比对封装做完之后版本管理反而成了最容易翻车的地方。热词里有人搜“vue 封装 git 版本差异比对”我理解是想知道怎么对比两个版本的封装差异。我用的方法是每次发版本打 tag用git diff v0.1.0 v0.2.0 -- src/api src/components单独看封装层的变化而不是看全部文件。另外 package.json 的版本号要和项目版本解耦。我会让业务包的 version 跟着发版节奏走而内部封装模块的 version 独立递增。这样接口调整时依赖这个模块的兄弟项目能感知到变化不会在静默中被破坏。V1 阶段没有上 monorepo但把公共封装放到独立目录、独立 package.json已经能解决大部分冲突问题。5.5 硬件封装相关的典型坑虽然这是软件项目但总结时我复盘了硬件方向上一些常见的封装问题因为很多原理相通值得一并记录。比如 AD 软件加载封装库失败多半是库文件路径用了绝对路径换台电脑就找不到正确做法是相对路径或者把库放进统一工程目录。再比如封装焊盘顺序需要重新编号PCB 封装库的焊盘编号和原理图符号引脚必须严格一致如果封装是复制别人的焊盘编号可能从 0 开始而原理图从 1 开始导网表后直接报错。SOP20W、TSSOP10 这类封装最容易出错的是“丝印尺寸”和“焊盘尺寸”的关系。不同厂家的封装库标准不一样推荐的做法是从元件厂官网下载官方封装库而不是在第三方封装库网站随意找。0603 和 0805 等无源器件虽然公制对应清楚但有些老工程师还在用英制项目里必须统一标注单位否则贴片回来尺寸全偏。这些坑和软件封装一样都是“定义不一致”惹的祸提前把统一标准写进项目规范能省一大笔返工费。6. 实测心得与后续扩展这个 V1 封装做完之后我最大的体会是封装不是终点而是“可维护性”的起点。你把内部复杂度藏起来之后更重要的是让团队知道哪些东西能改、哪些东西不能改。我踩过几次坑之后现在养成了一个小习惯每封装一个模块就在文件头部写一个“对外承诺”注释比如这个方法会返回什么、异常走哪个回调、abort 之后还能不能复用。这个注释比任何设计文档都靠谱因为它在代码旁边改代码时一定会看到。另外一个小技巧是封装的稳定性要靠“调用方不变”来验证。我发布 V1 封装之后先冻结了对外 API然后让另一个同事只通过公开接口实现一个等于原来功能的新页面。他如果全程没翻看过内部实现说明边界画得对如果他忍不住打开内部代码那说明封装还不够。这个验证办法我逢人就推荐。后续扩展方向目前有两个一个是把 SSE 封装做成 npm 包加上自动重连、服务端重发事件 ID 的支持另一个是把硬件封装和软件封装的对应关系整理成一份团队内部规范以后画 PCB 封装和写 API 时都能少踩同样的坑。V1 只是第一步但把这一步做扎实后面的路会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑