ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

V1前端系统性封装实践:从axios二次封装到SSE流式请求

V1前端系统性封装实践:从axios二次封装到SSE流式请求 写代码这行字的时候我脑子里冒出来的是我们V1项目里各种历史遗留的面条式代码同一个接口的请求逻辑在三个页面里各写了一遍有的带token有的不带AI对话流式渲染那段逻辑更是惨不忍睹EventSource连接在组件销毁后照样跑页面切几次就白屏。所以当新一版迭代排期下来时我做的第一件事不是加需求而是把V1项目从头到尾做了一次系统性的封装与总结。这篇文章就是这次封装工作的完整复盘包含每一步的设计原因、核心实现和踩坑记录适合正准备做代码梳理、接口层收敛和组件复用的前端朋友参考。1. 项目背景与封装思路1.1 为什么V1阶段就要搞封装很多团队的习惯是先把功能跑通重构以后再说但我在这个V1项目里吃亏了。项目初期为了上线接口请求都是直接复制粘贴鉴权、错误提示、loading状态全部散落在业务代码里。等AI对话功能加进来之后问题更明显SSE流式响应需要在每一条消息到达时实时更新界面同时还要支持用户在生成过程中点击停止如果不在底层封装好统一的连接管理和中断机制业务组件里就会堆满各种addEventListener、removeEventListener、abort调用代码体积先不管光是心智负担就足以让新同学看很久才敢动。所以我坚持在V1阶段做封装核心目的有三个第一是收敛重复逻辑让相同的请求处理只写一次第二是隔离复杂度把SSE、axios这些技术细节关在api层内部业务组件只调用一个方法第三是建立规范团队所有成员写出来的请求代码风格一致出问题知道去哪个文件里找。现在回想起来这三天封装时间换来了后面至少三周的开发效率提升非常值得。1.2 分层设计与模块边界封装之前先定边界否则就是拿着放大镜在意大利面里找土豆。我参考了一套非常简单的分层思路把前端代码分成四层api层负责所有网络通信包括axios实例、SSE连接、鉴权头types层负责接口入参和返回结构的类型定义components层负责可复用的UI组件utils层放纯函数工具。业务页面只允许引用api、types和components不允许在业务代码里直接出现axios或fetch。目录结构大概是这样src/ ├── api/ │ ├── http.ts # axios 二次封装 │ ├── sse.ts # SSE 流式请求封装 │ ├── auth.ts # 登录相关接口 │ └── chat.ts # AI 对话相关接口 ├── components/ │ ├── RemoteSelect/ │ └── StatusTag/ ├── types/ │ └── chat.ts └── utils/ ├── format.ts └── storage.ts这个结构谈不上惊艳但它让每个人都很容易定位代码。比如AI回答渲染闪烁问题大家第一时间会去看api/sse.ts和渲染组件而不会在页面里翻几百行找状态位。分层最大的价值不是代码好看而是把变化隔离在固定的模块内后端改接口返回值时我只需要动api和typesUI层基本不动。2. 接口请求层的封装实践2.1 axios二次封装的核心配置接口层我基于axios做二次封装没有自己造fetch轮子因为axios的拦截器机制、取消请求方式、实例化配置已经很成熟团队也熟悉。封装的第一步是创建一个独立的http实例而不是直接用全局默认的axios这样可以避免污染环境也能为不同后端服务创建不同实例。基础配置如下import axios from axios; import { getToken } from /utils/storage; import { loadingStore } from /stores/loading; const http axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }); http.interceptors.request.use((config) { const token getToken(); if (token) { config.headers.Authorization \Bearer ${token}\; } config.showLoading config.showLoading ! false; if (config.showLoading) { loadingStore.add(); } config.headers[X-Client-Version] import.meta.env.VITE_APP_VERSION; return config; });这里有两个容易忽略的点。一是timeout我设置了15秒但AI对话的SSE接口不能用这个超时所以SSE请求我会单独走api/sse.ts不走这个常规http实例。二是token没有存在localStorage而是放在内存变量和storage做了配合原因是localStorage容易被XSS脚本读取token放内存里相对更安全刷新页面后通过getToken()从storage恢复。具体的存储策略可以按团队安全级别调整但思路是统一收敛不能每个页面自己读localStorage。2.2 拦截器的职责划分与类型扩展请求拦截器只管附加公共信息和启动loading响应拦截器管统一拆包和错误处理。我们的后端返回结构约定为{ code, message, data }code0代表成功非0代表业务失败。响应拦截器里必须把HTTP状态码和业务码分开判断如果HTTP状态码是200再去看业务code如果是401、500这种走标准错误分支。axios的TypeScript类型里没有showLoading和silent这两个自定义字段直接写在config上会报类型错误。我通过模块扩展解决declare module axios { export interface AxiosRequestConfig { showLoading?: boolean; silent?: boolean; } }响应拦截器的核心逻辑是这样的http.interceptors.response.use( (response) { loadingStore.done(response.config); const { code, message, data } response.data; if (code 0) { return data; } if (code 401) { redirectToLogin(); } if (!response.config.silent) { message.error(message); } return Promise.reject(new Error(message)); }, (error) { loadingStore.done(error.config); if (error.code ECONNABORTED) { message.error(请求超时请稍后重试); } else if (!error.config?.silent) { message.error(error.message || 网络异常); } return Promise.reject(error); } );业务码为0时直接返回data这样调用方拿到的直接是业务数据不需要每次写.data.data。错误处理里加了silet开关是为了某些静默刷新场景——比如用户切换tab时后台拉取列表失败了不应该弹红色错误提示调用方传入{ silent: true }然后在自己的catch里做降级处理。2.3 loading计数器的正确写法全局loading用了一个计数器原因是多个请求并发时第一个完成后不能立即关闭loading否则会出现闪烁必须等所有在途的请求都结束计数归零才关闭。这个计数器本身也是封装的一部分我把它放在stores/loading.ts里export const loadingStore { count: 0, add() { this.count; }, done(config?: { showLoading?: boolean }) { if (config?.showLoading false) return; this.count Math.max(0, this.count - 1); if (this.count 0) { globalLoadingVisible.value false; } }, };这里要注意请求拦截器中已经把showLoading默认成true了所以响应拦截器里的done可以直接根据config.showLoading减数。但有一种情况容易漏请求在发送阶段直接报错比如网络中断此时请求拦截器已经执行了add()但响应拦截器也执行了done(error.config)逻辑上是配对的。如果不是走的统一拦截器而是在业务代码里自己创建的promise那loading就会挂在状态里这也是为什么所有请求必须走封装后的http实例的原因。3. AI交互逻辑的SSE封装3.1 为什么选SSE而不是WebSocketAI对话这个场景服务器需要把一整段回答拆成多个token持续推给前端数据流向是单向的——服务端到客户端。SSEServer-Sent Events天然就是为这种单向实时推送设计的它基于普通HTTP内部自动实现断线重连机制而且消息格式是标准明文调试非常方便。WebSocket当然也能做但它是一个双向长连接需要处理心跳、粘包、二进制帧复杂度明显更高。在这个项目里我们没有双向实时的需求所以选SSE更合适。选定SSE之后又遇到一个限制浏览器原生的EventSource只支持GET请求而我们的AI接口需要传很长的上下文内容GET会有URL长度限制。解决方案是使用fetchReadableStream手动解析流式响应这样既能用POST传body又能拿到流式数据。3.2 基于fetch的SSE流式解析封装我在api/sse.ts里封装了一个ssePost函数核心思路是读取响应体的流按行拆出data:前缀的负载然后通过回调对外暴露消息内容。第一次写这个功能时最容易踩的坑是网络传输会把一段完整数据拆成多个chunk如果直接对每个chunk做split(\n)就会出现半行数据解析失败。解决办法是维护一个buffer每次把新chunk拼接进来然后按换行符切分末尾保留可能不完整的行等下一个chunk再继续处理。export async function ssePost( url: string, body: unknown, callbacks: { onMessage: (raw: string) void; onDone: () void; onError?: (err: Error) void; signal?: AbortSignal; } ) { const { onMessage, onDone, onError, signal } callbacks; const resp await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: \Bearer ${getToken()}\, }, body: JSON.stringify(body), signal, }); const reader resp.body?.getReader(); if (!reader) throw new Error(浏览器不支持流式读取); 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 [DONE]) { onDone(); return; } onMessage(payload); } } } onDone(); }这里我故意没有在解析时直接JSON.parse因为onMessage可能是把原始字符串交出去由外部解析也可能自行拼接纯文本解耦更灵活。到底什么是结束标识需要和后端约定我们用的[DONE]是OpenAI兼容协议里的常见结束标记你的后端可能是data: [DONE]也可能是event: done按实际调整即可。3.3 配合AbortController实现中断与清理SSE封装里最重要的一个配套组件是AbortController。用户点击停止生成、切换会话、关闭页面时都需要立刻中断正在进行的fetch请求。如果不中断后端会继续生成token产生不必要的费用前端也会收到已经不存在的分量更新。封装层面我让ssePost接收一个外部传入的signal然后在调用方再包装一个createSSEHandle函数把abort函数暴露出去export function createSSEHandle( url: string, body: unknown, callbacks: Parameterstypeof ssePost[2] ) { const controller new AbortController(); const promise ssePost(url, body, { ...callbacks, signal: controller.signal }); return { promise, abort: () controller.abort(), }; }组件里使用时就非常干净const handle createSSEHandle(/api/chat, { prompt }, { onMessage: (raw) appendAnswer(raw), onDone: () setGenerating(false), onError: (err) { message.error(err.message); }, }); handle.promise.finally(() { isAbortedRef.value false; });特别注意捕获错误时一定要判断error.name AbortError因为主动abort产生的错误不应该被当成异常提示给用户。我在ssePost里catch到错误后如果名字是AbortError就直接吞掉否则才调用onError。3.4 把SSE封装成可复用的组合式函数为了让业务组件彻底脱离SSE细节我把整个AI对话状态封装成了一个Hook/组合式函数useChatStream。它内部管理了消息列表、isGenerating状态、停止方法对外只暴露sendMessage(text)和stopGenerate()。这样页面组件只需要关注UI渲染和用户操作所有网络连接、缓冲、abort都在Hook内部处理。这个封装里有一个经验教训在组件卸载时必须调用abort并且要在Hook内部通过isUnmounted标志防止回调里更新已卸载组件的状态。尤其是在开发模式组件可能被React.StrictMode挂载两次如果不做保护界面上可能会残留一次幽灵请求。4. 组件与工具层的封装提炼4.1 高频组件的二次封装除了网络层V1项目里的UI组件也需要整理。我原则很简单一个组件在三个以上页面重复出现并且带相同交互逻辑时才有抽取价值。比如我们有一个用户状态标签需求在各个列表页都要显示在线、离线、繁忙等状态过去的写法是每个页面复制一段模板和颜色映射后来改成StatusTag组件传一个status字段进去内部统一映射颜色和文案。再比如远程搜索下拉框我给el-select做了一层二次封装命名为RemoteSelect。它封装了防抖搜索、加载态、option渲染对外用v-model绑定选中值通过attrs透传原生的clearable、placeholder等属性。二次封装组件最需要注意的就是v-model的实现要接收modelValueprop并向外触发update:modelValue事件不能直接改props。还有对于第三方UI库的组件attrs透传能省掉我们重新声明几十个props的麻烦。4.2 工具函数库的整理与单测工具函数模块我按文件拆分format.ts放日期、金额、文件大小格式化storage.ts统一封装storage读写内部做JSON序列化validator.ts放手机号、邮箱、身份证校验debounce.ts放防抖节流。一个重要的约定是工具函数必须是纯函数不引用任何全局状态和DOM这样才可以单测并且在多个端复用。比如日期格式化这个函数我在V1项目里经常在不同地方被复制但每个地方的写法还不一致有的直接toLocaleString()有的自己拼字符串结果不同入口显示的日期格式对不上。封装后统一为export function formatDate( input: string | number | Date, pattern YYYY-MM-DD HH:mm:ss ): string { const d new Date(input); const map: Recordstring, string | number { YYYY: d.getFullYear(), MM: String(d.getMonth() 1).padStart(2, 0), DD: String(d.getDate()).padStart(2, 0), HH: String(d.getHours()).padStart(2, 0), mm: String(d.getMinutes()).padStart(2, 0), ss: String(d.getSeconds()).padStart(2, 0), }; return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) String(map[match])); }配合vitest写单元测试后重构信心大增。项目里任何对时间的格式化处理都统一走这个函数不会出现差8小时或者月份少1这种低级问题了。4.3 配置与环境变量的统一管理V1项目初期接口地址、模型温度参数、埋点ID都散落在各个页面里甚至有人把测试环境的地址写死在组件里。这次封装我新建了config.ts把所有环境变量统一出口const env import.meta.env; export const config { apiBaseUrl: env.VITE_API_BASE_URL, appVersion: env.VITE_APP_VERSION, aiModel: env.VITE_AI_MODEL || default-model, aiTemperature: Number(env.VITE_AI_TEMPERATURE || 0.7), };然后使用zod对这几个必填变量做运行时校验比如apiBaseUrl必须是合法URLaiTemperature必须是0到2之间的数字。这样如果部署时环境变量漏配前端启动时控制台会立刻报错而不是等到某个接口发出去才发现地址是undefined。前端环境变量有个铁律永远不要把后端密钥、数据库密码塞进去任何客户端代码里的密钥都不安全这些信息必须由后端持有。5. 封装过程中的常见问题与排查实录5.1 全局loading在并发请求下闪烁第一次改造时我用的是单个布尔变量一个请求发起时loadingtrue响应回来就loadingfalse。结果两个接口同时发起第一个先回来把loading关了页面表格已经显示了半截内容第二个才回来又加载一遍用户体验特别差。后来才改成计数器方案这是非常典型的问题。顺便提示一点如果页面里有局部loading比如按钮加载态不要塞进全局计数器用局部状态更精准。5.2 SSE连接异常断开的自动重连AI对话时如果断网两秒或者后端服务主动断开流式请求会静默结束用户看起来就是答案没生成完就停了。我给ssePost外层加了一个retry逻辑支持传入最大重试次数。但重连时要特别注意用户点击停止产生的abort不能重连只有非用户主动发起的、非AbortError的错误才触发重连。重连间隔采用递增策略第一次1秒、第二次2秒、第三次4秒叫指数退避避免重连风暴。实现的时候不要把重连逻辑写进ssePost内部因为它会导致回调被重复注册更好的方式是放在createSSEHandle这一层用递归去重新发起请求。5.3 部分封装其实是过度设计封装过程中要时刻警惕为了封装而封装。比如我在V1项目里曾经试图把localStorage封装成一个通用Store设计了一大堆getter/setter和过期时间结果整个项目实际只用了两个字段纯属浪费。另一个常见问题是为了统一请求错误提示把所有接口的所有错误都弹全局提示导致某些静默校验接口在业务层根本没法处理异常。封装不是越厚越好一个原则是当调用方需要传入大量配置去绕过默认行为时说明这个封装的默认行为可能过于强势。正确的做法是提供默认值同时保留按需覆盖的出口。最后想说的封装这个动作看起来是在藏逻辑实际上是在为未来的改动留空间。V1项目时间紧我们当时花了两三天做这次梳理排期口头挨了不少骂。但后来的新需求让所有人都尝到了甜头新增一个页面时接口方法、类型定义、AI流式逻辑直接复用组件从组合库里拖出来拼装基本半天就能完成一个过去需要两三天的功能。我更深的体会是封装没有唯一正确的答案它是团队共识的产物——只要你把边界划清楚、把文档写明白哪怕目录朴素也能让协作顺畅。如果哪天你也准备动手重构一个能跑但很脆的V1项目希望这篇总结里的方向和坑能帮你少走点弯路。
返回列表