V1项目封装实战:从请求层到组件的收拢与复盘
V1版本上线那天产品在群里发了一晚上的庆祝消息我盯着代码仓库却一点都高兴不起来。需求是赶出来的联调是加班调的主流程能跑但代码里到处是复制粘贴的请求、写死的配置、同一个弹窗写了三套样式、接口地址散落在各个页面里。这个状态很多团队都经历过——V1最大的价值是验证了业务能跑通代价则是技术债已经悄悄堆起来了。所以V1之后真正该做的不是急着开V2而是把封装的活儿补上把散落的逻辑收拢、把可复用的能力抽出来、把踩过的坑写进文档。这篇文章就围绕V1项目封装这件事聊聊我做完整轮技术改造和项目总结的实操思路包括请求层怎么二次封装、组件怎么抽、哪些地方千万别过度封装、复盘文档到底该写什么适合正在做V1收尾或者准备开始V2的开发者参考。1. 先从烂摊子说起V1代码为什么必须做一次封装1.1 V1交付后的真实状态先说个扎心的真相V1代码几乎没有不乱的。不是团队水平不行而是V1的天然使命就是用最小成本验证业务可行性这个使命本身就和代码整洁度冲突。我复盘自己的V1项目时打开仓库发现三类典型问题第一类是请求逻辑完全失控。同一个获取用户信息的接口在三个页面里各写了一遍axios调用每个页面的loading状态、错误提示、token注入方式都不一样。有人用axios实例有人直接axios.get还有人封装了一个自己的request方法——结果全项目有四种请求写法。这种散落最直接的后果是后端某天把接口路径从/api/v1/user/info改成/api/v2/user/profile我需要改七个文件还漏了一个。第二类是硬编码和魔法值满天飞。翻页大小写死在代码里状态码用1、2、3这种数字接口超时时间每个请求单独配。最离谱的是一个字典映射表同一个业务状态在三个文件里维护了三份后面数据对不上了才被发现。第三类是组件层的重复。一个确认弹窗订单页写了一遍退款页又写了一遍设置页再写一遍样式还是同一套只是按钮文案和回调逻辑不一样。这种重复代码的维护成本是隐性的——你以为只是复制粘贴很快实际上后续每次改样式都要同步改N个地方漏改一个就是线上事故。V1做完后的正确姿势就是把这三类问题一次性收拢也就是做一次系统性的封装重构。1.2 如何划定封装范围封装不是把所有代码都重写一遍那是大爆炸式重构风险极高。我先做了一次摸底用静态统计的方式量化问题规模在项目根目录跑一遍检索数一下请求方法出现多少次、同一组件被复制了几份、配置项被硬编码的位置有多少。# 统计 axios/get 请求的散落情况示意 grep -rn axios\|request( src --include*.ts | wc -l # 找出复制粘贴的弹窗组件 find src -name *Modal.vue -o -name *Dialog.vue | sort拿到数字之后我按三层边界来划分封装范围第一层是基础设施层包括请求、存储、日志、环境配置这层必须抽因为它是所有业务的地基第二层是公共能力层包括工具函数、通用组件、通用hooks这层按出现三次及以上的原则抽第三层是业务能力层比如订单模块的表单、支付模块的回调统一处理这层只在业务内部抽不跨模块复用。这个划分非常关键。很多人封装失败就是因为把第三层的业务能力强行抽到了第二层最后搞出一个万能的订单组件参数十几个谁也看不懂。封装范围的判断标准只有一个这一份代码是不是真的被两个以上互不相关的场景复用。没有复用的抽象就是徒增复杂度。2. 请求层封装把散落的接口调用收拢到一个出口2.1 axios 二次封装的最小可用设计请求层是V1项目封装收益最高的一个点因为它几乎触及所有页面。我见过很多团队所谓的二次封装就是新建了一个axios实例、导出一个request函数然后就没有然后了。这种做法能统一baseURL但解决不了错误处理、登录失效、重复请求这些真正烦人的问题。我的做法是把请求封装拆成四个层次axios实例配置、请求拦截器、响应拦截器、业务错误处理。先看axios实例和拦截器这部分核心代码// src/utils/request.ts import axios from axios import { getToken, clearToken } from ./auth import { Message } from ant-design-vue const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, // 关键点让请求带上 cookie 凭证 withCredentials: true }) // 请求拦截器注入 token、防重复提交 const pendingMap new Map() function removePending(config) { const key ${config.method} ${config.url} if (pendingMap.has(key)) { const cancelToken pendingMap.get(key) cancelToken(cancel) pendingMap.delete(key) } } service.interceptors.request.use( (config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } // 对需要防重复的请求做 cancel 处理 if (config.preventRepeat) { removePending(config) config.cancelToken new axios.CancelToken((cancel) { pendingMap.set(${config.method} ${config.url}, cancel) }) } return config }, (error) Promise.reject(error) ) // 响应拦截器统一错误码和登录失效 service.interceptors.response.use( (response) { // 注意这里返回的是业务数据而不是整个 response const res response.data if (res.code ! 0) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { if (error.response?.status 401) { clearToken() // 跳转登录页同时记录当前页便于登录后回跳 window.location.href /login?redirect${encodeURIComponent(window.location.pathname)} } return Promise.reject(error) } ) export default service这里有个非常重要的设计点响应拦截器直接返回res.data而不是整个response。这样做的好处是业务代码拿到的就是纯净的业务数据不需要每处都写.data.data。很多团队的请求封装做了一半就是因为拦截器没有统一返回值业务层还是要各自拆包等于白封装。2.2 拦截器里真正该做的事拦截器不是摆设它是全项目请求逻辑的唯一关卡。我在实际封装中整理出拦截器真正该做的五件事顺序也有讲究第一注入凭证。token从本地存储读取而不是从页面变量读避免刷新丢状态。第二统一防重复提交。用上面的pendingMap记录未完成的请求快速点击提交按钮时直接cancel掉前一个请求这个功能对表单页面尤其实用。第三统一处理HTTP层错误。网络超时、断网、502这类错误在拦截器里统一提示业务代码不用catch。第四统一映射业务错误码。登录失效返回401权限不足返回403这两个码在拦截器里统一切换页面或提示不要散落在业务层。第五把响应数据剥壳后返回让业务层拿到的是纯数据。这里有一个很多人踩过的坑超时时间不要所有接口一刀切。文件上传接口和查询接口的耗时完全不同我用config.timeout做覆盖在调用处可以单独指定更长的超时时间这是axios配置本身就支持的封装时不要堵死这个口子。2.3 流式接口SSE的封装思路V1后期我们接了大模型相关的流式输出功能这属于接口封装的一个新场景SSE流式接口。普通的axios封装处理不了流式响应——你不能等整个响应结束了再返回数据用户等不起必须边接收边渲染。我做的流式封装把传统的Promise思路改造成了接收器模式核心是用fetch的ReadableStream逐块读取// src/utils/sseRequest.ts export async function streamRequest({ url, data, onMessage, onDone, signal }) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${getToken()} }, body: JSON.stringify(data), signal }) if (!response.ok) { throw new Error(HTTP ${response.status}) } 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 }) // SSE 数据按双换行分割每条消息以 data: 开头 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?.(JSON.parse(payload)) } } } }这个封装的关键点有三个一是用TextDecoder处理流式数据注意要传{ stream: true }否则中文会乱码二是维护一个buffer因为网络分块可能把一个完整SSE消息切成两半不能读一块就解析一块必须等换行符确认消息完整三是把AbortSignal透传出去让组件销毁或用户点击停止生成时能干净地中断请求。流式接口封装做完之后页面调用变成了一个非常简洁的形态业务层不再关心底层到底是fetch还是WebSocket只管来一段渲染一段。这才是接口封装应该达到的效果——调用的人不需要知道细节。3. 组件与工具层封装把重复劳动沉淀成资产3.1 组件抽取的判断标准请求层封装完之后我接着处理组件重复的问题。组件抽取是最容易做过头的一层所以我给自己定了一个硬性标准——三处复用原则同一段UI结构和交互逻辑在三个及以上互不相关的业务场景中出现才值得抽成公共组件只有两处使用时先复制一份等第三次出现再抽。为什么是这个标准因为组件抽取本身是有成本的抽象props、设计插槽、考虑边界场景这些工作量比复制粘贴大得多。如果只出现两次就抽很可能抽完就发现两个场景的需求已经开始分叉了——一个要左边对齐一个要右边对齐你得为这个分叉加一个props第三次、第四次分叉继续加最后组件比原来的代码还难维护。我的V1项目里抽得最成功的是一个表格筛选分页组合组件。三个列表页订单列表、退款列表、操作日志结构几乎一致都是顶部筛选表单、中间表格、底部翻页。我把它们抽成一个ProTable组件设计成数据驱动!-- ProTable.vue 的核心 props -- template div SearchForm :fieldssearchFields searchhandleSearch / Table :columnscolumns :data-sourcedataSource :loadingloading / Pagination :currentpage :totaltotal :page-sizepageSize changehandlePageChange / /div /template抽完之后页面的代码从几百行浓缩到几十行只需要声明字段配置、列配置和请求函数。但这个组件之所以没翻车是因为我严格控制了它的业务边界——它只负责渲染和数据状态管理不掺入任何具体业务逻辑比如订单状态怎么流转、退款金额怎么计算。业务逻辑通过loadData事件抛给父组件处理。3.2 公共方法与配置封装组件之外工具函数和配置项也需要收拢。V1时代最让我头疼的是格式化逻辑到处都是——日期格式化有人用dayjs、有人用moment、还有人手写toLocaleString输出格式还不一样。我的处理方式是建一个src/utils/format.ts把所有格式化逻辑收口// src/utils/format.ts export function formatDate(date: Date | string | number, pattern YYYY-MM-DD HH:mm:ss) { // 统一使用 dayjs return dayjs(date).format(pattern) } export function formatAmount(value: number | string, digits 2) { const num Number(value) if (Number.isNaN(num)) return -- return num.toFixed(digits) } export function formatStatus(status: number) { // 字典表只维护一份 const MAP { 0: 待处理, 1: 处理中, 2: 已完成, 3: 已取消 } return MAP[status] ?? 未知 }配置项封装我走的是另一个思路环境变量优先常量表其次禁止在业务代码里出现魔法数字。baseURL、接口路径前缀、第三方平台的key全部放到环境变量文件里按.env.development、.env.production区分。业务状态码、枚举值这类固定映射集中放在src/constants下用TypeScript的as const锁定保证全局只有一份定义。这里有一个值得单独说的细节封装配置时顺带把git信息也做进去了。我们在V1后期经常遇到这个bug是哪个版本引入的这类问题于是封装了一个基于构建时间的版本信息模块在页面上通过一个隐藏入口就能看到当前构建的commit hash和构建时间。把这个能力沉淀成公共工具之后排查线上问题的时间缩短了很多。3.3 多端场景下的请求封装差异V1做完Web端之后我们同步启动了小程序端的开发这时候发现封装这件事在不同端有完全不同的形态。小程序没有axios用的是wx.request但它比浏览器多了几个非常麻烦的问题登录态管理、并发请求的token刷新、分包加载导致的基础库能力差异。我刚接手小程序封装时第一反应是把Web端的axios封装思路直接平移过去——统一request方法、统一错误码、统一loading。结果发现完全行不通因为小程序请求有一个Web端没有的核心矛盾token过期后需要静默调用wx.login换新token但此时可能已经有多个请求在排队了如果每个请求都各自去刷新token会触发并发刷新后端直接拒绝。正确的封装姿势是维护一个刷新中的Promise单例让所有等待的请求都挂在这个Promise上刷新完成后再统一重放// 小程序端请求封装的核心串行化token刷新 let refreshPromise null function refreshToken() { if (refreshPromise) { return refreshPromise } refreshPromise new Promise((resolve, reject) { wx.login({ success: async (res) { // 用 code 换取新 token const { token } await api.exchangeToken(res.code) resolve(token) }, fail: reject }).finally(() { refreshPromise null }) }) return refreshPromise }这段代码的精髓就在于refreshPromise这个单例第一个401请求触发刷新后续的401请求不会再次触发而是直接复用同一个Promise等刷新完成后把各自的原请求重放一遍。如果每个请求都各自刷新不仅后端扛不住还会出现token相互覆盖的竞态问题。所以封装这件事不能一套走天下同样的目标统一请求入口、统一错误处理在Web端和小程序端的实现细节完全不同。封装的经验应该沉淀成问题和解法的清单而不是某段代码到处抄。4. 封装的边界哪些代码不该动哪些抽象是错的4.1 三个迹象说明你正在过度封装封装本身是好事但V1项目最容易犯的错误不是不封装而是封装上瘾。我见过一个项目请求层套了三层最外层是业务request中间层是公共request最底层是axios实例每一层都只做了一点点事但调用方为了搞清楚一个请求到底走了哪些逻辑得把三层代码全部翻一遍。我总结出三个过度封装的明显迹象第一抽象层的代码量超过了业务层的代码量。一个公司内部的项目如果公共模块比业务模块还庞大大概率不是业务简单而是抽象失控了。第二为了将来可能用到而设计。比如给一个只在当前页面使用的组件设计了十个props和四个插槽理由是以后应该会扩展。YAGNI原则在封装领域同样成立——你现在不知道将来要什么设计出来的扩展点往往全是错的。第三调用方需要阅读源码才能理解封装的含义。封装的意义是让调用变得更简单如果调用方不看实现就不知道怎么传参、不知道返回值是什么那这个封装就是失败的。4.2 封装失败的典型反模式反模式一万能工具函数。有人喜欢写一个processData(data, type, options)通过type字段区分几十种处理逻辑参数对象越来越复杂函数体越来越长。这种大杂烩封装比不封装更糟因为它把相关的逻辑拆散了把不相关的逻辑揉在一起。正确的做法是一个函数只做一件事功能不同就应该拆成不同名字的函数。反模式二继承滥用。业务组件之间有一点共性就抽一个父组件用继承关系强行归并结果子类需要覆写父类的一大堆方法才能工作。组件复用优先使用组合props、插槽、组合式函数而不是继承尤其在前端领域继承关系会让组件间的耦合变得极其隐蔽。反模式三把长得像当成本质相同。两个表单看起来长得像但一个的提交逻辑是走订单流程一个是走退款流程就把它们硬抽到一个组件里用type字段区分。结果就是组件里塞满了if (type order) ... else if (type refund) ...每个新场景都要改公共组件。公共组件一旦需要为特定业务改动说明抽象错了——它应该拆开。4.3 我的取舍原则经过V1这轮封装我给自己定了几条取舍原则供参考封装是给已重复三遍的当下付费不是给可能发生的未来付费。判断一个东西要不要抽只看它过去有没有被重复不看它将来会不会被复用。用这个标准可以挡掉大部分过度设计。接口不稳定的不封装。V1阶段业务还在摸索订单状态机可能下个月就重构此时花大力气把状态机封装成公共模块等于绑定一个还没定型的逻辑。对不稳定的业务允许重复等稳定之后再一次抽干净反而更省时间。每次封装都要回答一个问题调用方的代码因此变简单了吗如果封装完之后调用方要写的代码更少了、要理解的约束更少了这个封装才算合格。我甚至会在封装完一个方法后刻意把调用处的代码拿给另一个不熟悉这块的同事看他如果不需要看实现就能用才算通过。5. 项目总结不是写周报V1复盘应该沉淀什么5.1 数据复盘与架构复盘封装做完了代码干净了接下来是总结这个重头戏。很多人写项目总结就是一份流水账做了什么功能、遇到了什么bug、加班了多少天这种东西写完就没人看了。真正的项目总结应该是下一轮项目可以直接拿来用的资产。我把V1总结拆成两个维度。第一个维度是数据复盘目标是把感受变成数字。我会统计这几个指标需求变更率开发期间需求变更次数除以总需求数、Bug分布按模块、按产生阶段统计、单模块开发耗时用于下次估时。拿Bug分布来说如果数据表明60%的Bug集中出现在表单校验和接口对接这两个环节那V2的技术方案就得在这两个环节下功夫——比如引入统一的校验规则声明、请求层的字段类型定义。第二个维度是架构复盘这个需要结合代码现状来看。V1项目最容易出现的架构问题是模块之间的依赖混乱业务A直接import了业务B的内部组件公共层反向依赖了业务层循环引用时不时冒出来。复盘时我画了一张模块依赖图文字版标注每个模块的职责、对外暴露的能力、以及不该依赖但实际依赖了的坏味道。这张图的价值在于V2开工时可以直接作为改造地图。5.2 从总结里提炼可复用的清单总结最有价值的部分是把经验转换成清单。经验是模糊的清单是可执行的。我从V1复盘里提炼了三份清单第一份是接口设计自检清单。内容包括所有接口是否统一了返回结构错误码是否有全局字典分页参数是否统一幂等性是否需要这份清单在V2设计每个新接口时都会过一遍避免V1踩过的坑比如有的接口返回{data: []}、有的返回{list: []}二次发生。第二份是页面开发检查清单。内容包括所有文案是否抽成了常量空状态是否考虑极端数据超长文本、0条数据是否测试表单在提交中是否有防重复保护这份清单我打印出来贴在工位上每次提测前过一遍肉眼可见地减少了低级bug。第三份是性能检查清单。V1上线后我们遇到了首屏加载慢、图片懒加载缺失、大列表渲染卡顿等问题我把这些问题和对应的优化手段沉淀成清单V2的每张新页面开发时都要对照执行而不是等问题暴露后再修。5.3 交接文档怎么写给后来的人看V1总结还有一个容易被忽略的用途交接。项目可能换人维护或者你负责的模块要交给别人。写交接文档最大的误区是写文档等于写使用手册把每个函数的参数列一遍那没有意义——代码自己就是最好的使用手册注释都嫌多余。真正有用的交接文档只回答三个问题这项目是干什么的整体架构是什么样的有哪些坑是不能踩的我写总结文档的固定结构是一页纸的项目背景和核心业务流一张文字版架构图标注模块边界和依赖方向一份踩坑记录表每行一个坑写上现象、原因、解决方式一份改造建议列表记录我知道但目前没时间改的问题点。特别是踩坑记录这张表我强烈建议认真写。比如接口A在某个边界参数下会返回500而不是合法错误码处理时必须先判断字段是否存在登录刷新token必须走公共方法不能自己调login接口否则会并发刷新。这些信息在代码里完全看不出来只有实际踩过的人才知道不写下来就彻底丢了。6. 封装与总结的收尾一次小规模重构的真实记录6.1 重构的节奏和验证方式最后分享一次我实际操作的封装重构节奏。这个项目V1总共2.1万行业务代码我用了两周半时间做封装收尾没有一天是停工的每步都跟着测试验证一起走。我把顺序定成自底向上先封装配置和工具函数不涉及任何业务改动风险最低再封装请求层改完所有接口调用都会切到新方法接着抽公共组件页面逐个替换最后整理总结文档。每一层做完跑一遍全量回归测试。这里的关键是每一步都是可独立验证的增量修改而不是推到重来。有一点特别重要封装和重构一定要守一个规矩——行为不变。我在这一轮封装里给自己定了死规定不允许在封装的过程中顺手改业务逻辑哪怕发现某个逻辑是错的。因为一旦把重构和修bug混在一起出了问题你根本分不清是新封装的锅还是改逻辑的锅排错成本直接翻倍。看到有bug先记到TODO列表封装完成之后再单独修。6.2 效果量化这轮封装做完我用git统计和静态分析工具对比了前后数据同分支规模下重复代码率从17%降到了6%左右请求调用点从分散的40多处收敛到统一入口新增一个接口的平均成本从半天降到一个小时左右页面级公共组件抽出来4个直接删掉的重复代码约3000行因为统一了错误处理和loading逻辑线上反馈的操作后无反应类问题明显下降。数字能说明效率但还有两件事数字体现不出来一是新同事接手代码时的上手速度明显快了原来要翻遍所有页面才能搞懂项目结构现在看一眼文档里的架构图就懂了二是后续V2新增需求时团队不需要再讨论这个功能应该放哪里封装边界已经画好了大家照着边界填代码就行。最后再分享一个我在这个过程中反复体会到的点封装和总结最大的敌人不是技术难度而是觉得可以以后再弄的心态。V1刚上线那周我觉得功能都正常了不如直接冲V2但拖了三周之后连我自己都开始记不清某个方法为什么要那样写了。代码的腐化速度比想象中快得多趁着对业务和代码的上下文记忆还清晰的时候做封装总结效果是最好的。一旦拖到V2开发到一半再回头补你就得一边理解新需求一边回忆旧逻辑成本直接翻倍。所以V1版本发布会之后真正该庆祝的不是上线这个动作而是把这次交付变成下次交付的垫脚石。