“V1”这个词出现在项目里最多的场景,通常不是版本号本身,而是一句带着点心虚的话:“先出一个 V1,跑通就行。”我参与过不少这类项目,也见过太多 V1 变成交付前的“烫手山芋”。这个项目挂的名字很直白:V1 项目封装与总结。它对应的不是某个抽象概念,而是把一个已经验证过的 AI 交互原型,从“能跑”整理成“能交付、能复用、能交接”的版本。封装是其中最核心的动作,总结是最后必须落地的产物。
这篇整理主要面向两类人:一类是刚写完第一个可运行版本、正在犹豫要不要重构代码的开发者;另一类是把 AI 对话类功能集成进 H5、小程序或后台产品,却不知道怎么处理流式输出、请求层和组件边界的同学。我先说说 V1 项目为什么要封装,再按请求层、AI 交互逻辑、组件层、踩坑实录与总结文档这几个方向,把整个过程拆开讲。
1. 封装前先想清楚:V1 项目真正缺的是什么
1.1 从 demo 到 V1:不是功能问题,是结构问题
demo 阶段的目标是“证明这件事可行”。我当时的第一版原型很简单:一个输入框、一个发送按钮、一个回答容器。点击按钮后,把用户问题发给大模型接口,SSE 流式返回的内容一段段渲染到页面上。测试下来回答能显示,滚动能看,按钮能点,看起来已经“跑通了”。
但真正进入 V1 交付清单时,问题全冒出来了:同样的 fetch 逻辑散落在页面里,错误处理是一堆重复的 if;后端域名换了小版本,前端的代码要改三处;用户快速连点“发送”,会出现多条并发请求,回答界面乱到没法看;最麻烦的是点击“停止生成”只会清空前端文案,底层请求根本没有取消,服务端的流量还在跑。
所以这里要先纠正一个认知:V1 缺的不是更多功能,而是结构。你不可能在“一条平铺直叙的事件流”里长期稳定地维护网络状态、交互状态和业务状态。封装的本质就是给代码划边界,把页面和网络之间、组件内部和组件外部之间、调用者和被调用者之间的规则定下来。边界清晰后,功能才谈得上可控。
1.2 封装的本质:给代码划边界
很多人一提“封装”,就想到三大特性里的“封装继承多态”,以为封装就是写个 class、把办法藏起来。实际操作中,封装更重要的价值是边界:调用方不需要知道内部细节,只依赖一个稳定的入口和一套明确的返回值。
我习惯用一个厨房的类比。后厨有自己的备菜区、炒菜区和出菜口;菜单上写什么,客人就看什么。客人不会闯进后厨去看菜怎么切、火怎么调,只需要按菜单下单、拿到菜。代码里的接口层封装配的是“出菜口”,组件封装是“菜单”,AI 交互逻辑封装是“后厨的标准化流程”。
在 V1 项目里,我把要处理的边界分成三类:
- 函数边界:把流式解析、错误码转换、token 获取这些可以复用的操作提取成独立函数。
- 模块边界:请求逻辑归请求层,业务接口归 API 层,UI 状态归组件层,不互相渗透。
- 服务边界:前端只依赖由后端定义的消息协议,包括 SSE 数据格式、结束标记、错误结构。
定了这些边界之后,后端只要不破坏消息协议,内部怎么改都影响不到前端;前端只要不绕过封装层,控制好统一入口,后续换域名、换鉴权方式也都有明确落脚点。
1.3 项目模块拆分的基本顺序
V1 阶段最忌讳上来就设计一个庞大的目录结构。我这次的拆分顺序是:先底层、后业务、再界面。底层指的是环境配置和请求模块;业务层是对具体接口的封装,比如聊天接口;界面层是组件和页面。顺着这个顺序搭出来的结构如下:
src/ ├── api/ │ ├── request.js # fetch 二次封装,统一超时、鉴权、错误 │ └── chat.js # 聊天相关接口 ├── components/ │ └── ChatPanel/ │ ├── index.vue # 容器组件,负责交互编排 │ ├── MessageList.vue # 展示消息列表 │ └── Sender.vue # 输入区与发送按钮 ├── utils/ │ ├── sse.js # SSE 流式读取器 │ └── env.js # 环境与多域名配置 ├── constants/ │ ├── errorCode.js # 错误码映射 │ └── copywriting.js # 提示文案集中管理 └── pages/ └── chat/ └── index.vue # 页面入口这个结构没有引入太重的东西,每层各管一摊事。如果你现在手里也有一个功能能跑但结构混乱的 V1,可以先按这个模板对号入座,再逐步把代码搬进去。搬的过程不是机械复制,而是顺手找出重复逻辑并归并,这才是封装的真正起点。
2. 接口层封装:把网络请求做成“不用动脑”的模块
2.1 先定标准:用 axios 还是原生 fetch
接口封装的第一个选择题是请求库。axios 的优势是拦截器、取消机制和对老浏览器更友好的兼容性;原生 fetch 的优势是零依赖、更贴近浏览器标准。我这次选的是原生 fetch,没有外挂 axios,理由是项目运行环境已经能完整支持 fetch,没必要为了一个可拦截的能力再引一个依赖。
但不管是 axios 还是 fetch,二次封装要解决的核心问题是一模一样的:
- 统一 baseURL,避免请求路径散落各处。
- 统一超时处理。
- 统一鉴权 header。
- 统一错误类型,让调用方只认
error.code,不用自己判断字符串。 - 预留取消能力,给后面的 SSE 和“停止生成”做准备。
下面这个createRequest是我在实际项目里用的一个基础版本:
export function createRequest(config = {}) { const { baseURL = '', timeout = 15000, getToken = () => '', defaultHeaders = {}, } = config; const requestWithTimeout = (url, options) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timer)); }; return async function request(path, options = {}) { const url = /^https?:\/\//.test(path) ? path : baseURL + path; const headers = { 'Content-Type': 'application/json', ...defaultHeaders, ...options.headers, }; const token = typeof getToken === 'function' ? getToken() : ''; if (token) headers.Authorization = `Bearer ${token}`; try { const response = await requestWithTimeout(url, { ...options, headers }); if (!response.ok) { const error = new Error(`HTTP ${response.status}`); error.status = response.status; error.code = 'HTTP_ERROR'; throw error; } if (response.status === 204) return null; return await response.json(); } catch (e) { if (e.name === 'AbortError') { const error = new Error('请求超时或被中断'); error.code = 'REQUEST_ABORTED'; throw error; } if (e instanceof TypeError) { const error = new Error('网络连接异常'); error.code = 'NETWORK_ERROR'; throw error; } throw e; } }; }这里面有两个容易被忽略的点。第一个,fetch只有在网络层失败时才会抛出TypeError,HTTP 4xx、5xx 并不会抛异常,必须自己在response.ok时抛错;第二个,AbortController同时承担了超时和中止两个职责,所以必须在finally里清掉定时器,否则中止会在之后误触发。
2.2 一个请求模块管理 2 个域名
AI 对话类的 H5 项目经常遇到一个情况:首页和静态资源在一个域名,聊天接口在另一个域名,尤其是要部署到 App 或小程序容器里时,多个域名几乎成了标配。热词里有人问“uniapp 封装 H5 如何指向 2 个域名”,这确实是个高频问题。
我的做法是把域名配置抽成独立的环境配置,不给业务层“写死”的机会。举例来说:
// utils/env.js export const ENV = { dev: { chatBaseURL: 'https://dev-api.example.com', pageBaseURL: 'https://dev-page.example.com', }, prod: { chatBaseURL: 'https://api.example.com', pageBaseURL: 'https://page.example.com', }, }; export const currentEnv = ENV[import.meta.env.MODE] || ENV.dev;然后createRequest在创建实例时传入对应的baseURL:聊天模块用chatBaseURL,上传或静态资源相关请求用pageBaseURL。这样可以保证 H5 打包后只需要根据部署环境切换配置,不需要重新改请求路径。
这里要特别提醒:别在封装层用“当前页面域名”来拼接口地址。H5 被套进 App 壳后,window.location.href可能不是业务页面地址,而是本地 file 或容器的虚拟地址,依赖它很容易翻车。多域名这件事,宁可配置多一些,也不要运行时猜。
2.3 错误码与异常类型:让业务层拿到稳定信号
项目跑起来之后,最常见的不稳定因素就是错误处理不统一。有人直接catch到一串英文字符串,有人拿到response.status后在页面里写401 ? '请登录' : '出错了'。页面一多,文案就五花八门。
封装层要做的是把“HTTP 状态码”和“业务错误码”统一成一套稳定信号。我当时建立了一个错误码表:
| 错误码 | 触发情况 | 页面提示 |
|---|---|---|
| REQUEST_ABORTED | 请求超时或手动取消 | “请求已取消,请重试” |
| NETWORK_ERROR | 断网、DNS 失败、跨域被拦 | “网络连接异常,请检查网络” |
| AUTH_FAILED | 401 或 token 失效 | “登录已过期,请重新登录” |
| FORBIDDEN | 403 无权限 | “没有权限执行此操作” |
| SERVER_ERROR | 500 以上 | “服务暂不可用,请稍后重试” |
对应的实现也比较直接:在createRequest里把 HTTP 状态映射成AUTH_FAILED、FORBIDDEN、SERVER_ERROR等错误码;业务层收到后只负责看error.code,页面里同一错误码只需要一个提示逻辑。后续就算后端换状态码,也只要在封装层改一行,不用满项目找文案。
3. AI 交互逻辑封装:SSE 流式输出与 abort 中断
3.1 为什么选 SSE 而不是 WebSocket
AI 对话场景里,回答是逐字产生的,前端需要第一时间渲染出来。常见方案有轮询、WebSocket 和 SSE。轮询是定时请求,实时性和资源浪费都不理想;WebSocket 是双向通道,适合聊天室、实时协作这类需要持续双向通信的场景,但对“一问一答”的对话接口来说,复杂度有点高。
SSE 是 Server-Sent Events,服务端单向推送,前端只收数据。它天然符合大模型对话场景:用户发起一次 POST,服务端把答案片段通过文本流持续推给前端,推完就关闭。协议本身就是文本格式,后端实现简单,前端也能用原生fetch接流,不需要额外依赖。这个 V1 项目选 SSE,不是因为“新”,而是因为它的实现路径最短,且能直接复用现有 HTTP 网关、负载均衡和鉴权体系。
3.2 通用流读取器实现
用fetch接 SSE,很多人会掉进一个坑里:看到response.body.getReader()和read()之后,不知道一帧数据可能既包含半行,也可能包含好几行。如果只处理单次value,有些消息会被截断,有些会被拆散。
我整理的sseRequest是这么处理的:
export async function sseRequest({ url, body, token, onMessage, signal, }) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: token ? `Bearer ${token}` : '', }, body: JSON.stringify(body), signal, }); if (!response.ok) { throw new Error(`SSE 请求失败:HTTP ${response.status}`); } if (!response.body) { throw new Error('当前环境不支持流式读取'); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; const handleLine = (line) => { if (line.trim() === '') return; if (!line.startsWith('data:')) return; const data = line.slice(5).trim(); if (!data || data === '[DONE]') return; try { onMessage(JSON.parse(data)); } catch (e) { // 当 JSON 被拆到下一帧时,先跳过,等待补充解析 console.warn('SSE 数据解析失败,等待下一条消息', e); } }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ''; lines.forEach(handleLine); } if (buffer.trim()) { buffer.split(/\r?\n/).forEach(handleLine); } }关键点在于buffer = lines.pop():把最后一段可能不完整的内容留在缓冲区里,等下一帧到达时再拼接。TextDecoder一定要用{ stream: true },否则 UTF-8 多字节字符在流中间被切断时会乱码。实际调接口时,你还会看到有的服务端会先发一段注释行,比如: ping,所以handleLine里对不是data:开头的行直接跳过,不能一刀切处理。
3.3 会话状态与中断:把“停止生成”做成正经能力
V1 项目里最容易漏掉的,是“停止生成”的完整闭环。按钮上只是调了一句abort(),看起来界面停了,但组件卸载时没有清请求、超时时没有恢复状态、用户点击停止后没有回到初始状态,这些都属于半截工程。
我封装了一个useChatSession,把会话状态和中断能力绑定在一起:
import { ref, onBeforeUnmount } from 'vue'; import { sseRequest } from '@/utils/sse'; export function useChatSession(chatApi) { const state = ref('idle'); // idle | running | aborted | done | error const controllerRef = ref(null); const start = async (text) => { if (state.value === 'running') return; controllerRef.value?.abort(); const controller = new AbortController(); controllerRef.value = controller; state.value = 'running'; try { await chatApi.stream(text, { signal: controller.signal, onMessage(payload) { if (typeof payload?.delta === 'string') { // 触发上层回调,把增量文本交给渲染层 } }, }); state.value = 'done'; } catch (e) { if (e.name === 'AbortError') { state.value = 'aborted'; return; } state.value = 'error'; } }; const stop = () => { controllerRef.value?.abort(); }; onBeforeUnmount(() => { controllerRef.value?.abort(); }); return { state, start, stop }; }这里有一个非常重要的设计:AbortError被单独捕获,再置成aborted状态。如果把它当成普通错误处理,那么用户主动点“停止”时,界面会弹出一条“请求失败”提示,体验非常奇怪。正确逻辑是:停止是用户有意为之,不是失败。
另外,我在组件卸载时也调用了abort()。这个动作不是可有可无的。AI 流式接口如果前端关了页面还允许请求继续跑,后端可能还会持续生成并占用资源;加一个卸载清理,既省流量,也避免组件已经销毁后再去更新 DOM 导致内存泄漏。
3.4 并发切换与竞态:V1 最容易翻车的地方
还有一类问题容易被忽略:用户在一次回答还没结束时,就刷新页面、切换会话或发起了新问题。如果在旧请求的回调里还去更新新页面的状态,就会出现“旧回答覆盖新回答”的竞态。
通用的处理方式就是给会话加“代数”。我在封装里用controllerRef存当前请求的AbortController,每次发起新请求就把旧控制器中止。这样旧请求的回调要么在不经意间执行,也会因为界面已经切换到新状态而被过滤;中止它之后,状态不会再被旧流更新。如果你在 React 里,也可以用一个requestSeqRef自增编号,回调时只认最新一次请求的编号,这是更保险的兜底方案。
4. 组件层与工程化:让 UI 和逻辑解耦
4.1 对话界面拆成“容器 + 展示组件”
组件封装最忌讳的是把所有逻辑都塞进一个巨型单文件组件。我把对话页拆成两个层次:
- 容器组件
ChatPanel/index.vue:负责拿到输入、启动会话、接收流式增量、更新消息列表、处理停止动作。 - 展示组件
MessageList.vue和Sender.vue:只负责展示数据和触发事件,不感知请求、不感知 SSE。
这样的拆分带来的收益是:MessageList可以独立调试,也能在测试环境用静态假数据渲染;ChatPanel里替换“聊天接口”时,页面其他部分完全不用动。以后如果你要再接一个本地小模型,只需要在 API 层新增实现,组件层不变。
组件内部状态也不要一股脑全放在全局。state(正在生成、已停止、出错)属于会话维度,放在useChatSession里;页面消息列表属于界面维度,放在容器组件里;按钮置灰、loading 文案则根据state计算出来。这样在报错排查时能顺着状态流找回问题,而不是在十几个v-if里捞数据。
4.2 常量、文案、环境配置这类“不起眼”的封装
V1 项目到后期,最琐碎的就是文案分散和魔法数字。按钮在停止时候要变成“停止生成”,超时时要提示“请稍后重试”,接口返回特定错误码时要拒绝操作。这些字符串如果不集中管理,前端同事会在不同页面写出完全不同的话术。
我把错误提示文案统一放到constants/copywriting.js,把错误码状态统一放到errorCode.js,再在项目里规定:业务页面不直接写“网络异常”这类字面量,而是引用统一导出。这个规则不复杂,但是团队里几个文件在维护,能有效避免文案不一致和后端状态码变动时改不全的问题。
4.3 文档是封装的一部分
封装不是代码提完就结束了。还有一类“半成品”,是代码变得很干净,但交接文档还在文档中。V1 总结想要对其他人生效,至少要把下面这些写清楚:
- 接口路径和请求头怎么带。
- SSE 流的数据格式:增量字段、结束标记、错误结构。
- 如何切换多域名环境。
- 前端如何触发中止,后端收到中止请求后要做什么。
- 已知问题列表和回退方案。
我把这些信息整理成组件的 README 和docs/api.md。这个动作看起来花时间,但实际在 V1 价值巨大:你不用把代码逻辑从头到尾解释一遍,只需要把边界和协议写清楚,接手的人就能在半小时内运行起来。
5. V1 阶段踩过的坑与排查实录
5.1 常见问题速查表
下面是这个项目里真实遇到过的问题,我整理成一张速查表,后面再接类似需求时能直接对照。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 回答显示到一半不再更新 | SSE 流被浏览器误判超时,或后端连接断开 | 检查后端 keep-alive;前端把 TCP/HTTP 层空闲超时调大 |
| 点击“停止”后按钮没恢复 | 只中止了 UI 状态,没有 abort 底层 fetch | 统一用AbortController关联会话,停止时调用stop() |
| 快速连点“发送”出现多条回答 | 缺少并发控制 | 在useChatSession.start里判断running状态,或强制中止前一次请求 |
| 中文回答开头出现乱码 | 忘记用TextDecoder的 flow 模式 | 解码参数改为decoder.decode(value, { stream: true }) |
| 页面刷新后接口还在请求 | 组件卸载或页面刷新时没清理 | onBeforeUnmount/useEffect清理函数里调用abort() |
| 部署到 App 容器后拿不到接口域名 | 误用window.location拼地址 | 域名改为环境配置注入,不依赖页面地址 |
| 接口返回 200 但页面一直报 “网络错误” | 后端返回了非 JSON 但前端仍调用response.json() | 判断response.content-type,或对解析做容错 |
| 一条 SSE 消息被拆到多个 chunk 后解析失败 | 缓冲区分行逻辑不完整 | 使用buffer.pop()保留半行内容,下轮拼接 |
5.2 几条值得记住的避坑经验
第一,不要信任“看起来正常的几次流式响应”。我在本地测 SSE 很顺利,但在包了一层网关的测试环境里,要么第一帧数据被网关吞了,要么整条连接被空闲超时掐断。排查方式是先在后端用 curl 直接看流,再从前端读原始chunk,确认在哪一层被切,不要一上来就改前端代码。
第二,AbortController不是“点击停止之后再也不用管”的状态。它还需要和一个“会话状态”绑定。我把controllerRef存放的控制器同时作为“当前会话是否活跃”的标记,这样既能在页面上判断按钮是否可点,又能在组件销毁时统一清理,比单独维护多个布尔值可靠得多。
第三,流式解析的分行逻辑一定要用\r?\n兼容处理。有的后端按标准 SSE 发\r\n,有的只发\n。只处理一种,就可能出现相邻消息粘连或数据丢失。我统一用buffer.split(/\r?\n/),既兼容换行,也不怕最后一段没有换行。这个细节在联调阶段最容易让人抓狂,提前做好能省很多时间。
6. 总结篇:V1 总结怎么写才有价值
6.1 先定边界,再写“能复用”的经验
项目到了收尾阶段,写总结不是把开发过程按时间顺序复述一遍,那是流水账。真正有价值的总结,是把这次封装过程中确认下来的边界、决策和教训讲清楚。
我的总结包含这么几块:
- 目标与范围:V1 做了哪些功能,主动没做哪些功能。
- 系统结构与职责:请求层、API 层、SSE 层、组件层各自负责什么。
- 关键技术决策:为什么选 SSE、为什么用 fetch、为什么把 abort 合并进会话状态。
- 遗留问题:哪些地方只做了临时方案,在什么条件下需要替换。
- 下一步建议:如果要做 V2,优先处理哪些风险。
其中“主动没做哪些功能”特别重要。V1 最怕的是边界不明,导致后面每提一个需求都被质疑“为什么没有”。写明范围和前提,接手的人才能知道哪些是你的责任边界,哪些是产品规划里故意砍掉的。
6.2 交接文档清单:让总结变成可执行的交接手册
总结不能只停留在口头或会议纪要,我习惯把它落成几份可更新的文档:
README.md:项目启动方式、环境变量、本地联调步骤。docs/api.md:接口协议、SSE 数据格式、错误码表。docs/pitfalls.md:踩坑记录和排查手册。CHANGELOG.md:从 V1 开始的版本记录。
这套文档配合前面的封装代码,才是真正能交给下一个开发者的完整产物。代码展示“怎么做”,文档解释“为什么这么做”,边界和遗留问题让人知道“接下来从哪里接手”。这三个问题说清楚,V1 封装就算真正完成了。
我个人每次写总结时都默认一个心态:如果某一天有人对着这份代码问“这个设计当时是怎么想出来的”,文档里应该能找到答案。封装解决的是代码边界问题,总结解决的是时间边界的问题。V1 项目把这两件事做完,后面才不会再踩一遍已经踩过的坑。