ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

V1项目代码封装实战:请求层、SSE流式交互与组件抽象复盘

V1项目代码封装实战:请求层、SSE流式交互与组件抽象复盘 V1版本跑通之后我花了两周时间做了一次系统性的代码封装和项目复盘。这篇文章就从这次封装的整体思路、分层设计、关键代码和踩坑记录这几个维度展开把我在V1项目里沉淀下来的封装经验和教训一次性讲清楚。过程里会涉及请求层封装、接口模块化、SSE流式交互、工具函数沉淀、组件抽象这些具体内容适合正在做项目重构、或者准备给首版项目做技术梳理的开发者参考。1. 先把“封装”这件事想清楚V1项目封装的整体思路1.1 V1项目为什么要做系统性封装V1版本最大的特点是“功能跑通了但代码没法看”。业务验证阶段大家追求的是快速上线接口调用到处写逻辑散落在页面里同样的请求参数、错误处理、鉴权逻辑复制粘贴了不知道多少遍。功能是好的但一旦进入二期迭代每改一个接口字段就要全局搜索替换改完还容易漏。这不是代码风格问题而是工程结构问题。封装解决的不是“代码好不好看”的问题而是三个很实际的问题改动成本、复用效率和出错的概率。拿请求层来说如果不做统一封装每个页面自己发请求、自己处理loading、自己弹错误提示那后端只要调整一次响应结构前端所有页面全要动。封装之后这些逻辑收敛到一个文件里对外暴露的接口保持稳定内部再怎么改都不影响业务代码。还有一层原因是团队协作。V1项目往往是多个人并行开发如果没有统一的封装规范A写的请求方式和B写的请求方式完全不一样代码评审的时候光是统一风格就很费劲。系统级封装相当于把“游戏规则”定下来后面进项目的新人照着封装好的模块用就行不需要理解底层细节。1.2 封装的边界哪些该封装哪些不该动封装最大的坑不是不做而是过度做。我见过一些团队在做V1总结的时候把简单的事情复杂化动不动就搞一套微前端、动态加载、插件化架构出来。封装是在现有代码基础上做收敛和抽象不是推倒重来。我这次封装遵循了几个原则先统计后动手。动手前先全局搜索一遍看看哪些代码模式重复出现把重复次数最多的先抽出来。三层分类。把封装对象分成“请求层”“业务逻辑层”“视图层”每层只做自己该做的事。请求层只管发请求和收响应业务逻辑层只管数据转换和状态管理视图层只管界面渲染。不被“伪需求”绑架。有些代码虽然重复但本质上是业务定制强行抽象成通用封装反而会把简单问题搞复杂。比如某个页面的特殊校验逻辑放页面里就好不要塞进工具函数。“封装继承多态”是面向对象的老三样在V1项目里我遇到的实际映射是封装是把细节藏起来继承是复用公共能力多态是同一套接口适配不同实现。但真实工程里不需要刻意套这个概念按需求拆就好。1.3 渐进式封装不推翻重来的演进策略V1代码库最忌讳“推倒重写”。业务还在跑功能还在加如果花三个月重构等重构完业务早就变了。我这次用的是渐进式封装策略先搭好封装的架子然后每改一块业务就迁移一块不追求一步到位。具体操作流程是这样的新建封装目录api、utils、components、hooks把公共能力先沉淀进去。新写的代码一律走封装后的模块不再裸写请求。老代码按页面优先级逐步迁移每次改动只迁移局部确保功能不回退。每迁移完一个模块跑一遍回归测试记录前后行为差异。这样做的核心原则是封装的架子要稳迁移的速度要慢。架子不稳后面全白搭速度太快容易在迁移过程中引入隐藏bug。2. 请求层封装V1项目最值得投入的地方2.1 axios实例与拦截器的标准配置请求层是整个V1项目里最值得先封装的部分因为几乎每个页面都要用到改动一次受益全局。我的做法是基于axios做二次封装核心是一个实例加两个拦截器。// api/request.ts import axios, { AxiosRequestConfig, AxiosResponse } from axios import { message } from antd import { getToken, clearAuth } from /utils/auth interface ApiResponseT unknown { code: number data: T msg: string } const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }) service.interceptors.request.use( (config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) service.interceptors.response.use( (response: AxiosResponseApiResponse) { const res response.data if (res.code 0) { return res.data } message.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg || 请求失败)) }, (error) { if (error.response?.status 401) { clearAuth() window.location.href /login } const errMsg error.response?.data?.msg || error.message || 网络异常 message.error(errMsg) return Promise.reject(error) } ) export function requestT(config: AxiosRequestConfig): PromiseT { return service.requestunknown, T(config) }这里有个容易忽略的细节响应拦截器里return res.data同时泛型标注Promise 这样业务代码拿到的直接是后端的数据体不需要每次再解一层。很多人封装的时候忽略了类型导致业务里到处是any封装的效果大打折扣。baseURL用import.meta.env这种环境变量方式而不是硬编码原因很简单V1项目往往同时要连开发环境、测试环境、生产环境的接口换环境只需要改配置不需要动代码。2.2 统一响应结构与错误处理策略后端接口如果响应结构不统一前端封装的难度会直线上升。我在V1项目里推动后端约定了一套标准响应结构字段类型说明codenumber0表示成功非0表示业务失败dataunknown实际业务数据成功时才有值msgstring错误信息失败时用于提示这个约定在前后端联调前就要达成不能等封装完了再改。实际执行中后端偶尔会不遵守所以我加入了一个兼容逻辑如果后端返回的data是null但code是0按成功处理如果code不存在但HTTP状态码是200也按成功处理。这种兜底让封装不容易被后端的小偏差打崩。错误处理策略上我分为三层全局兜底请求超时、断网、500等错误统一弹提示。业务错误后端返回的业务失败码message提示msg字段。页面级覆盖某些场景需要特殊处理比如登录过期跳转提供错误回调覆盖默认行为。注意一个原则全局的错误提示是兜底不是主力。不要让所有错误都弹Toast那跟没有封装没什么区别。我一般在请求函数里支持传入一个silent参数业务方明确希望不弹错误的时候传true由页面自己处理。这个设计在V1项目里很实用尤其是那些表单提交场景错误需要展示在表单里而不是弹窗。2.3 请求取消与竞态处理V1项目里有一个典型的场景搜索框每输入一个字符就触发一次搜索上一次请求还没返回下一次已经发出去了响应回来先后顺序乱了页面显示的是上一次的结果。这就是竞态问题。axios的AbortController机制可以很好地解决。// api/request.ts export function requestWithSignalT( config: AxiosRequestConfig { signal?: AbortSignal } ): PromiseT { return requestT(config) }配合业务侧使用// 通用竞态处理 hooks function useRequestT(fetcher: (signal: AbortSignal) PromiseT) { const [data, setData] useStateT | null(null) const [loading, setLoading] useState(false) const abortRef useRefAbortController | null(null) const run useCallback(async () { abortRef.current?.abort() const controller new AbortController() abortRef.current controller setLoading(true) try { const result await fetcher(controller.signal) setData(result) } catch (e) { if (e.name ! AbortError) { throw e } } finally { setLoading(false) } }, [fetcher]) useEffect(() { return () abortRef.current?.abort() }, []) return { data, loading, run } }这个封装的巧妙之处在于run执行时先abort上一次请求再发起新请求。AbortError不会走到catch的业务逻辑分支避免了对旧响应的处理。组件卸载时通过useEffect的清理函数自动abort避免组件销毁后setState引发的内存泄漏。实际使用中你会发现竞态问题不只在搜索场景出现tab切换、分页切换、筛选条件变化都会遇到。把这个能力收敛到封装的hooks里业务方只要用run去触发请求天然就不会有竞态问题。2.4 SSE流式输出封装与大模型AI交互逻辑V1项目里有一块新东西就是大模型AI对话。传统的HTTP请求要等所有内容生成完才返回但大模型的回答是流式的要一个字一个字往外蹦。SSEServer-Sent Events就是为了解决这个场景的。浏览器原生支持EventSource但它只支持GET请求而大模型接口通常需要POST所以我用fetch自己解析流式响应封装了一个工具。// utils/sse.ts export interface SSEOptions { url: string data: Recordstring, unknown onMessage: (content: string) void onDone?: () void onError?: (err: Error) void } export function createSSEStream(options: SSEOptions) { const controller new AbortController() fetch(options.url, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify(options.data), signal: controller.signal, }) .then(async (response) { if (!response.ok || !response.body) { throw new Error(SSE请求失败) } const reader response.body.getReader() const decoder new TextDecoder() 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 payload trimmed.slice(5).trim() if (payload [DONE]) { options.onDone?.() return } try { const json JSON.parse(payload) options.onMessage(json.content ?? ) } catch { // 忽略解析失败的非JSON行 } } } options.onDone?.() }) .catch((err) { if (err.name ! AbortError) { options.onError?.(err) } }) return { abort: () controller.abort(), } }配合abort机制前端可以实现“停止生成”功能用户点停止按钮就调用返回的abort方法中断fetch流。这个中断会在底层关闭网络连接同时后端收到中断信号也会停止生成既省流量又省计算资源。我踩过的坑是译文编码问题。如果服务端返回的是UTF-8TextDecoder默认就能处理。但如果返回的是其他编码内容会乱码。保险做法是把decoder.decode(value, { stream: true })里的stream参数始终传上处理分包边界否则一个中文字符被拆成两个分片时会在拼接处出现乱码。这个参数在SSE流式场景里是必传的。还有HTTP状态码问题。SSE是长连接如果服务端在流结束前断开了连接一次完整的中文回复可能只收到一半。我的处理是加一个心跳检测超过30秒没有新数据就主动abort并触发onError由业务层决定是否重连。这个重试不是无脑重连一般最多重试两次而且要有递增的退避时间否则服务端压力会很大。3. 业务逻辑层与接口封装的细节3.1 API接口模块化封装按领域聚合请求层封装完之后还需要一层接口模块化封装的中间层。请求层处理的是“怎么发请求”接口层处理的是“有哪些请求”。接口层按业务领域分文件每个文件对应一个领域的所有接口导出统一的对象。// api/user.ts import { request } from ./request export interface LoginParams { username: string password: string } export interface UserInfo { id: number name: string avatar: string roles: string[] } export const userApi { login: (data: LoginParams) request{ token: string }({ url: /user/login, method: POST, data }), getUserInfo: () requestUserInfo({ url: /user/info, method: GET }), updateProfile: (data: PartialUserInfo) requestUserInfo({ url: /user/profile, method: PUT, data }), }接口层的价值在于让业务页面跟后端URL解耦。页面代码里永远不出现字符串形式的URL全部通过userApi.getUserInfo()这种方式调用。后端改了URL前端只要改api/user.ts一个文件全站生效。命名规范上我做了个约定login、getUserInfo这种“动词名词”格式动词反映HTTP方法语义get、update、delete、create名词反映操作对象。连接符不用下划线保持一致。V1项目里我自己最烦的就是一个接口层里有get_user_info又有fetchUserDetail这种混搭命名统一之后找接口快多了。3.2 业务逻辑复用hooks与组合式封装接口层只负责收发数据但业务逻辑往往比单纯请求复杂。比如AI对话场景用户发送一条消息后要经历“追加用户消息到列表→显示loading→发起SSE请求→逐字更新assistant回复→请求结束关闭loading”这一串状态变化。这些逻辑散在页面里代码会变得很臃肿而且换个页面就没办法复用。我的做法是把这种带状态的业务逻辑抽成自定义hooks同时把SSE流式解析交给封装的SSE工具页面只关心消息列表和输入框。// hooks/useChat.ts import { useState, useRef } from react import { createSSEStream } from /utils/sse export function useChat() { const [messages, setMessages] useStateMessage[]([]) const controllerRef useRef{ abort: () void } | null(null) const sendMessage async (content: string) { // 追加用户消息 setMessages((prev) [...prev, { role: user, content }]) // 追加空的assistant消息占位 const index Date.now() setMessages((prev) [...prev, { role: assistant, content: , id: index }]) const stream createSSEStream({ url: /api/chat, data: { message: content }, onMessage: (delta) { setMessages((prev) { const next [...prev] const target next.find((m) m.id index) if (target) { target.content delta } return next }) }, onDone: () { controllerRef.current null }, onError: (err) { controllerRef.current null setMessages((prev) [ ...prev, { role: system, content: 生成失败${err.message} }, ]) }, }) controllerRef.current stream } const stop () { controllerRef.current?.abort() controllerRef.current null } return { messages, sendMessage, stop } }这种hooks封装的好处是业务逻辑完全从视图中剥离出来。Vue里对应的写法是组合式函数composables逻辑是一样的。这样做之后同样的对话交互可以轻松用在聊天页、侧边栏浮动助手、批量总结工具等不同入口一套代码到处复用。V1项目里最容易遇到的问题是把所有东西都放hooks里导致hooks参数极其复杂。我的经验是hooks参数尽量是业务语义对象不要是具体的DOM事件hooks内部不要碰DOM操作保持纯逻辑。如果一个hooks超过100行就考虑拆分成更小的hooks。3.3 不要让业务逻辑泄漏到视图层封装做完之后有一个很直观的验证方法看页面代码里还有没有if/else处理业务分支。如果一个页面组件里有大量的“如果状态是空就显示xxx如果错误是404就显示yyy”这种逻辑说明业务逻辑没有完全收敛到hooks或者工具函数里。一个合格的封装应该是页面组件只管渲染和用户交互业务判断放在hooks里数据处理放在工具函数里请求和响应处理放在请求层。我推进V1封装时给团队定的目标是“页面代码里不允许出现fetch、axios、localStorage这三个词”这个强制约定倒逼着大家把逻辑往上抽效果很好。4. 工具层与组件层封装提效的关键4.1 通用工具函数的沉淀与防抖节流工具函数是V1项目最容易积累也最容易失控的地方。我的沉淀标准很简单同一个逻辑在三个以上地方出现就抽成工具函数。用这个标准筛下来V1项目里沉淀最多的几类是防抖节流、日期格式化、数组去重、深拷贝、url参数解析。防抖和节流需要特别注意封装成hooks形式而不是普通函数否则在React/Vue组件里使用会丢失正确的this和闭包上下文。// hooks/useDebounce.ts import { useEffect, useRef, useState } from react export function useDebounceT(value: T, delay 300): T { const [debouncedValue, setDebouncedValue] useState(value) useEffect(() { const timer setTimeout(() { setDebouncedValue(value) }, delay) return () clearTimeout(timer) }, [value, delay]) return debouncedValue }还有一个容易被忽略的细节useDebounce返回的是值不是函数。如果你需要“防抖执行一个函数”那要封装的是useDebounceCallback用useRef保存最新回调引用不然每次渲染都会重建定时器。这个区别在V1项目里让我踩过一次坑后来就把两种场景单独封装成两个hooks避免混淆。4.2 本地存储与业务配置封装localStorage直接裸调的问题很多key散落、取值要手写JSON.parse、取不到值要处理异常。V1项目里我封装了一个带类型的storage工具所有key值集中管理业务方拿到的直接是反序列化后的对象。// utils/storage.ts export const storage { getT(key: string): T | null { try { const raw localStorage.getItem(key) return raw ? (JSON.parse(raw) as T) : null } catch { return null } }, setT(key: string, value: T): void { localStorage.setItem(key, JSON.stringify(value)) }, remove(key: string): void { localStorage.removeItem(key) }, }封装storage的重点不在于代码量而在于两个约定一是key常量统一收敛到一个文件里避免到处引用魔法字符串二是get方法永远要try-catch因为localStorage在隐私模式或者存储满了的情况下会抛异常业务代码不能感知这些底层异常。业务配置封装我采用的是配置文件枚举的方式比如错误码映射、权限码列表、字典项。这些配置散在代码里是最难维护的改一个文案要把整个项目翻一遍。收敛到一个配置文件加上对应的hook比如useDict改起来就只改一处还能附带按需加载优化。4.3 组件封装的三个层次与过度封装红线组件层封装是V1项目里最容易走偏的地方。我总结了三个层次展示型组件按钮、卡片、标签这类组件API稳定改动少可以放心封装。交互型组件下拉选择、日期选择、表格这类组件有自己的状态和行为封装时要提供受控和非受控两种模式方便不同使用场景。业务型组件用户选择器、部门树、附件上传这类组件强绑定业务数据封装时要通过props注入数据源不能硬编码接口调用。组件封装的核心原则是“暴露状态不要暴露DOM”。比如封装一个用户选择器它内部可以自己加载用户列表但必须暴露selectedValue和onChange让父组件知道当前选中了谁。如果业务方发现某个组件不能满足需求优先考虑增强props而不是复制一份组件代码。过度封装的红线也值得警惕。我的判断标准是如果一个组件的props超过15个或者内部条件分支超过5个就要停下来想想——是不是拆成多个单一职责的组件更合理而不是硬塞进一个“万能组件”。V1项目里我见过一个Table组件封装了几十个配置项最后连作者自己都不记得怎么用了这就是典型的过度封装。5. 封装踩坑记录与常见问题排查5.1 封装后接口调不通的三类典型问题封装完成后的联调阶段我遇到过不少奇奇怪怪的问题归纳起来最常见的有三类。第一类是响应拦截器里直接返回res.data但业务代码里还在取res.data.data导致拿到undefined。这类问题的原因是对封装的返回值约定不清晰解决方法是接口层写清楚注释同时用TypeScript的类型让调用方知道返回的就是业务数据。第二类是拦截器报错弹了Toast页面也弹了Toast用户看到两条错误提示。这是因为页面代码在catch里又做了一次提示。我的约定是请求层负责全局兜底提示业务方明确传了silent才不提示否则页面不要重复提示。封装后需要同步更新调用方的编码习惯这个在团队规范里写清楚。第三类是请求取消导致的误报。竞态处理里我abort掉上一次请求但是axios会把abort当成error传给catch逻辑如果业务方在catch里统一弹错误提示就会出现“用户还没操作就弹了个错误”的诡异现象。解决方法是catch逻辑里先判断error.name是否等于AbortError是的话直接return不处理。5.2 封装导致的隐藏bug类型丢失与作用域污染一个容易被忽略的问题封装级别越多类型信息丢失越严重。比如响应拦截器把res.data返回给业务层如果接口层没有正确标注泛型业务代码里就会变成any写代码的时候毫无提示运行时报undefined才发现。这种问题排查起来成本很高因为在浏览器里看到的是页面报错找不到源头。我的经验是封装每一层都必须显式标注返回类型宁可多写几行类型定义也不要让any悄悄渗透进来。V1项目里我专门花了一天时间搜索代码里所有的any并逐个替换成具体类型。看起来很费时间但后面几周写代码的速度明显变快很多错误在编译期就被拦截了。变量作用域污染是另一个隐藏问题。封装工具函数的时候要注意不要污染全局作用域尤其是那些给数组或对象原型挂方法的做法。虽然用起来方便但会导致不同库之间方法冲突排查起来极其痛苦。我的做法是永远不修改原生对象原型一切功能通过导出的纯函数实现。5.3 团队协作中封装规范落地的经验封装不只是技术问题也是规范问题。V1项目里最让我头疼的不是封装本身而是大家不按封装后的模块使用。有人觉得手写请求更快有人觉得直接读localStorage更省事结果封装白做了。后来我总结出来的经验是封装要配套“禁制清单”和“替代清单”。禁制清单明确列出哪些做法在项目里不允许再出现比如直接使用axios实例、直接操作localStorage替代清单明确列出对应的封装模块用法。规范文件比代码review更有效率新人进来看一眼就知道怎么写。代码review的时候要重点检查“绕过了封装层”的代码。我习惯在PR模板里加一个勾选项“代码是否使用了封装的请求模块/存储模块/组件库”如果勾不上来说明新代码在走老路那就打回去重写。常见问题典型现象排查思路响应数据多包一层页面数据是undefined检查拦截器返回值与业务调用是否一致重复错误提示一次错误弹两个Toast检查页面catch逻辑是否重复提示Abort误报错误操作中无故弹错误检查catch是否过滤AbortError类型丢失页面数据是any检查接口层泛型标注是否完整封装过度组件props过多、调用复杂评估是否拆分组件或放宽耦合6. 封装复盘记录几个值得长期沉淀的决策V1封装做完之后我做了一次复盘记录了几个对后续迭代影响比较大的决策。第一个是请求层独立成包。我把它从项目代码里拆出来单独维护一个子目录业务代码只依赖它暴露的接口。这样做的好处是如果以后再开V2项目这个请求层可以整体搬迁不用重新写。第二个是SSE流式处理能力的沉淀。AI交互是V1新增的核心场景这块封装相对完整包括流式解析、停止生成、错误重试三个能力。后续项目只要复制这套封装再接不同的业务回调就能用。实际上这个能力封装的价值远超预期因为AI功能从单聊场景扩展到批量处理、定时生成时流处理逻辑完全没有改动只改了业务数据格式。第三个决策是“配置优先于代码”。V1项目里有不少硬编码的业务参数比如分页大小、轮询间隔、默认超时时间散落在各个页面。封装阶段我把这些参数统一收敛到一个配置文件里通过环境变量区分环境。后续调整参数时不需要发版改配置就行这个决策在V1项目上线后帮了大忙。还有一个小技巧是给所有封装模块补了README注释。每个封装目录下都有一个简短的说明文件记录这个模块解决什么问题、有哪些约定、调用方需要注意什么。这不是给文档写手看的是给三个月后的自己看的——理论上自己写的封装应该记得怎么用实际上真到了三个月后看到一排参数真的会忘了为什么要这么设计。我在封装过程中最深的一点体会是封装不是说代码抽出来就完事了要把“为什么这么封装”也沉淀下来。V1项目跑得快很多技术决策都是赶着做的如果不记录下来后面的人不知道为什么请求层要统一处理鉴权、为什么AI对话的流式逻辑要抽成独立模块就可能在新需求里重新发明一遍而且发明得还不如原来。真正的项目总结不只是代码的整理也是决策逻辑的整理。封装是手段不是目的。V1项目里这一圈做下来最大的收获不是代码变整齐了而是迭代的速度提升了——改一个接口从全局搜索改成改一个文件加一个新页面从复制粘贴改成调用现成模块。这才是V1项目封装中最有价值的部分。
返回列表