V1项目交付那天我在发布验证通过后做的第一件事不是开香槟而是把半年的代码从头翻了一遍边看边记。这个动作看起来很笨但后来证明它比加班写新功能更值钱——因为它产出了一套可以被V2直接使用的“封装”。这篇文章就是我对这个V1项目封装与总结的完整复盘。先声明一下这里的“封装”不是芯片封装、PCB封装那一类硬件术语而是软件工程里对请求、组件、工具函数、消息中间件的二次封装。重点会讲清楚我在请求层、流式输出、组件库、后端中间件各自封装了什么、为什么这样封装、踩了哪些坑。如果你也在做AI交互类应用或者手头有一个需求叠加到快要失控的项目这篇内容应该能帮你省下几个晚上的排查时间。1. 项目背景V1为什么需要一次“封装式复盘”1.1 V1阶段最容易出现的“代码半成品”状态V1阶段最大的特点是赶节奏。做原型验证的时候功能是“先跑通再说”的逻辑接口请求散落在各个页面里有的用axios有的用fetch有的甚至是从某个旧项目里复制来的一段带token的请求代码错误提示要么不弹要么每个页面弹各自的弹窗SSE流式输出的解析逻辑在聊天页面里写了一大坨改一次崩三处。这种状态我叫它“代码半成品”功能能用但任何一次改动都在积累技术债。比如后端把接口返回结构从{ code, message, data }改成{ status, data }散落各处的请求代码就要挨个改比如登录态过期后有的页面会白屏有的会弹出两个登录框有的干脆卡住不动。这些问题在V1阶段不会立刻爆发因为页面少、改动小但等到V2要加新模块、新交互时这些散装的代码会以几何级数拖慢开发速度。所以我赶在V1功能冻结、业务需求还没大规模涌进来的窗口期专门腾出时间做了一次“封装式复盘”。复盘不是重写代码而是把重复的、脆弱的、隐藏约定全部抽象出来变成团队看得见、用得上的公共层。1.2 复盘时界定的封装边界封装这件事最怕的是没有边界什么都想塞进去。我这次做的第一件事不是写代码而是列边界清单。我明确要封装的请求层统一入口、统一鉴权、统一错误码处理、SSE流式客户端、通用UI组件、工具函数、消息队列的发布订阅服务、第三方SDK的适配层。明确不封装的具体业务页面、跟业务强耦合的格式化逻辑、一次性脚本。判断标准很简单如果一个函数或组件会被两个以上地方以“几乎一样的方式”调用并且调用方式在未来一年内不会频繁变化就值得封装如果只有一个页面用而且连你自己都说不清它会不会改就先不封装。过度封装是另一个常见的坑我见过有人把两行代码也抽成Utils结果一个项目几十个util文件每个文件一两行找起来比写起来还累。封装应该发生在第三个重复出现的时候而不是第一次出现的时候。2. 请求层封装从axios到统一请求入口2.1 统一实例与拦截器设计我实测下来axios二次封装是投入产出比最高的一件事。最基础的做法是创建一个request模块内部用axios.create生成实例配置baseURL和超时时间请求拦截器里统一从store或本地缓存取token加进Authorization头响应拦截器里统一处理HTTP状态码和业务错误码。这样做的直接效果是所有页面请求的鉴权逻辑从几十个文件里消失了后续换登录方案比如从JWT换到SSO只需要改一个文件。先看代码import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use( (config) { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) service.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { if (error.response?.status 401) { // 跳登录页 } return Promise.reject(error) } ) export default service这里有几个细节值得注意。第一不要把整个response返回给页面拦截器里判断code 0之后直接返回res.data页面里写起来会非常干净。第二401的处理一定要放在响应拦截器里否则每个页面都要自己判断“要不要跳登录”。第三超时时间不要全局一个值下载接口和普通查询接口的超时应单独配置流式接口甚至不应该设置固定超时我后面在常见问题里再讲。2.2 封装多域名与多环境的切换V1项目里有个特殊需求同一个前端应用需要同时调用内网服务和公网服务而且H5端要支持指向两个不同域名。这个问题在开发期不明显一到联调就乱了一会儿这个接口跨域一会儿那个接口404。解决办法是把域名配置统一收口。微信小程序请求封装、uniapp封装H5如何指向2个域名本质是同一件事环境与域名的映射表不能散落在业务代码里。我用的方案是维护一份配置文件里面按环境维护多套域名映射。小程序端可以用process.env.NODE_ENV或自定义编译模式来区分H5端则更简单直接用构建环境变量。核心思路是先定义一个基于环境变量的配置对象再根据请求标识选择对应的实例。// config.js const domainMap { development: { apiBase: http://192.168.1.10:8080, h5Base: http://dev.example.com }, production: { apiBase: https://api.example.com, h5Base: https://h5.example.com } } export const getBaseURL (type) domainMap[import.meta.env.MODE || development][type]这样页面里不出现任何硬编码的域名。请求封装层再维护两个axios实例一个走业务API一个走H5域名请求时根据config.mark自动路由到对应实例。现在遇到新环境、新域名只需要在domainMap里加一行配置不需要动业务代码。还有一个补充经验如果同一个环境需要动态切换多个域名比如灰度环境按用户分流建议把映射表放到后端配置中心或启动时拉取而不是再维护一份前端配置。前端配置一旦膨胀就成了新的技术债。2.3 SSE流式输出与大模型接口封装V1项目是做AI对话助手的核心交互是大模型回答需要打字机式实时渲染。这个功能最容易翻车也最值得封装。先说结论用SSE流式输出时不要自己手写EventSource去解析每个数据块要把“建立连接、接收流、解析JSON、处理错误、取消请求”封装成一个独立的流式请求模块。我最终封装了一个带AbortController的请求函数核心逻辑是export async function fetchChatStream({ messages, onMessage, signal }) { const response await fetch(/v1/responses, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${getToken()} }, body: JSON.stringify({ messages }), signal }) if (!response.ok) { throw new ApiStreamError(response.status, await response.text()) } const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { value, done } 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]) return try { const json JSON.parse(data) onMessage?.(json) } catch (e) { // 说明数据半包留在buffer里等下一次读取 } } } }这里有个关键点后端如果用的是POST SSE大多数大模型接口都是这样不能直接用浏览器原生EventSource因为EventSource只支持GET。所以我才用fetchReadableStream来读流。另一个关键点是半包合并服务端下发的数据可能会被TCP拆成多段必须在客户端做buffer拼接按换行符切分后再解析否则会频繁出现JSON.parse报错。调用方再用AbortController控制取消。用户点击“停止生成”时调用controller.abort()fetch的signal会直接中断连接。这块的完整逻辑我会在组件封装部分继续展开。3. 组件与逻辑封装让业务代码变薄3.1 通用组件封装原则页面多了以后最先失控的不是请求而是UI组件。我在V1项目里维护了一套基础组件SubmitButton带loading防重复提交、ConfirmDialog二次确认、EmptyState空状态、Skeleton加载骨架。封装原则是组件只做通用交互不做业务判断。比如SubmitButton接收一个async onClick内部自己处理loading和禁用但它不关心提交流程里是登录、下单还是发消息。组件内部只负责“点击后变loading、Promise结束恢复”。一个常见的反面案例把具体的业务字段写进组件props里比如一个TopicList组件里直接写死了topic.category的取值和颜色映射。等第二个业务要用时你不得不复制一份再改。正确做法是把可变的字段映射通过render prop或插槽暴露出来组件只负责布局和加载态。封装组件时我还坚持一条props宁可少不要多。如果一个组件的props超过8个说明它的边界没划好要么拆成几个子组件要么把配置集中到一个options对象里。V1后期我们团队的新人上手速度明显加快很大程度就是靠这套收敛好的基础组件——他们不需要关心内部实现看一遍示例就能拼出页面。3.2 基于组合式函数封装AI交互逻辑V1项目里聊天页面的交互逻辑非常重发送消息、维护消息列表、滚动到底部、重试、停止生成。如果这些全靠一个组件方法堆基本没法维护。我的做法是抽成useChat这个组合式函数在Vue里叫composable在React里叫hook。核心设计是对外暴露messages、sendMessage、stop、retry、status对内管理流式请求、AbortController、消息历史。页面组件只需要调用useChat然后渲染messages即可。这样“基于什么技术栈封装AI交互逻辑”的答案就很清楚了逻辑封装在组合式函数里UI封装在组件里两者只通过状态和事件通信。export function useChat() { const messages ref([]) const status ref(idle) let controller null async function sendMessage(content) { const userMessage { role: user, content } messages.value.push(userMessage) status.value loading controller new AbortController() let fullText await fetchChatStream({ messages: messages.value, signal: controller.signal, onMessage: (chunk) { fullText chunk.delta || const last messages.value[messages.value.length - 1] if (last?.role assistant) { last.content fullText } else { messages.value.push({ role: assistant, content: fullText }) } } }).catch((err) { if (err.name ! AbortError) { status.value error } }) status.value done } function stop() { controller?.abort() } return { messages, status, sendMessage, stop } }这个useChat可以直接被Web页面、小程序页面复用只要请求层不变。这也是我这次封装总结里最满意的一部分。实际开发中还补了两个能力重试时自动把最后一条assistant消息弹出再请求切走页面时自动stop()避免组件卸载后流还在跑。3.3 生成器封装迭代与轮询还有一个比较偏门但很实用的封装用generator迭代器封装函数来处理分批请求和轮询。V1里有个数据同步功能要从后端分批拉取大量数据直到全部拉完。一般写法是写一个while循环中间夹一堆状态变量读起来很费劲。我用生成器把“每次取下一页”变成可以for await...of遍历的序列async function* fetchAllPages(fetchPage) { let page 1 let hasMore true while (hasMore) { const { list, totalPages } await fetchPage(page) yield list hasMore page totalPages page 1 } } for await (const list of fetchAllPages((page) api.getList({ page, size: 100 }))) { // 每页结果直接消费 }这样写的好处是调用侧不用关心分页状态逻辑也更好测试。生成器把“迭代”这个行为抽象出来循环结构在生成器内部维护业务代码只需要消费结果。不过要提醒一句generator封装适合“顺序消费”的场景如果要做并发拉取还是用Promise.all配合批次控制更合适别为了优雅牺牲性能。4. 后端与中间件的封装实践4.1 接口层与消息队列封装V1项目的后端有一部分是.NET WebAPI业务里要发RabbitMQ消息。第一版代码里每个业务方法都自己new一个Connection结果就是连接数爆炸、性能奇差。后来我把RabbitMQ封装成一个独立的服务类对外只暴露PublishAsync、SubscribeAsync等语义化方法连接管理和重连逻辑全部收进内部。public class RabbitMqService : IDisposable { private IConnection _connection; private readonly object _lock new object(); public void EnsureConnection() { if (_connection ! null _connection.IsOpen) return; lock (_lock) { if (_connection ! null _connection.IsOpen) return; var factory new ConnectionFactory { HostName configuration[rabbitmq:Host], Port int.Parse(configuration[rabbitmq:Port]), UserName configuration[rabbitmq:UserName], Password configuration[rabbitmq:Password], AutomaticRecoveryEnabled true }; _connection factory.CreateConnection(); } } public Task PublishAsync(string exchange, string routingKey, object message) { EnsureConnection(); using var channel _connection.CreateModel(); var body JsonSerializer.SerializeToUtf8Bytes(message); channel.BasicPublish(exchange, routingKey, body: body); return Task.CompletedTask; } }要点有三个连接单例化用锁保证并发安全开启AutomaticRecoveryEnabled依赖方不会感知到网络抖动channel按需创建而不是长期持有因为channel不是线程安全的。这样封装之后业务代码里调用RabbitMqService.PublishAsync一行就完事。后来团队打算换Kafka也只是替换这个服务类的内部实现业务代码完全不用动这就是封装带来的可替换性。4.2 API文档路由的坑Swagger 404V1项目发布后遇到一个很典型的问题WebAPI部署到IIS后访问/swagger/v1/swagger.json返回404。第一反应是Swagger没配置对折腾半天发现是路由前缀的问题。IIS部署时如果站点有虚拟目录Swagger的路径会带着目录名浏览器访问路径不对自然404。解决办法是改SwaggerEndpoint的相对路径app.UseSwaggerUI(c { c.SwaggerEndpoint(v1/swagger.json, My API V1); });或者全局配置一个相对路径的基地址。这个问题本质和“封装”有什么关系关系在于如果API文档访问入口被封装成标准约定团队就不需要每次部署都排查一遍这个坑。所以我在总结里特意把这类部署期问题单独拉出来写进排坑手册。另外还要注意如果项目用了多个版本控制比如v1和v2SwaggerEndpoint里的v1指的是Swagger文档名而不是版本号别混了。5. 常见问题与排查技巧实录5.1 SSE请求返回502 Bad Gateway怎么办V1联调期间我收到过最多的报错是unexpected status 502 bad gateway而且出现在/v1/responses这个流式接口上。502本身是网关错误意味着请求已经到代理层但上游服务没正常响应。SSE场景下特别容易误判你看到页面发请求等了很久然后502以为是自己代码问题其实可能是网关超时或服务端在流式过程中主动断连。排查路径很关键。先看网关超时时间大模型流式接口的TTFB首包时间通常比普通接口长网关默认的读超时如果只有60秒很可能不够。接着用curl直接打后端服务跳过网关看是否正常curl -N --no-buffer http://127.0.0.1:15721/v1/responses \ -H Content-Type: application/json \ -d {messages:[]}如果直连正常而经过网关502问题基本在网关配置或代理层如果直连也502再看后端日志可能是模型服务token超限或响应体过大被中断。建议把这类现象整理成速查表现象可能原因处理办法直接请求后端正常走网关502网关读超时阈值太短调大proxy_read_timeout请求一开始就502网关连不上上游或上游启动失败检查上游端口与健康检查流式输出到一半502上游进程异常退出或响应流中断抓后端日志查内存/异常堆栈日志无异常但客户端收到502上游返回了非200状态观察上游实际status code还有一个容易踩的本地调试时把后端地址写成127.0.0.1前端页面跑在另一个端口跨域没配好时浏览器会报CORS错误而不是502别把两者搞混。CORS错误通常在Network面板看不到完整响应头而502能看到网关返回的状态码两者特征差异很大。5.2 封装后的接口报“invalid url”或“unexpected endpoint”V1中有人图方便直接在代码里拼接API路径结果出现类似invalid url (get /v1)或者unexpected endpoint or method. (options /v1/models)的问题。这说明请求的URL路径和HTTP方法跟服务端路由对不上。常见原因有两个一是封装层把baseURL和url重复拼接导致/api/v1/v1/xxx这种路径二是后端路由配置里方法不一致比如前端POST后端只接受GET于是出现了options /v1/models这种预检请求直接404的情况。把请求统一收口进request模块之后这类问题可以快速定位直接打印最终请求URL区分是封装层拼错还是后端路由问题。我的另一个经验是给请求模块加一个debug模式开启后自动在控制台打印method、url、params。上线后可以把debug模式关闭联调时打开能省很多沟通成本。5.3 AbortController取消后组件还在更新状态这是前端最容易踩的坑。用户点击“停止生成”后流被abort了但onMessage回调里可能还在往状态里塞数据导致组件出现“已停止但内容还在跳”的诡异现象。原因是abort只是中断网络没能阻止后续回调继续触发。解决办法在abort时设置一个标志位回调里先判断let isAborted false controller.signal.addEventListener(abort, () { isAborted true }) function handleMessage(data) { if (isAborted) return // 更新UI }另外要注意onMessage里更新如果用的是引用类型比如直接改messages数组中最后一个对象的content在React里可能不触发重渲染要记得用不可变更新。在Vue里改引用类型没问题因为Vue的响应式是基于代理的但React的setState要求新引用。这个差异很容易让同时写两个框架的人犯迷糊。还有一个细节abort之后fetch会抛一个AbortError如果在useChat里没有判断错误类型就直接把status置为error页面会闪现一个错误状态。我后来统一在catch里判断err.name ! AbortError再置error才算把这个坑填平。写到这里其实“封装和总结”这件事已经不需要再堆新内容了。我想分享几个在V1项目提交后自己最认同的验收标准也当是给这篇文章收个尾。第一调用方不需要知道底层实现。拿RabbitMQ来说业务同事看到PublishAsync就能直接写不需要理解Connection、Channel这些概念这就是封装成功。如果调用方还得翻源码才能用说明封装层设计得不够。第二封装层要有明确的所有者。V1项目里最怕的是“公共模块”没人认领谁都能提改动坏味道一堆。我做总结时给每个封装模块指定了review负责人V2一开始就有了明确的技术责任人。第三不要过度封装。封装应该发生在第三个重复出现的时候而不是第一次出现的时候这是我反复强调的一点。最后分享一个保留习惯每次封装完我会顺手写一份“调用方示例”放在examples目录下别人看一遍就会用。写文档的投入是固定的但能省下N倍的答疑时间。V1的总结不是一字一句去重读代码而是把这半年里那些“只可意会”的约定变成V2团队看得见、用得上的资产。这是我认为做一次项目封装总结最值得的原因。