ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

【Vue3 + TypeScript】接口报错别再到处 try/catch:从请求封装、错误分类到全局兜底,搭一套可维护的错误处理体系

【Vue3 + TypeScript】接口报错别再到处 try/catch:从请求封装、错误分类到全局兜底,搭一套可维护的错误处理体系 个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C、 《Linux操作系统》学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、错误处理为什么会从几行 catch 变成系统问题1.1先看最容易失控的写法1.1.1 同一个错误会被处理很多次1.1.2 不同页面对同一状态码理解不一致1.2 先把“错误”分清楚不要所有失败都叫请求失败二、请求层与统一错误模型2.1 第一层把 axios 请求封装干净2.1.1 创建 axios 实例2.1.2 请求拦截器只做请求层的事情2.2 设计统一的错误类型而不是让页面直接面对 unknown为什么保留 raw2.3 响应拦截器先把底层错误翻译成应用能理解的格式正常响应HTTP 成功不代表业务成功2.4 HTTP 错误应该怎么归一化三、全局错误策略与业务边界不要在请求拦截器里把所有错误都弹出来有些错误根本不该弹有些业务错误需要页面自己展示给请求增加错误策略而不是全局一刀切401 最适合做成全局状态而不是每个页面自己跳登录如果有 refresh token业务错误要让业务层拥有最后决定权四、页面、Vue 与浏览器的多层兜底页面里还需要 try/catch 吗Vue 自身的运行时错误也要有兜底window.onerror 和 unhandledrejection 补最后一层window errorPromise 未处理异常五、日志、提示节流与组件错误边界错误日志应该记录什么开发日志和用户提示不是一回事重复错误要做节流不然一次故障能弹满屏错误边界一个组件坏了不要让整个页面跟着死六、请求生命周期Loading、取消与状态恢复错误处理和 Loading 也要配合AbortController用户主动取消不是错误七、工程组织、完整链路与常见错误一个更完整的目录可以怎么组织把完整链路接起来最容易犯的几个错所有错误都在 axios 拦截器弹 toast所有页面都 try/catchcatch 以后什么都不做把后端 message 原样展示给用户所有 500 都刷新页面上报完整请求数据总结前言前端项目刚开始写的时候错误处理通常很简单。接口报错了就在catch里弹一个消息表单提交失败再弹一个消息路由加载异常控制台打印一下。页面少的时候看不出问题。等项目慢慢变大代码里会开始出现很多相似东西try{constresawaitapi.getUser()}catch(err){ElMessage.error(请求失败)}另一个页面try{awaitapi.saveOrder(data)}catch(err){ElMessage.error(保存失败)}再一个页面try{awaitapi.deleteItem(id)}catch(err){console.error(err)ElMessage.error(删除失败)}表面上只是重复几行代码真正麻烦的是所有页面都开始自己理解错误。有的把 401 当“请求失败”有的跳登录页有的什么都不做有的接口 400 会弹后端 message有的直接写死“操作失败”有的重复弹两次消息因为 axios 拦截器弹了一次页面 catch 又弹一次。错误处理最后会和业务代码缠在一起。错误处理一旦开始跨页面重复问题就从“语法怎么写”变成了“职责应该放在哪一层”。问题到这里已经不是try/catch写得够不够而是谁来解释这个错误、谁负责提示、谁负责恢复。下面直接从最容易失控的页面代码开始把请求层、业务层和组件层的责任一点点拆开。一、错误处理为什么会从几行 catch 变成系统问题1.1先看最容易失控的写法假设有一个用户列表页constloadUsersasync(){try{loading.valuetrueconstresawaituserApi.list()users.valueres.data}catch(err){ElMessage.error(获取用户失败)}finally{loading.valuefalse}}单看这一段没什么问题。但项目里有 50 个页面以后问题就来了。1.1.1 同一个错误会被处理很多次假设 axios 响应拦截器已经写了if(error.response?.status401){ElMessage.error(登录状态已失效)router.push(/login)}页面里又catch(err){ElMessage.error(获取用户失败)}最终用户看到登录状态已失效获取用户失败两条消息连续弹。实际上用户只需要知道登录过期了需要重新登录。1.1.2 不同页面对同一状态码理解不一致页面 Aif(status403){ElMessage.error(没有权限)}页面 Bif(status403){router.push(/403)}页面 Ccatch{ElMessage.error(请求失败)}同一个 403在三个页面里行为完全不同。这时候 bug 不是某一行代码的问题而是错误处理没有统一规则。1.2 先把“错误”分清楚不要所有失败都叫请求失败前端常见错误至少可以分成几类。类型例子更适合谁处理网络错误断网、DNS、连接失败请求层HTTP 错误401、403、404、500请求层 全局策略业务错误用户名重复、库存不足业务层表单校验错误手机号格式错误组件/表单层代码运行错误读取 undefined 属性Vue / window 全局兜底Promise 未处理异常async 内 throw 未 catch全局兜底这里最关键的是不同错误不是换一个提示文案那么简单它们本来就属于不同层级。比如库存不足{code:40021,message:库存不足}这是业务逻辑问题。请求已经成功到达后端后端也正常返回了结果只是当前业务条件不允许继续。这种错误应该保留给页面决定提示库存不足禁用提交按钮刷新库存引导用户修改数量而401 Unauthorized更像系统级状态登录凭证失效它不应该让每个页面自己重复判断。二、请求层与统一错误模型2.1 第一层把 axios 请求封装干净假设项目结构src/ ├─ api/ │ ├─ modules/ │ │ ├─ user.ts │ │ └─ order.ts │ └─ request.ts ├─ utils/ ├─ stores/ └─ views/核心请求实例放到src/api/request.ts2.1.1 创建 axios 实例importaxiosfromaxiosexportconstrequestaxios.create({baseURL:import.meta.env.VITE_API_BASE_URL,timeout:10000})不要在每个 API 文件里重新axios.get(...)axios.post(...)统一实例的好处是baseURL 一处维护timeout 一处维护token 一处处理401/403 一处处理日志一处记录。2.1.2 请求拦截器只做请求层的事情例如自动带 tokenrequest.interceptors.request.use(config{consttokenlocalStorage.getItem(token)if(token){config.headers.AuthorizationBearer${token}}returnconfig})这里不要顺手塞一大堆业务逻辑。比如如果当前是订单页就加参数 A如果当前用户是管理员就改 body如果是某个按钮发出的请求就改 URL这些应该留在业务层。拦截器是统一入口不代表它应该知道所有业务。2.2 设计统一的错误类型而不是让页面直接面对 unknownTypeScript 里catch的错误本质上并不安全。你不能默认catch(err){console.log(err.message)}因为err不一定就是你想象的 Error。可以统一定义应用错误。tsexport type AppErrorType | ‘NETWORK’| ‘HTTP’| ‘BUSINESS’| ‘CANCELLED’| ‘UNKNOWN’export interface AppError {type: AppErrorTypemessage: stringcode?: string | numberstatus?: numberraw?: unknown}这样页面最后拿到的不是五花八门的 axios error而是统一结构 json { type: BUSINESS, message: 用户名已存在, code: 10012 }或者{type:HTTP,message:没有访问权限,status:403}为什么保留 raw很多时候开发环境还需要看原始错误。所以raw?:unknown可以留下。但 UI 层不要依赖err.raw.response.data.xxx否则前面统一封装就失去意义了。2.3 响应拦截器先把底层错误翻译成应用能理解的格式正常响应假设后端统一返回{code:0,message:success,data:{}}可以定义exportinterfaceApiResponseT{code:numbermessage:stringdata:T}APIexportconstgetUser(id:number){returnrequest.getApiResponseUser(/users/${id})}HTTP 成功不代表业务成功这是前端很容易混淆的地方。例如 HTTP 是 200200 OK但 body{code:10012,message:用户不存在,data:null}网络和 HTTP 都成功了但业务失败。可以在响应拦截器里把业务失败转换成AppErrorrequest.interceptors.response.use(response{constbodyresponse.dataif(body.code!0){returnPromise.rejectAppError({type:BUSINESS,code:body.code,message:body.message||操作失败,raw:response})}returnresponse},error{returnPromise.reject(normalizeHttpError(error))})这里做的是格式统一。至于是否弹消息不一定要直接在这里做。2.4 HTTP 错误应该怎么归一化可以写一个函数importaxiosfromaxiosexportfunctionnormalizeHttpError(error:unknown):AppError{if(!axios.isAxiosError(error)){return{type:UNKNOWN,message:发生未知错误,raw:error}}if(error.codeERR_CANCELED){return{type:CANCELLED,message:请求已取消,raw:error}}if(!error.response){return{type:NETWORK,message:网络连接异常请检查网络,raw:error}}conststatuserror.response.statusreturn{type:HTTP,status,message:getHttpMessage(status),raw:error}}再定义functiongetHttpMessage(status:number):string{switch(status){case400:return请求参数有误case401:return登录状态已失效case403:return没有访问权限case404:return请求的资源不存在case500:return服务器内部错误default:return请求失败${status}}}到这一步页面已经不需要知道 axios error 的内部结构。三、全局错误策略与业务边界不要在请求拦截器里把所有错误都弹出来很多项目会写request.interceptors.response.use(resres,err{ElMessage.error(请求失败)returnPromise.reject(err)})简单但容易出问题。有些错误根本不该弹比如搜索框联想用户输入 a → 请求 1 用户马上输入 ab → 取消请求 1 → 请求 2第一个请求被取消是正常行为。如果全局弹请求失败体验会很奇怪。有些业务错误需要页面自己展示登录页用户名或密码错误你可能希望显示在输入框下面而不是顶部弹一个 toast。订单页库存不足也可能要直接高亮对应商品。因此可以给请求增加“错误处理策略”。给请求增加错误策略而不是全局一刀切可以在 axios config 上扩展自定义字段。例如exportinterfaceRequestOptions{silent?:booleanskipAuthRedirect?:boolean}调用userApi.login(data,{silent:true})表示错误继续 reject但不要由全局 toast 自动提示页面自己展示也可以设计成interfaceErrorPolicy{mode:global|local|silent}三种模式模式行为global全局统一提示local页面自己处理silent不展示仅记录统一错误处理不等于所有错误使用同一种 UI而是统一决定“谁来处理”。401 最适合做成全局状态而不是每个页面自己跳登录401 很典型。假设 token 过期以后同时有 5 个请求发出去/users → 401 /menu → 401 /message → 401 /profile → 401 /config → 401如果每个请求都执行ElMessage.error(登录过期)router.push(/login)可能出现连续 5 个 toast重复跳转重复清 token可以加一个状态锁lethandlingUnauthorizedfalseasyncfunctionhandleUnauthorized(){if(handlingUnauthorized)returnhandlingUnauthorizedtruetry{authStore.clearSession()ElMessage.warning(登录状态已失效请重新登录)awaitrouter.replace(/login)}finally{handlingUnauthorizedfalse}}然后统一调用。如果有 refresh token流程会再复杂一点请求返回 401 ↓ 正在刷新 token ├─ 是 → 当前请求进入等待队列 └─ 否 → 发起 refresh ↓ 刷新成功 ↓ 重放等待请求但原则没变这是认证状态机不应该分散在每个业务页面。业务错误要让业务层拥有最后决定权假设后端返回{code:20031,message:当前商品库存不足,data:{available:2}}如果全局直接ElMessage.error(当前商品库存不足)信息虽然没错但页面其实还能做更多catch(err){if(isBusinessError(err,20031)){quantity.valueerr.data.availablefocusQuantityInput()return}throwerr}这里业务层最懂“库存不足之后该怎么办”。所以更合理的关系是底层把错误识别清楚全局层处理通用系统错误业务层处理业务语义组件层决定局部 UI四、页面、Vue 与浏览器的多层兜底页面里还需要 try/catch 吗需要但用途变了。以前catch{ElMessage.error(失败)}现在更像try{awaitsaveOrder(form)ElMessage.success(保存成功)}catch(err){if(isAppError(err)err.typeBUSINESS){handleOrderBusinessError(err)return}// 系统错误如果已经由全局处理这里不重复提示}或者页面根本不 catchawaituserApi.load()让通用错误向上进入统一处理。try/catch 不应该消失而应该从“重复弹错误”变成“处理当前页面真正知道怎么解决的错误”。Vue 自身的运行时错误也要有兜底接口错误只是一类。组件内部也可能出现constnameuser.profile.name但profile是undefined。 Vue 允许配置全局错误处理constappcreateApp(App)app.config.errorHandler(err,instance,info){console.error(Vue Error:,err)console.error(Component:,instance)console.error(Info:,info)}这里适合记录错误上报监控收集组件信息在必要时展示统一降级页。不适合任何 Vue Error 都直接 location.reload()因为有些错误只影响一个局部组件。window.onerror 和 unhandledrejection 补最后一层除了 Vue 捕获的错误还有浏览器全局错误。window errorwindow.addEventListener(error,event{console.error(Global Error:,event.error)})可以捕获部分运行时脚本错误和资源错误。Promise 未处理异常window.addEventListener(unhandledrejection,event{console.error(Unhandled Promise:,event.reason)})例如asyncfunctionload(){thrownewError(boom)}load()如果外层没有 catch就可能落到这里。这两个地方属于最后兜底。如果业务代码频繁依赖这里处理错误说明前面分层已经失控。五、日志、提示节流与组件错误边界错误日志应该记录什么如果只记录请求失败出了问题基本没法查。更有价值的日志应该包含{type:HTTP,status:500,url:/api/orders/123,method:PUT,requestId:xxx,route:/orders/123/edit,time:...}但要注意隐私和敏感数据。不要随手把密码Token身份证号完整请求 body全部上报。开发日志和用户提示不是一回事给开发看的PUT /api/orders/123 returned 500, request-idabc给用户看的保存失败请稍后重试两者不要混在一起。用户不需要看到AxiosError: Request failed with status code 500重复错误要做节流不然一次故障能弹满屏假设后端短暂不可用页面有十几个定时请求。每个都失败网络错误网络错误网络错误网络错误……可以做提示去重letlastErrorKeyletlastErrorTime0functionshowGlobalError(key:string,message:string){constnowDate.now()if(keylastErrorKeynow-lastErrorTime3000){return}lastErrorKeykey lastErrorTimenow ElMessage.error(message)}这样连续相同错误只提醒一次。错误处理不仅要“告诉用户失败了”还要避免错误提示本身成为新的体验问题。错误边界一个组件坏了不要让整个页面跟着死如果项目里有复杂看板页面 ├─ 用户统计 ├─ 订单图表 ├─ 消息列表 └─ 实时地图其中订单图表组件异常不一定要让整个页面白屏。可以设计错误边界组件template ErrorBoundary OrderChart / /ErrorBoundary /template概念上子组件正常 → 正常渲染 子组件报错 → 捕获 → 展示局部降级 UI例如订单图表暂时无法加载 [重新加载]而其他模块继续工作。这比全局直接白屏更稳。六、请求生命周期Loading、取消与状态恢复错误处理和 Loading 也要配合很多页面真正的 bug 不是错误没提示而是请求失败以后 loading 永远不结束错误处理时一定要确保状态恢复。tsloading.value truetry {await loadData()} finally {loading.value false}finally 的价值就在这里。不要写 ts loading.value true const res await loadData() loading.value false一旦await抛错最后一行就不执行。AbortController用户主动取消不是错误例如搜索letcontroller:AbortController|nullnullasyncfunctionsearch(keyword:string){controller?.abort()controllernewAbortController()returnrequest.get(/search,{params:{keyword},signal:controller.signal})}用户继续输入以后旧请求被取消。这属于正常控制流程。所以统一错误模型里最好有CANCELLED然后if(err.typeCANCELLED){return}不要弹任何提示。七、工程组织、完整链路与常见错误一个更完整的目录可以怎么组织项目大一点以后可以这样拆src/ ├─ api/ │ ├─ request.ts │ └─ modules/ ├─ errors/ │ ├─ types.ts │ ├─ normalize.ts │ ├─ handler.ts │ └─ reporter.ts ├─ components/ │ └─ ErrorBoundary.vue ├─ stores/ │ └─ auth.ts └─ main.ts职责types.ts → 错误类型 normalize.ts → axios / unknown 转 AppError handler.ts → 全局策略 reporter.ts → 日志上报这样页面只需要关心当前业务能不能处理这个错误而不是重复理解 HTTP、axios、token 和网络状态。把完整链路接起来一次请求失败以后理想流程可以是页面发起请求 ↓ Axios Request Interceptor ↓ 后端 ↓ Axios Response Interceptor ↓ 错误归一化为 AppError ↓ 判断错误类型 ├→ CANCELLED忽略 ├→ 401认证状态机 ├→ 403权限策略 ├→ NETWORK全局提示/离线状态 ├→ BUSINESS交给业务页面 └→ UNKNOWN记录 兜底运行时异常再走另一条Vue 组件错误 ↓ errorHandler / error boundary ↓ 记录 ↓ 局部降级Promise 漏网unhandledrejection ↓ 最后兜底到这里错误才真正被分层。最容易犯的几个错所有错误都在 axios 拦截器弹 toast问题业务无法决定 UI取消请求也会被当错误。所有页面都 try/catch问题重复判断、重复提示、规则不一致。catch 以后什么都不做catch(err){}错误被吞掉调试最难。把后端 message 原样展示给用户后端可能返回技术信息不一定适合作为 UI 文案。所有 500 都刷新页面局部错误被升级成整个应用中断。上报完整请求数据可能把敏感信息一起传出去。总结前端错误处理真正难的不是catch语法而是职责边界。项目规模起来以后可以用下面这张表检查每一层到底该做什么层级更适合处理的事情请求层网络异常、HTTP 状态、原始错误归一化全局策略401、权限、通用网络提示、日志入口业务层库存不足、表单校验、业务状态恢复组件层局部降级、错误占位、重试按钮Vue / Window运行时异常与最后兜底统一错误处理并不是“所有错误走同一个地方”而是让每一种错误只在真正知道怎么处理它的那一层停下来。所以页面里的try/catch不需要全部消失它只是不用再重复理解 axios、401、网络超时这些通用问题。判断分层是否有效也很简单如果页面代码越来越专注正常业务流程而通用错误逻辑逐渐从页面里退出这套设计就在发挥作用。资源分享【Linux】线程到底是什么从轻量级进程、虚拟地址到页表与 MMU一次理清线程底层模型【Linux】malloc 1GB 内存为什么没立刻占满从缺页、COW 到 Cache/TLB看懂线程为什么更轻【Linux】多线程打印为什么会乱pthread 创建、等待、退出、取消与分离全实战
返回列表