
一、题目与考察点题目如何给一个 Promise例如 fetch 网络请求增加超时功能超时后拒绝并能真正取消底层请求考察点Promise 的状态机特性pending → fulfilled/rejected状态一旦敲定不可逆、不可从外部取消Promise.race竞速模型竞态条件的处理错误分类与错误处理设计AbortController/AbortSignal标准取消机制资源清理意识定时器、事件监听器主要矛盾 vs 次要矛盾主要矛盾Promise 一旦创建就无法从外部中断 —— 即使判定超时底层请求仍在跑浪费资源。→ 解决靠AbortController。次要矛盾 1竞态条件 —— 正常完成与超时几乎同时发生时如何保证逻辑正确。→ 由 Promise 状态只能敲定一次的特性 Promise.race天然解决。次要矛盾 2错误信息清晰度 —— 调用方必须能区分超时 / 主动取消 / 服务端 500 / 网络断开。→ 靠错误类型name或自定义 Error 类区分。次要矛盾 3资源清理 —— 业务先完成时要clearTimeout避免定时器泄漏。二、核心思路一句话用Promise.race让业务 Promise 与超时 Promise 竞速并用AbortController与AbortSignal把取消信号同时传给底层请求和超时定时器并配合clearTimeout和错误类型标记从而实现超时、取消、错误区分与资源释放。三、解决方案流程图文本版┌─────────────────────────────────────────────────────────┐ │ withTimeout(businessPromise, ms) │ └──────────────────────────┬──────────────────────────────┘ │ ┌──────────────────┴──────────────────┐ │ 创建 AbortController │ │ signal 同时传给 fetch 和计时逻辑 │ └──────────────────┬──────────────────┘ │ ┌──────────────┴───────────────┐ │ Promise.race │ │ │ │ 选手A: 业务Promise(fetch) │ 选手B: 超时Promise(setTimeout) │ │ └──────┬─────────────────┬──────┘ │ │ ┌──────────▼──────┐ ┌───────▼─────────────┐ │ A 先到达(成功/失败)│ │ B 先到达(时间耗尽) │ └──────────┬──────┘ └───────┬─────────────┘ │ │ ┌─────────────▼─────┐ ┌───────▼──────────────────┐ │ clearTimeout │ │ controller.abort(超时原因) │ │ 清理超时定时器 │ │ → fetch 中断 HTTP 连接 │ └─────────────┬─────┘ │ → reject TimeoutError │ │ └───────┬──────────────────┘ ▼ ▼ resolve(业务数据) catch(err) 按 err.name 分流: ├─ TimeoutError → 提示请求超时 ├─ AbortError → 提示已取消 └─ 其他 → 提示网络/服务错误取消场景用户点取消按钮分支用户点击取消 → controller.abort() ├─→ fetch 收到 signal → 中断 HTTP 请求 └─→ 超时 Promise 监听到 abort 事件 → clearTimeout reject(AbortError) 超时逻辑自身也可被取消双向联动四、方案演进从及格到满分方案 V1基础版Promise.race及格分/** * 创建一个计时用的 Promise * 它只负责一件事ms 毫秒后 reject 一个超时错误 * param {number} ms - 超时时间毫秒 * returns {Promisenever} 永远不会 resolve只会 reject 的 Promise */functiontimeoutPromise(ms){returnnewPromise((resolve,reject){// 时间一到立刻 reject让 race 的结局变为超时setTimeout((){reject(newError(操作超时${ms}ms));},ms);});}/** * 给任意 Promise 增加超时能力基础版 * param {Promiseany} promise - 业务 Promise例如 fetch(...) * param {number} ms - 超时毫秒数 * returns {Promiseany} 竞速后的 Promise */functionwithTimeout(promise,ms){// Promise.race数组里谁先敲定状态resolve 或 reject// 返回的 Promise 就以谁的结果作为最终结果且状态不可再改变// —— 这天然解决了竞态条件即使两者几乎同时完成也只有一个能生效returnPromise.race([promise,timeoutPromise(ms)]);}// 使用示例 withTimeout(fetch(https://api.example.com/data).then(resres.json()),5000// 5 秒超时).then(dataconsole.log(成功:,data)).catch(err{// ⚠️ 基础版缺陷1靠字符串匹配区分错误脆弱不推荐if(err.message.includes(超时)){console.error(请求超时请检查网络);}else{console.error(其他错误:,err);}// ⚠️ 基础版缺陷2即使请求成功setTimeout 依然在后台倒计时// 到点后会 reject 一个无人监听的 Promise结果被忽略但定时器占着资源// 在 Node.js 中甚至会阻止进程正常退出// ⚠️ 基础版缺陷3超时后原始 fetch 请求仍在后台继续传输资源浪费});V1 结论解决了超时判定和竞态条件但存在定时器泄漏和无法真正取消请求两个问题。方案 V2修复定时器泄漏补上遗漏点/** * 带自动清理的超时封装V2 * 关键点无论业务 Promise 成功还是失败都要 clearTimeout * 避免定时器泄漏尤其 Node.js 中会阻止进程退出除非 timer.unref() */functionwithTimeoutV2(promise,ms){lettimernull;// 保存定时器句柄供后续清理// 计时 Promise把 clearTimeout 的控制权暴露出来consttimeoutnewPromise((resolve,reject){timersetTimeout((){consterrnewError(操作超时${ms}ms);err.nameTimeoutError;// 用 name 标记错误类型而不是靠 message 字符串reject(err);},ms);});// Promise.race 竞速finally 保证无论谁赢定时器都被清除returnPromise.race([promise,timeout]).finally((){clearTimeout(timer);// ✅ 业务先完成 → 清掉定时器防止泄漏});}方案 V3满分版 ——Promise.raceAbortController真正取消这是的核心方案下面给出完整、修正后的实现/** * 满分版超时封装竞速 真正可取消 资源清理 错误类型区分 * * param {function} taskFactory - 一个函数接收 signal返回业务 Promise * 注意传工厂函数而不是现成的 Promise * 因为 signal 必须在创建 fetch 时就注入 * param {number} ms - 超时毫秒数 * returns {Promiseany} */asyncfunctionwithTimeout(taskFactory,ms){// 1️⃣ 创建控制器它是取消信号的源头// controller.signal 广播给 fetch中断请求和超时逻辑清除定时器constcontrollernewAbortController();const{signal}controller;lettimernull;// 2️⃣ 超时 Promise专职计时且自身也监听 abort 事件可被外部取消consttimeoutnewPromise((resolve,reject){timersetTimeout((){// 时间到主动触发 abort让底层 fetch 真正中断 HTTP 连接// 传入原因对象abort 事件的 reason 和 fetch 的报错都会携带它controller.abort(newDOMException(请求超时${ms}ms,TimeoutError));},ms);// 监听取消事件无论是超时触发的 abort还是用户手动 abort// 都会走到这里 —— 清定时器 以 signal.reason 作为 reject 原因signal.addEventListener(abort,(){clearTimeout(timer);// ✅ 超时逻辑自身也被取消定时器不泄漏reject(signal.reason);// reason 是 DOMExceptionname 为 TimeoutError 或 AbortError},{once:true});// once: true 防止监听器泄漏});try{// 3️⃣ 竞速业务任务 vs 超时// 业务任务的 signal 与超时逻辑共用同一个 controller实现双向联动returnawaitPromise.race([taskFactory(signal),timeout]);}finally{// 4️⃣ 兜底清理业务先成功完成时也要清掉定时器并解除绑定clearTimeout(timer);}}// 使用场景 1给 fetch 加 5 秒超时 withTimeout(// fetch 原生支持 signal 配置项收到 abort 会真正断开 HTTP 连接(signal)fetch(https://api.example.com/data,{signal}).then(res{if(!res.ok)thrownewError(服务端错误: HTTP${res.status});// 区分 500 等业务错误returnres.json();}),5000// 5000 毫秒).then(dataconsole.log(成功:,data)).catch(err{// 5️⃣ 按错误的 name 精确分流而不是匹配 message 字符串switch(err.name){caseTimeoutError:console.error(⏰ 请求超时建议用户检查网络或重试);break;caseAbortError:console.error( 请求被用户主动取消);break;default:console.error(❌ 网络或服务端错误:,err.message);// 如 HTTP 500、断网}});// 使用场景 2用户点击取消按钮 constcontrollernewAbortController();// 发起可取消的请求signal 同时给 fetchfetch(https://api.example.com/big-file,{signal:controller.signal}).then(resres.blob()).catch(err{if(err.nameAbortError)console.log(用户取消了下载);});// 页面上取消按钮的点击处理document.querySelector(#cancelBtn).addEventListener(click,(){controller.abort();// 一个调用同时中断HTTP 请求 关联的超时定时器});方案 V4更优解AbortSignal.timeout()—— 现代标准一行流现代浏览器Chrome 103 / Firefox 100 / Safari 16和 Node.js 17.3 已内置静态方法AbortSignal.timeout(ms)它返回一个到点自动 abort、reason 为TimeoutError的 signal连Promise.race都不需要写// 场景A只需要超时不需要手动取消 fetch(https://api.example.com/data,{signal:AbortSignal.timeout(5000)// ✅ 一行搞定5秒后自动 abortreason.name TimeoutError}).then(resres.json()).then(dataconsole.log(成功:,data)).catch(err{if(err.nameTimeoutError){console.error(⏰ 超时);// 超时DOMException, nameTimeoutError}elseif(err.nameAbortError){console.error( 被取消);}else{console.error(❌ 其他错误:,err);// 网络错误(TypeError: Failed to fetch)、HTTP错误等}});// 场景B既要超时又要支持用户手动取消组合多个 signal// AbortSignal.any() 将多个 signal 合并为一个任意一个 abort合并的 signal 就 abortconstuserControllernewAbortController();// 用户取消用fetch(https://api.example.com/data,{signal:AbortSignal.any([userController.signal,// 来源1用户手动取消AbortSignal.timeout(5000),// 来源25 秒自动超时])}).then(resres.json()).then(dataconsole.log(data)).catch(errconsole.error(失败原因:,err.name));// TimeoutError 或 AbortError// 用户点取消按钮document.querySelector(#cancelBtn).onclick()userController.abort();V4 优势无手写定时器、无泄漏风险、无竞态边界问题、错误类型标准化是当前生产环境的最优解需兼容旧浏览器时降级到 V3。附通用版不依赖 fetch适配任意 Promise如 Node.js 数据库查询/** * 通用超时包装器适用于不支持 signal 的任意 Promise * 注意这类场景只能做到不再等待无法真正中断底层操作主要矛盾无解 * 只能靠底层库自身提供的取消能力如 mongoose 的 abort 选项 */functionwithGenericTimeout(promise,ms,taskName任务){lettimer;consttimeoutnewPromise((_,reject){timersetTimeout((){// 自定义错误类便于调用方 instanceof 精确判断classTimeoutErrorextendsError{constructor(msg){super(msg);this.nameTimeoutError;}}reject(newTimeoutError(${taskName}超时${ms}ms));},ms);// Node.js 专属unref() 让该定时器不阻止进程退出可选优化if(typeoftimer.unreffunction)timer.unref();});returnPromise.race([promise,timeout]).finally(()clearTimeout(timer));}// 使用给数据库查询加 3 秒超时withGenericTimeout(db.query(SELECT * FROM huge_table),3000,数据库查询).then(rowsconsole.log(rows)).catch(err{if(err.nameTimeoutError)console.error(查询超时);elseconsole.error(查询失败:,err);});五、使用场景场景说明推荐方案HTTP 接口请求超时最常见后端慢/弱网避免页面卡死干等V4AbortSignal.timeout或 V3文件上传/下载大文件需要更长超时 用户手动取消按钮V3 / V4 场景BAbortSignal.any组合页面切换时取消未完成请求ReactuseEffect清理函数、VueonUnmounted中调用abort()防止组件销毁后 setStateV3数据库/第三方 SDK 调用底层不支持 signal 的任意 Promise通用版只能放弃等待请求重试策略超时后配合指数退避重试V4 retry 封装微服务间调用类似 gRPC deadline、熔断降级的前置能力V3/V4React 中的典型用法示例import{useEffect}fromreact;functionUserProfile({userId}){useEffect((){// 每次 effect 创建独立 controllerconstcontrollernewAbortController();fetch(/api/user/${userId},{// 组合组件卸载取消 5秒超时任一触发即中断signal:AbortSignal.any([controller.signal,AbortSignal.timeout(5000)])}).then(resres.json()).then(setUser).catch(err{if(err.nameAbortError)return;// 卸载导致的取消静默忽略console.error(err.nameTimeoutError?请求超时:请求失败);});// ✅ 清理函数组件卸载或 userId 变化时取消旧请求防止内存泄漏和竞态数据覆盖return()controller.abort();},[userId]);// ...}六、边界场景与易错点边界场景问题应对业务先成功定时器未清setTimeout泄漏Node.js 阻止进程退出finally中clearTimeoutV2 起已内置超时与完成几乎同时发生竞态担心状态错乱Promise 状态只能敲定一次race天然保证只有一个结果生效无需额外加锁超时后原始请求还在跑带宽/服务端资源浪费、返回数据迟到造成副作用必须用AbortController传导取消V3/V4仅race做不到abort 后 fetch 抛什么错signal.reason未指定时抛AbortErrorabort(reason)指定后抛该 reason超时用abort(new DOMException(..., TimeoutError))或AbortSignal.timeout()以便和用户取消区分AbortSignal.timeout的 rejection 无人处理若 signal 创建了但没绑定到任何任务超时会产生未处理的 abort只在创建请求时即时生成 signal不要提前囤放超时时间设为 0 或负数setTimeout(fn, 0)仍会在下一个宏任务触发可能永远赢不了已在微任务队列的 resolved Promise参数校验ms必须为正整数重复 abort对已 abort 的 controller 再次abort()规范上是无操作no-op安全监听器泄漏signal.addEventListener(abort, ...)长期持有{ once: true }或事后removeEventListenerHTTP 错误 ≠ 请求失败fetch 对 404/500 也会 resolve在then中检查res.ok与超时/网络错误分开处理旧环境兼容AbortSignal.timeout/AbortSignal.any需较新版本降级到 V3 手写方案或用 polyfill七、满分答案面试直接背诵版一句话核心用Promise.race让业务 Promise和超时 Promise竞速解决超时判定与竞态用AbortController把超时/取消信号传导给底层请求解决真正中断用clearTimeout 错误name标记解决资源清理与错误区分。完整回答分四层展开原理层点出主要矛盾Promise 状态机一旦从 pending 敲定就不可逆且无法从外部取消——这是主要矛盾。所以超时分两个层次①不再等待Promise.race可以做到②真正终止底层操作必须靠AbortController。基础实现封装一个setTimeout到点就 reject 的计时 Promise与业务 Promise 一起丢进Promise.race。race 的先到先得 状态只敲定一次特性天然解决了正常完成与超时的竞态条件不需要额外加锁。满分细节四个必补点真正取消创建AbortController把signal传给fetch({ signal })超时时调用controller.abort(new DOMException(超时, TimeoutError))HTTP 连接被真正断开不浪费带宽和服务端资源双向联动计时 Promise 内部也监听signal的abort事件{ once: true }用户手动取消时同步清除定时器——超时逻辑自身也可被取消资源清理finally中clearTimeout防止业务先完成时定时器泄漏Node.js 中还会阻止进程退出可加timer.unref()错误分类靠err.nameTimeoutError/AbortError/ 其他精确分流给用户提供超时请重试 / 已取消 / 服务异常等不同提示不要靠 message 字符串匹配同时记得 fetch 对 HTTP 500 也会 resolve需检查res.ok单独归类业务错误。更优解亮点加分现代环境Chrome 103、Node 17.3直接用标准 APIAbortSignal.timeout(5000)作为 fetch 的 signal一行代码即可无手写定时器、无泄漏若还要支持用户手动取消用AbortSignal.any([userController.signal, AbortSignal.timeout(5000)])合并信号。需要兼容旧浏览器时再降级到手写Promise.race AbortController方案。对于不支持 signal 的场景如某些数据库 SDKPromise.race只能放弃等待、无法真正中断需向面试官说明这一局限。