尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

V1项目封装设计复盘:从SSE流式到Axios二次封装的完整思路

发布时间:2026/9/27 20:47:04

资讯中心
01
ARTICLE

V1项目封装设计复盘:从SSE流式到Axios二次封装的完整思路

V1项目封装设计复盘:从SSE流式到Axios二次封装的完整思路
1. 为什么 封装 是这个项目里最值钱的部分先说个让人有点意外的事实V1 项目里最让我后面省心的不是某一个功能模块写得有多漂亮而是那些看起来最不起眼的封装层。当时我做第一版的时候也和很多人一样觉得把页面画出来、接口调通就算完事。但越往后走越发现项目里真正决定开发效率和后期维护成本的恰恰是那些包了一层又一层的东西。你每少写一个重复的封装后期就多一分改东墙补西墙的苦。这篇总结我不打算聊业务流程就专门复盘一下 V1 里那些封装设计——从前端接口请求、组件封装到后端模块化设计的完整思路踩过的坑以及如果重新做一遍我会怎么改。这个项目的前端用的是 Vue 技术栈后端是 Node.js中间通过 SSE 流式输出解决大模型回答的实时渲染问题配合 AbortController 实现了请求中断。整体不算复杂但正因为不复杂封装设计的好坏直接暴露在日常开发的每个细节里。我总结完 V1 项目之后最大的感受就是封装的本质不是写代码是在做不许想的决策——你提前把该想的事情想完后续开发完全不必重复思考。2. V1 项目的技术骨架一个偏实时交互的轻前端架构V1 项目的整体架构并不复杂但它天然带着交互实时性的血统这就决定了前端的很多封装不能照搬传统后台管理的套路。2.1 项目核心流程V1 项目表面上是把用户输入的内容交给大模型处理然后渲染返回结果。但核心链路一旦拆开大概是这样的用户输入 → 前端组装上下文 → 请求后端接口 → 后端调用大模型服务 → 模型流式返回 → 前端逐段渲染回答 → 用户可随时中断这里面的关键点有两个第一大模型的回答是流式的不是一次性给完的第二用户随时可能取消这次请求比如换个问题、关掉页面甚至只是想停止生成。如果这两点处理不好用户会明显感觉到界面卡住了或者等了半天才全部出来体验非常糟糕。所以 V1 从一开始就有了两个明确的技术选型决策SSE 流式输出和 AbortController 请求取消。2.2 为什么选 SSE 而不是 WebSocket 或轮询大模型回答的实时渲染常规方案有三个WebSocket、轮询、SSE。我们在 V1 里选择了 SSE这里是有明确理由的。准确说WebSocket 是双向通信协议适合客户端和服务端频繁互发的场景比如聊天室、实时协作编辑。而 SSE 是单向的服务端到客户端的流式推送正好符合大模型往客户端推数据这个单向需求。用过 WebSocket 的人都会承认在只需要服务端推送的场景里它的复杂度是多余的——要处理连接状态、心跳机制、消息格式、重连逻辑这些在 V1 的这个场景里全是负担。轮询呢按下不表。设定一个定时器隔几秒拉一次接口对大模型这种生成速度不均匀的场景来说轮询要么太密浪费资源要么太疏导致前端渲染有卡顿感。而且大模型回答中间停顿是常态轮询会不断制造无效请求。SSE 用起来就简单直接后端往 response 里持续写数据前端通过 EventSource 接口接住并实时渲染。虽然是单向但在这个场景里单向就是最合适的。我们 V1 没有做传统的 EventSource 封装而是直接用 fetch 来读流绕开了 EventSource 的几个限制这个选择后面会细说。3. SSE 流式输出的实现与封装回答实时渲染的核心这一步是整个 V1 里最核心的封装没有之一。大模型回答能不能像打字机一样一个字一个字蹦出来用户体验顺不顺滑全靠这里的实现。3.1 老方案与新方案的取舍如果在网上搜SSE 前端实现搜到最多的方案是用 EventSource。EventSource 用起来非常简单几行代码就能收消息。但 EventSource 有几个硬伤只能 GET 请求没法自定义请求头 Authorization 之类不好带不支持 POST 请求体复杂参数传不了断开重连是自动的但有些场景需要手动控制没法和 AbortController 很好地协作V1 项目因为要传一些结构化的用户配置用 GET 会很憋屈。所以我没有用 EventSource而是用 fetch ReadableStream 手动解析流式数据这样既能 POST又能自定义 headers还能配合 AbortController 随时掐断请求。3.2 前端封装思路把流式数据变成标准接口我封装的关键思路很简单底层用 fetch 读流对外暴露一个 Promise 风格的接口业务方根本不用关心流的存在。大致逻辑是// sseRequest.js export function sseRequest({ url, method POST, data, headers {}, onMessage, // 每个数据块的回调 signal, // 外部传入的 AbortSignal }) { return new Promise((resolve, reject) { fetch(url, { method, headers: { Content-Type: application/json, ...headers, }, body: JSON.stringify(data), signal, }) .then((response) { if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; function push() { reader.read().then(({ done, value }) { if (done) { resolve(); return; } buffer decoder.decode(value, { stream: true }); // 按换行符切分SSE数据 const lines buffer.split(\n); buffer lines.pop(); // 最后一段可能不完整留在buffer里 lines.forEach((line) { if (line.startsWith(data:)) { const dataStr line.slice(5).trim(); if (dataStr) { try { const parsed JSON.parse(dataStr); onMessage(parsed); } catch (e) { // 容错处理某些中间数据可能不是完整JSON } } } }); push(); }).catch((error) { if (error.name AbortError) { reject(error); } else { reject(error); } }); } push(); }) .catch(reject); }); }这段代码的核心教义是流式数据一个 chunk 一个 chunk 到达你需要把它们拼起来按 SSE 协议里约定好的格式切分成一条条 message。之所以用buffer来攒数据是因为网络传输不一定按一行一行来——一个 chunk 可能包含半行消息两个 chunk 合并起来才能凑出一行完整数据。如果每次都从split(\n)开始处理遇到边界情况就会丢失数据。这个细节是典型的不做不知道一踩全知道的地方。3.3 配合 AbortController 实现请求中断取消请求这个东西很多前端项目根本没考虑过直到用户反馈我点停止怎么没反应我重新提问怎么又出老答案。在 V1 里中断大模型生成是必须的。用户在等待回答时可以随时点击停止生成前端要立刻断掉这次 SSE 请求同时后端要感知到并取消对大模型的调用。// 在业务组件里 let abortController null; function startGenerate() { abortController new AbortController(); sseRequest({ url: /api/generate, data: { prompt: currentPrompt }, signal: abortController.signal, onMessage: (msg) { if (msg.type delta) { currentText.value msg.content; } }, }); } function stopGenerate() { abortController?.abort(); }AbortController是个原生 API负责产生一个信号signal你把这个信号传给 fetch调用abort()时 fetch 请求就会被终止。而后端那边呢收到的 HTTP 请求会中断我们就可以在服务端监听这个断连事件取消对大模型的内部调用。这个双向协作是整个回答流式显示 随时停止体验的关键闭环。前端不传 signal、后端不监听断连那中止操作就只能做到表面功夫跟用户点了停止实际后端还在吭哧吭哧生成一个样。3.4 关于 abort 的一个重点坑不是所有 abort 都能成功这里必须说一下实际踩过的坑AbortController 的 abort 只能本端操作没有任何失效期的概念。什么意思如果你在请求完成后才调用abort()那不会有任何报错但也没任何作用。另外abort 之后 fetch 会抛出AbortError。如果你在封装层里对这个错误处理不当用户那边会看到控制台疯狂报错甚至整个 Promise 变成 rejected 状态。所以我在上面的代码里对error.name AbortError做了单独的 catch 处理让业务层知道这是用户主动取消的不是真出错然后走静默处理。这个封装好了之后业务组件的代码几乎是干净的const { startGenerate, stopGenerate, currentText } useAIStream();不用关心 SSE、不用关心 buffer、不用关心错误流中断。这就是封装的价值——把脏活累活全干完露出一个极简的操作台。4. 接口请求封装uni-app、微信小程序与 Vue 的差异化处理V1 项目不是纯浏览器场景部分页面还涉及移动端和小程序。这次遇到的一个热搜词就很有代表性uni-app 封装 H5 如何指向 2 个域名。我在这块也做了类似的处理说一下思路。4.1 为什么需要区分不同环境指向不同域名项目在开发和测试阶段经常会有一个前端服务对多个后端环境的需求。比如你的 H5 页面线上环境指到正式服务器 IP测试环境指到测试服务器 IP本地联调又指到 localhost。如果这些地址是散落在代码各个请求里写死的那么每次切换环境都要全局搜索、逐个替换光是想想就血压上升。我在 V1 里统一的处理方式是在封装层根据运行时环境自动选择 baseURL。// request.js const ENV_MAP { development: https://dev-api.example.com, test: https://test-api.example.com, production: https://api.example.com, }; function getBaseURL() { // uni-app 环境下可以用 uni.getSystemInfoSync 判断 if (typeof uni ! undefined) { const sysInfo uni.getSystemInfoSync(); // H5 场景下从运行参数或者全局配置读 if (sysInfo.uniPlatform h5) { return window.location.origin.includes(localhost) ? ENV_MAP.development : ENV_MAP.production; } if (sysInfo.uniPlatform mp-weixin) { // 小程序里的地址要和微信公众号后台配置保持一致 return https://api.example.com; } } return ENV_MAP.production; }不同平台的请求对象不同也要在封装层做好适配。uni-app 里能用uni.requestAxios 也能在 H5 上用但小程序环境里 Axios 基本上没法直接用因为小程序没有浏览器的 XHR/Fetch 接口。所以封装层要多做一步内部统一用平台的网络请求对外暴露 Promise 接口。4.2 常见的 2 个域名指向问题为什么会有指向 2 个域名的问题真实场景可能是H5 既要在微信内打开走业务 API又要在一个独立 Web 端打开走开放的 API两个环境域名不同鉴权方式也不同。处理思路其实特别朴素在封装层加一层路由。function dynamicBaseURL(reqConfig) { // 1. 优先读请求配置里的 baseURL 覆盖 if (reqConfig.useNoAuth) { return https://open-api.example.com; } // 2. 默认业务域名 return getBaseURL(); }不要把这个逻辑散落到具体业务代码里全部在请求封装层判断。这样即使后续加了第三个域名改动也只发生在一个文件里。4.3 微信小程序的请求封装差异微信小程序的请求封装有几点和浏览器不同这个好多人一开始不知道发起wx.request时没有浏览器那样默认同源策略放开的概念域名需要在小程序后台配置合法域名白名单。请求头里的Content-Type默认值可能和服务端预期的不一致容易触发跨域或 preflight。并发请求量大的时候如果没有统一超时控制用户会在弱网环境下积累很多永远在转圈的请求。我的封装方案是内部统一走wx.request返回一个 Promise同时包一层超时控制。function wxRequest({ url, method, data, header }) { return new Promise((resolve, reject) { wx.request({ url: getBaseURL() url, method, data, header: { content-type: application/json, ...header, }, timeout: 15000, success(res) { if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else { reject(new Error(HTTP ${res.statusCode})); } }, fail(err) { reject(err); }, }); }); }从外部看它和 Axios 调用接口的姿势几乎一样getBaseURL /path 参数。但底层已经完全适配了微信小程序的限制业务代码一行不用改。4.4 关于 Axios 二次封装浏览器的 H5 页面我还是选择了 Axios 做二次封装但这里的重点不是怎么封装而是你封装到哪一层才算合理。业内很多项目把 Axios 封装成拦截器 统一错误提示 统一 loading这一个模板。但 V1 里我的做法是多留一些口子const service axios.create({ baseURL: getBaseURL(), timeout: 20000 }); service.interceptors.request.use((config) { // 1. 附加 token const token getToken(); if (token) config.headers.Authorization Bearer ${token}; // 2. 某些接口需要取消 loading config.loading config.loading ?? true; return config; }); service.interceptors.response.use( (res) { // 这里不直接返回 res.data而是做一层数据解包 const bizData res.data?.data ?? res.data; return bizData; }, (error) { // 统一错误处理 return Promise.reject(error); } );这里的关键是不要把业务代码的错误处理逻辑全都塞进封装层。封装层只关注通用能力比如鉴权、超时、环境切换具体某个接口的错误怎么提示应该由业务层决定。否则你会在封装层里写一堆if (error.code 5001)之类的判断把好好的封装搞成一锅粥。5. V1 项目中的组件封装思路不是每个东西都值得抽出来组件封装也是 V1 项目里比较占篇幅的一块部分。搜索关键词里出现了uniapp 封装 h5 如何指向 2 个域名、axios 二次封装这类问题其实都指向同一个本质什么时候该抽、怎么抽、抽到什么程度。5.1 组件封装的两个原则我总结下来V1 组件封装只贯彻两个原则高频复用才抽、抽出来必须一次做到位。说得直白点如果一个页面组件只在自己的父级里出现一次那完全不需要做成通用组件直接在父页面里写就行。很多项目的组件库过度膨胀根源就是每写一段代码都想万一以后用得上呢。这个万一对于小项目是个灾难——为用不上的抽象付出维护成本本身就是浪费。真正值得抽的组件是那些在两三个以上页面里都要用、且交互逻辑一致的东西。比如 AI 回答框既要实时流式渲染又要支持停止生成、输入框要支持快捷键发送、多行自适应、错误提示条要支持多种状态、加载动画要支持场景差异。5.2 一个 AI 流式回答框的封装实例我们的 AI 回答框组件AIAssistant.vue内部做了这些封装template div classai-assistant div classmessages refmessageBoxEl template v-formsg in messages :keymsg.id div :class[message-item, msg.role] span classrole-tag{{ msg.role user ? 你 : AI }}/span div classcontent v-htmlrenderMarkdown(msg.content)/div /div /template /div div classinput-area textarea v-modeldraft :disabledisGenerating keydown.enter.preventsend/textarea button clickisGenerating ? stop() : send() {{ isGenerating ? 停止生成 : 发送 }} /button /div /div /template组件的对内逻辑里包含了SSE 流式接收和文本增量追加停止生成按钮的展示/隐藏切换按下 Enter 发送Shift Enter 换行历史消息的列表管理回答内容里的 Markdown 渲染后端返回的是纯文本前端自己再做渲染对外暴露的接口就三个props: { apiUrl: { type: String, required: true }, paramsFactory: { type: Function, required: true }, // 生成请求参数的函数 }, emits: [complete, error],这样设计的好处是调用方不用关心内部流式处理细节只需要给一个 URL 和一个生成参数的函数。不同页面可以用同一个 AI 组件但传不同的 prompt 模板和上下文。这就是封装的价值能力内聚、对外极简、易于复用。V1 里这个组件被用在了智能客服、辅助写作、代码生成三个页面几乎没有改动内部逻辑全靠参数不同来区分场景。6. Generative 迭代器封装一种更优雅的流式处理方式这个热搜词是 generator 迭代器封装函数虽然 V1 里不是每个模块都用到了但我在部分代码里做了尝试效果不错值得单独写一段。6.1 什么是 Generator 迭代器为什么在流式场景里有用Generator 是 ES6 里的一个特殊函数执行时可以暂停、可以恢复每次yield一次就把控制权交回外部。流式场景天然适合用 Generator 来描述你一边生成数据、一边交给外部处理比写在回调里更直观、更容易阅读和维护。比如把 SSE 的数据处理改成 Generator 形式async function* generateStream() { 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) { if (line.startsWith(data:)) { const payload line.slice(5).trim(); if (payload) { yield payload; // 每次产出一条数据 } } } } }外部调用方式变成for await (const payload of generateStream()) { // 每次拿到一条新数据更新界面 onMessage(JSON.parse(payload)); }这个写法比回调嵌套清晰多了。尤其是在复杂流式逻辑里想打个 log、想加个条件跳过某些数据直接在 for-await 循环里加两行就行不用去改动封装内部。6.2 Generator 封装在实际项目中的局限不过说实话Generator 不是万能的。在 V1 里它主要用于调试和内部逻辑统一没有作为对外 API 暴露给所有业务模块。原因是Generator 和 AbortController 的协作没有回调式那么天然。如果中途用户点了停止for-await 循环怎么退出、资源怎么释放需要额外写控制逻辑。而在回调式写法里只要 signal 被 abortfetch 的 Promise 就会 reject我们 catch 住就行。一个折中方案是让 Generator 函数接收一个 signal 参数在每次 yield 后检查信号状态async function* generateStream(signal) { // 循环里每读一次数据先检查 signal.aborted if (signal?.aborted) { throw new DOMException(Aborted, AbortError); } const { done, value } await reader.read(); // ... }这样只要外部调用方在循环里 catch 到 AbortError 就能优雅退出。不过这个代码复杂度已经上来了对于 V1 这个体量的项目权衡之后我们最后还是在正式业务代码里保留了回调式写法Generator 版本留作研究和后续优化用。7. 后端/服务端的封装设计模块化与接口稳定性的平衡V1 项目的后端技术栈是 Node.js使用过程中对后端封装的需求也很强烈。7.1 接口层的统一响应封装后端所有接口的返回格式如果五花八门前端就会非常痛苦。前端封装完请求层必须依赖一个稳定的数据解包格式。所以后端这边我也做了统一响应封装。function ok(data, message ok) { return { code: 0, message, data }; } function fail(code, message) { return { code, message, data: null }; }前端拦截器里直接取data.data业务层拿到的永远是解包后的数据。即使后端某个接口忘了包前端也能兼容处理。这里有一个经验code 不要共用一套。不要用 HTTP 状态码来充当业务码。HTTP 401、403、500 这些是协议层面的业务代码码比如用户未登录、内容审核不过应该有独立编码方便前后端对齐。V1 里我们用 0 代表成功非 0 代表业务失败HTTP 层永远返回 200真正的错误信息全部塞进 body。这样对网关、日志、监控都友好。7.2 后端对大模型调用的封装与超时控制服务端调用大模型 API 同样需要封装。我按照下面的思路做了LLMClientclass LLMClient { constructor({ apiKey, baseURL, timeout 30000 }) { this.apiKey apiKey; this.baseURL baseURL; this.timeout timeout; } async* chatStream({ messages, temperature 0.7, maxTokens }) { const url ${this.baseURL}/chat/completions; const resp await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.apiKey}, }, body: JSON.stringify({ model: gpt-4o, messages, temperature, max_tokens: maxTokens, stream: true }), }); if (!resp.ok) { throw new Error(LLM API error: ${resp.status}); } const reader resp.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) { if (line.startsWith(data:)) { const dataStr line.slice(5).trim(); if (dataStr [DONE]) return; const json JSON.parse(dataStr); yield json.choices?.[0]?.delta?.content || ; } } } } }这个类把请求组装、流式解析、数据格式转换都封装在一起对外只暴露一个chatStream方法。业务层拿到的是一个异步迭代器可以直接透传给前端或者做中转。然后服务端接收到前端的请求后再把这个流式数据转发出去同时监听前端是否断开app.post(/api/generate, async (req, res) { res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); const client new LLMClient({ apiKey: process.env.LLM_KEY }); const stream client.chatStream({ messages: [{ role: user, content: req.body.prompt }] }); try { for await (const chunk of stream) { if (req.aborted) break; // 监听前端断开 res.write(data: ${JSON.stringify({ type: delta, content: chunk })}\n\n); } res.end(); } catch (e) { // 记录日志统计大模型调用失败次数 res.write(data: ${JSON.stringify({ type: error, message: e.message })}\n\n); res.end(); } });req.aborted在 Node.js 的 HTTP 请求对象里是一个布尔属性客户端断开连接时它会变成true。这个判断就能保证前端点了停止之后后端的循环也会很快终止不会白白消耗大模型的 token 额度。7.3 服务端模块化的封装边界后端的模块化设计也涉及封装思想。我的做法是就按功能拆几个模块比如llm/放模型调用auth/放鉴权db/放数据库操作每个模块内部只关注自己的业务。对外暴露的接口小而清晰不互相依赖。这时候千万别搞什么大统一中心化配置那种过度设计。一个小项目如果拆了二十多个模块每个模块都要引入七八个其他模块反而让调用链变得难以维护。封装不是越多越好是把变化隔离、把复用沉淀。8. V1 项目里关于硬件封装关联的踩坑实录搜索热词里意外出现了不少芯片封装相关的词像0603 封装尺寸、sop20w 封装、allegro 封装制作、ad 封装库之类的。V1 项目本身不是硬件项目但我做过嵌入式相关的开发这两个领域里封装一词的含义差异极大值得在这里做一次明确的辨义免得跨领域的人被搞晕。8.1 前端封装与硬件封装完全是两回事前端/软件里的封装是指把代码逻辑打包并提供统一接口硬件里的封装是指芯片或元器件的物理尺寸和引脚排列标准比如 0603 是贴片电阻电容的尺寸代号SOP-20 是 20 脚的小外形封装。如果你是从前端过来、跑到硬件项目里看到封装库三个字不要自动脑补成代码库。0603 封装尺寸指的是长 1.6mm、宽 0.8mm 的焊盘尺寸标准0805是 2.0mm × 1.25mm。这些尺寸决定了一个元件能不能放到 PCB 板上、贴片机能不顺手。而 PCB 设计工具里的封装比如 Cadence Allegro、Altium DesignerAD它们的封装库是指元件在 PCB 布局布线上使用的物理模型。前端封装里的导入导出和硬件设计里的导入 PCB、封装转完全是不同的操作逻辑。8.2 硬件封装领域的几个高频问题的快速扫盲allegro 16.6 pcb 封装制作流程涉及创建焊盘、设置原点、放置 outline、添加丝印和位号、关联原理图 symbol最终生成.psm文件和焊盘.pad文件。ad 软件如何加载封装库在 AD 的 Libraries 面板里添加集成库或分离库路径然后在原理图编辑器里给元件指定封装。AD 封装转 Allegro可以用 AD 的 File Export 配合中间的转换工具但需要注意焊盘命名规则、层叠定义等差异否则容易出现转换后焊盘丢失。封装怎么识别引脚看数据手册的引脚图识别 1 脚标志圆点/缺口/斜角结合封装外形图和焊盘编号一一对应。虽然这些和 V1 项目没有直接关系但封装这个词在中文技术语境里多义到让人头疼。我见过不少前端工程师第一次接触硬件时因为封装库这个名字反射性地以为是什么 npm 包结果花了不少时间才绕明白。V1 项目的总结里专门留这么一节也是想提醒做过软件的朋友对术语的歧义保持敏感比什么都重要。9. 封装设计的几个核心意识回头看 V1哪些做对了哪些该早点做V1 项目跑完、总结完我把自己当成一个刚刚完成了第一版项目的工程师认真复盘一下封装设计层面最有价值的几个意识和判断。9.1 对接口的执着封装即接口设计封装做得好的项目业务代码读起来很轻松因为每个封装都只暴露极少的内容内部再乱外部也无所谓。V1 里我做对的AI 流式组件对外只接受apiUrl和paramsFactory两个 props其他全部内聚。接口请求封装对外统一返回 Promise调用方永远用.then或者await接住 res 数据不关心它到底来自浏览器 fetch 还是小程序 request。我建议所有团队把封装设计等同于接口设计来对待。评审代码时先看它对外暴露了什么再看内部实现这个顺序不能反。很多代码署得乱往往不是实现逻辑乱而是对外暴露的口子太多、太杂。9.2 对重复的零容忍封装是 DRY 的自然结果封装最直接的驱动力是同一套逻辑写两遍以上就想吐。V1 里至少有三处重复让我动手写了封装层多个页面都要用 SSE 流式请求 → 提出sseRequest多个请求地址的环境切换 → 提出getBaseURL多个组件都要处理请求错误 → 提出统一拦截器有一个很实用的判断标准如果一段代码第二次出现可以先不急着抽如果第三次出现就必须抽了。第一次抽容易抽错边界第二次抽留出了观察窗口第三次是历史性的机会窗口再拖下去每多一个拷贝就多一个维护死角。9.3 对过度设计的警惕不是所有东西都要封装V1 项目里也犯过一个错早期把简单函数也包了一层又一层导致调用链极深调试的时候一层层跳进去都看不太懂逻辑。比如有一次我把一个简单的formatTime函数封装成了用工厂模式返回的对象结果业务代码为了格式化时间需要timeFactory.create().format()简直荒谬。后来复盘时我直接把它改回一个普通函数formatTime(timestamp)。这里的教训是封装是针对复杂性的不是针对所有代码的。没有复杂性的地方强行做设计只会把代码堆出新的复杂性。封装要有目的为了复用、为了隔离变化、为了统一约束。如果三个目的一个都不沾那大概率不该封。9.4 对安全与健壮的底线要求V1 的请求封装里做了两件安全相关的事统一走白名单内的业务域名同时对外部第三方域名请求单独走一个带noAuth标记的通道。错误信息统一脱敏——不把后端返回的堆栈、内部错误码直接透给用户。这些不是花哨功能但这些细节决定了一个项目能不能安全上线跑业务。10. 如果现在重做 V1我会重点优化的三处封装V1 已经跑完但回顾之后如果让我现在从零再写一遍有三个地方我会做得不一样也值得你复用时参考。10.1 AI 流式渲染组件把消息持久化做进封装里当前版本的 AI 组件只负责渲染当前会话不做持久化。实测里发现一个问题用户重新进入页面时历史对话全部丢失体验断层。如果重做我会在组件内部把消息列表的序列化/反序列化做进去或者对外暴露一个historyAdapter接口让业务方决定存到 localStorage 还是数据库。好处很明显流式渲染和历史消息两者天然相关放在同一个封装里才不会出现渲染逻辑和存储逻辑各自维护一套数据的问题。10.2 SSE 封装把重连逻辑也考虑到V1 里的sseRequest没有自动重连如果网络抖动导致 SSE 断流用户需要手动重新提问。如果重做至少要在封装层加一个重连策略选项每次断流后延时 1s、3s、5s 递增重试最多 3 次超过重试上限后改走重新发送请求的完整流程而不是继续读流整个过程中用户界面不闪断、不丢失已渲染的内容这个功能划不划算取决于业务但对 AI 客服这类场景来说断线重连的体验提升是非常明显的。10.3 请求拦截器把取消抑制做进通用能力现在 V1 里的请求取消主要靠业务代码手动创建AbortController。如果重做我会在请求封装层直接内置一层取消管理能力// reqManager.js class RequestManager { constructor() { this.map new Map(); // requestId - AbortController } start(key) { this.abort(key); // 如果有旧请求先取消 const controller new AbortController(); this.map.set(key, controller); return controller.signal; } abort(key) { const controller this.map.get(key); if (controller) controller.abort(); this.map.delete(key); } abortAll() { this.map.forEach((controller) controller.abort()); this.map.clear(); } }这样业务方只需要给请求一个 key封装层自动帮你处理同一个 key 只能有一个请求在飞的问题。很多实际场景里切页面、连续点多次提交这个能力可以避免不少小 bug。11. 封装之外关于这个 V1 项目本身的一句话总结如果你读到这里还没被绕晕那说明你对封装这事的执念和我差不多。V1 项目本身不复杂但它带出的封装方法论可以迁移到很多类似的项目里。这里我把整个链路再理一遍当作一个最终备忘SSE 流式输出用 fetch ReadableStream 实现不用 EventSource因为要 POST 自定义 header 配合 AbortController请求取消AbortController 负责前端中断后端监听req.aborted或响应关闭来取消大模型调用接口请求封装在 Vue 里用 Axios 拦截器处理环境切换、token 注入、错误提示在 uni-app/微信小程序里统一走 wx.request 风格 API但对外 Promise 化组件封装高频复用的才抽对外暴露尽量少的 props/events内部复杂实现整体内聚Generator 迭代器适合流式处理场景但取消协作比较复杂需谨慎选择安全与规范统一响应格式、错误脱敏、环境白名单都是封装层顺手做的事最后分享一个我真实体会到的东西封装有个隐形收益它逼你在写第一行代码之前思考将来谁会调用我这个东西、他需要什么。这种以调用者视角写代码的习惯比任何框架、工具都值钱。V1 项目出了不少问题但正是这些问题让我意识到写代码最爽的时刻往往不是功能上线那刻而是某天改需求时发现自己什么都不用改因为当初的封装边界划得刚刚好。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。