资讯动态

前端自学78天:从Promise到AbortController的异步请求封装

发布时间:2026/9/11 2:22:19 来源:尧图企业网站定制
1. 坚持78天我的前端学习路线是怎么走的先说明一下背景。这是我个人编程自学记录的第78天方向是Web前端开发。从HTML标签、CSS选择器一路学到JavaScript中间也摸过Node.js和Git今天刚好卡在“异步编程”这个坎上。这篇文章既是写给自己的阶段复盘也希望能给正在走同一条路的朋友一点参考。我不否认前端入门看起来很简单打开浏览器写一个div给div加个颜色马上就有反馈。但学到第70天往后真正的分水岭出现了——事件循环、Promise、异步请求这些概念不再只是“哦原来还有这个东西”而是必须老老实实写进代码里还要保证不出错。day78这个节点刚好是我决定把JavaScript异步这块彻底啃下来的时候。如果你也是自学编程、或者刚工作不久的前端新人这篇内容会有点用。我会把当天学到的异步控制原理、完整的代码封装过程、踩过的坑以及持续记录学习的技巧都整理出来。不管你是零基础还是写过一阵子代码都能从中找到可以直接拿去用的内容。先说我这个78天的路线是怎么安排的。前期没有直接冲到框架而是用最笨但最稳的方式原生三件套打底配合每日记录。第1到第20天HTML和CSS把页面布局、Flexbox、Grid用熟能独立还原中等难度的静态页面。第21到第45天JavaScript基础从变量、函数、作用域一路到数组方法、对象操作、DOM事件。第46到第60天开始接触网络请求和本地存储这时候已经意识到异步是个绕不过去的大山。第61到第75天补数据结构基础写了一些排序和查找的小练习顺带把Git的日常操作练熟了。第76天和第77天做了两个小项目来检验之前的积累一个待办事项管理页一个简易记账页面。这两个项目让我明确知道DOM操作和基本逻辑已经过关但一旦涉及请求并发、结果顺序控制这类场景代码就变得很别扭错误处理也经常出问题。也正是这两个项目让我把day78的学习目标锁定在“异步控制”上。2. 为什么前端学习者迟早要对异步编程较真2.1 异步不是JavaScript的附加题而是基本功很多入门者会把异步编程当成进阶内容觉得先把业务功能写完以后有空再补。但真实情况是从写前端页面的第一天起异步就在你身边。点击按钮触发一个请求从后端取数据这个过程就是异步的。页面加载时去拿用户信息也是异步的。哪怕是setTimeout延迟执行一段代码也离不开异步机制。我用一个生活化的类比来理解它。你去餐厅点餐如果服务员站在厨房门口等厨师炒完一道菜再端给你然后再回来接下一单那这个餐厅的效率会非常低。实际餐厅里服务员记下你的点单后会继续接待其他客人厨房做好了再通知服务员去取。这个“点单后不等菜好了才做下一件事”的模式就是异步。JavaScript里发请求就相当于点单继续执行下面的代码就是接待其他客人数据返回后的回调函数就是厨房出菜时的通知。之前写待办事项项目时我需要从本地存储读取数据再渲染到页面上。一开始代码如下let todos readFromStorage(); renderTodos(todos);看起来没毛病但如果readFromStorage换成真实的接口请求这两行代码的先后关系就会变得不可控。因为请求发出后JavaScript不会傻等结果而是继续往下执行renderTodos这时候todos还是空数组页面自然什么都渲染不出来。只有理解了这一点你才会明白为什么前端圈有那么多解决方案回调函数、Promise、async/await、Generator都是在解决同一个问题——如何更好得表达和组织“依赖前一个结果才能执行后面操作”的代码逻辑。2.2 从回调函数到Promise一次对代码结构的升级最初我学异步时用的是回调函数。比如读取文件、发起请求时把后续操作塞进回调里getData(function(data) { console.log(data); });简单场景下没问题但一旦有多个依赖关系先取用户信息再根据用户信息取订单列表再根据订单列表取订单详情代码就变成了这样getUser(function(user) { getOrders(user.id, function(orders) { getOrderDetail(orders[0].id, function(detail) { console.log(detail); }); }); });这就是传说中的“回调地狱”。代码向右生长每一层都缩进一格调试和修改都困难。更要命的是错误处理每一层都得单独判断一旦漏掉出错时根本不知道问题出在哪一层。Promise解决的核心问题是把这种层层嵌套的写法改成了链式调用getUser() .then(function(user) { return getOrders(user.id); }) .then(function(orders) { return getOrderDetail(orders[0].id); }) .then(function(detail) { console.log(detail); }) .catch(function(err) { console.error(err); });代码从“横向嵌套”变成了“纵向链式”可读性好了很多。不过刚开始学Promise时我总有一种说不出的别扭感明明就是想按顺序做三件事为什么写出来的代码不像顺序执行后来才明白Promise的本质不是取消异步而是把异步结果包装成一个状态对象然后再用then和catch去订阅这个状态的变化。它不会让代码变成同步只是让异步关系的表达更接近人的思考方式。2.3 弄懂这五个字状态、终态、链式Promise最基础、也最关键的五个字是“状态只能变一次”。一个Promise对象有三种状态pending进行中、fulfilled已成功、rejected已失败。状态可以从pending变为fulfilled或者从pending变为rejected但一旦状态确定就不能再变。这个“一次性的状态变化”是理解Promise链式调用的钥匙。我做了个小实验来验证这个特性const p new Promise((resolve, reject) { resolve(第一次成功); resolve(第二次成功); reject(错误); }); p.then((res) { console.log(res); // 只会打印第一次成功 });实际输出只有一个值这就是状态不可逆造成的效果。后续的resolve、reject会被直接忽略。理解了这个就能明白为什么一个Promise可以安全地被多次then订阅也不会因为业务上的重复计算而错乱。之后再去看async/await就顺理成章了。它本质上是Promise的语法糖让异步代码的书写方式几乎接近同步async function loadData() { const user await getUser(); const orders await getOrders(user.id); const detail await getOrderDetail(orders[0].id); console.log(detail); }注意这里并不是没有异步了。await只是把它后面的代码放到Promise状态变化之后再执行。真正执行时代码还是在事件循环中被调度。理解这一点很重要否则后续排查问题时很容易瞎猜。3. 第78天的实操写一个带超时控制的请求封装3.1 需求拆解为什么需要一个自己的请求封装理论学完不练等于白学。当天我给自己定了一个明确的任务不用第三方库只靠原生JavaScript封装一个带超时控制的请求函数。为什么选这个任务因为单纯发一个请求太简单真正有挑战的是接口迟迟不返回时前端不能被卡死接口正常返回时能区分成功和失败多个请求同时发出时每个请求的失败不能影响另一个。同时还要考虑用户取消请求后回调不能再触发。这个需求在真实业务里很常见。比如搜索框输入关键字时防抖之后发出请求如果用户输入了新的内容前一个请求还没返回就需要取消或忽略它。再比如上传文件、加载报表都需要有个“等待超时”的兜底机制不能无限白屏。3.2 第一版实现Promise加setTimeout先写一个最基础的版本思路是“谁先完成谁决定结果”用Promise.race来实现。function requestWithTimeout(url, options {}, timeout 5000) { return Promise.race([ fetch(url, options), new Promise((_, reject) { setTimeout(() { reject(new Error(请求超时)); }, timeout); }) ]); }Promise.race接收一个Promise数组只要其中一个先进入完成状态整个race的结果就跟着这个最先完成的Promise走。也就是说如果fetch在5秒内返回就使用fetch的结果如果5秒后还没返回setTimeout就会触发reject外层的promise被判为失败。第一版写完之后我测试了“正常返回”和“接口超时手动模拟延迟”两个场景基本能工作。但紧接着就发现一个潜在问题setTimeout设置的定时器即使fetch已经返回了它本身也还在。如果这个定时器一直不被清除在极端情况下会持有对回调的引用造成无谓的保留。更明显的问题在于这个封装只能区分“超时”和“未超时”如果页面已经跳走了请求返回的数据还是会执行后续逻辑没有任何取消机制。3.3 第二版引入AbortController实现真正的取消解决上述问题需要用到浏览器的AbortController。这个API本身不复杂核心就是一个signal对象把它传给fetch然后可以通过调用abort()方法主动让请求中断。function requestWithTimeout(url, options {}, timeout 5000) { const controller new AbortController(); const { signal } controller; const timeoutId setTimeout(() { controller.abort(); }, timeout); return fetch(url, { ...options, signal }) .then((response) { clearTimeout(timeoutId); if (!response.ok) { throw new Error(HTTP错误${response.status}); } return response.json(); }) .catch((err) { clearTimeout(timeoutId); if (err.name AbortError) { throw new Error(请求已取消); } throw err; }); }这一版的关键变化是超时不再是额外创建一个promise去竞争而是通过abort主动取消原请求。fetch收到abort信号后会抛出一个名称为AbortError的异常我们在catch里捕获到它再转成业务上能识别的错误信息。这里有个很重要的细节为什么abort之后还要手动抛一个错误因为fetch被abort时catch会接收到一个AbortError对象。如果直接把这个对象抛给上层业务代码业务方需要知道AbortError这个特定名称才能处理。更好的做法是在封装层统一转换成语义明确的错误消息或者标记一个canceled字段让业务方可以单独判断。我还额外做了一个“是否手动取消”的标记用于区分“超时自动取消”和“业务主动取消”。但这个可以以后再细说先把基础版跑通。3.4 调用方式与测试验证封装完成后我用一个模拟接口来验证。模拟接口用Promise加setTimeout模拟延迟响应function mockApi(delay, data) { return new Promise((resolve) { setTimeout(() { resolve(data); }, delay); }); } async function loadInfo() { try { const data await requestWithTimeout( /api/info, { method: GET }, 3000 ); console.log(请求成功, data); } catch (err) { console.error(请求失败, err.message); } } loadInfo();如果mockApi延迟2秒请求能正常通过。如果延迟4秒而超时时间设为3秒就会看到控制台输出了“请求已取消”或“请求超时”。注意我用的是requestWithTimeout超时时间是3秒而mockApi延迟4秒abort触发后promise立刻进入rejected状态不会傻傻等4秒。刚开始我以为这样就可以了结果把超时时间调成0时发现fetch请求根本没发出去就直接被abort了。仔细一想也对setTimeout设为0代码一执行就立刻触发abort请求都没机会发出。这也引出一个隐约的坑超时时间的边界值需要谨慎处理生产环境里可以留一个默认值比如5000毫秒然后允许调用方覆盖。4. 今天踩过的坑和排查过程4.1 被“吞掉”的异常第一个坑出现在Promise链里没有写catch的时候。我一开始测试时比较大意没有给mockApi调用加异常处理。某个测试用例里我在promise里手动抛了一个错误结果控制台只报了个“Uncaught (in promise)”的错误没有具体堆栈整个页面也没有任何反应。这个问题的本质是Promise内部的异常如果没有被catch捕获就不会向外传播到常规的try/catch体系。它会被放入一个“未处理的promise拒绝”状态在浏览器里表现成一条控制台警告而不会像普通同步异常那样直接中断脚本。这个坑在排查时最大的麻烦是它不会影响其他代码的运行看起来“好像没出错”但实际上数据没有渲染出来。解决办法很简单给所有Promise链末尾加catch或者统一使用async/await并用try/catch包裹。但更方便的是一开始就形成一个习惯任何会“产生Promise”的函数调用时要么await要么then之后接catch没有第三条路可选。4.2 setTimeout留下的定时器第二个坑是返回的时候忘记clearTimeout。虽然第一版用Promise.race时定时器即使不清除功能上也能跑通。但一旦请求很快返回定时器仍然在倒计时到了时间后还会执行reject只不过这个reject不会对已经settled的Promise有任何影响。看似无害其实是在白白消耗资源。当时我用Chrome的性能面板看任务记录发现大量过期的timer在积攒。虽然我的测试页面只发了几个请求但页面如果长时间运行、请求量大这些定时器累积到一定数量会对性能产生可感知的影响。修复方式就是从第二版开始的clearTimeout在请求成功和异常分支中都清理一次。我在实际代码里甚至用了一个finally来确保清理这样更稳妥let timeoutId; try { const response await fetch(url, { ...options, signal }); const data await response.json(); return data; } finally { clearTimeout(timeoutId); }try、finally的组合可以保证无论请求成功还是失败定时器都会消失。这也算是一个小小的“防御性编程”实践。4.3 多请求并发与Promise.all的行为验证第三个想说的不是报错而是一个容易理解偏差的点。写请求封装的同时我顺手验证了一下Promise.all的行为。当几个并发请求中有一个失败时Promise.all会直接进入rejected状态其他请求的结果全部被丢弃。const results await Promise.all([ requestWithTimeout(/api/a, {}, 3000), requestWithTimeout(/api/b, {}, 3000), requestWithTimeout(/api/c, {}, 3000) ]);如果接口b在500毫秒时返回500那么整个Promise.all立刻失败接口a和c即使稍后会成功返回也不会出现在结果里。这在有的场景下是符合预期的比如一次性加载多个基础配置任何一个失败都应该中断初始化流程。但如果有类似“图片批量上传”这样的需求一个失败不应该影响其他图片上传那就要用Promise.allSettled它会等所有请求都结束后把每个结果包装成{status: fulfilled, value}或{status: rejected, reason}的形式返回。我做了个小实验打印出Promise.allSettled的结果能看到它不会主动抛错而是把所有状态都收纳进一个数组。这个特性在业务中非常常用比如表单里多个字段分别调接口校验全部校验完再统一提示就是典型场景。4.4 常见问题速查表我整理了一个小表格作为当日学习的速查卡。放在这里供有类似困惑的朋友参考现象原因解决方式Promise 报错但没有中断未给 Promise 链加 catch每个 Promise 链末尾补 catch或改用 async/await多个请求同时失败页面全白Promise.all 遇错即放弃需要部分成功时改用 Promise.allSettled请求已取消但后续代码仍执行未检查 AbortError在 catch 中判断 err.name 并return定时器残留性能下降忘记 clearTimeout在 finally 中清理定时器接口很快返回但数据没渲染请求发出后未把结果交给渲染函数确认 then 里是否 return 了数据这张表并不复杂但我认为它比很多教程里的长篇大论更实用。排查问题时能按图索骥比临时翻文档强得多。5. 学到这里我的一些体会5.1 怎样才算真正掌握了异步编程掌握了异步的语法不代表掌握了异步的思维。语法层面知道Promise怎么new、then和catch怎么用、async/await怎么写这大概只需要一天。但“真正掌握”在我看来有几个具体的标志。首先是当一个请求失败时能准确说出错误是在哪一层被抛出在哪一层被捕获。其次是面对一个“不等所有请求完成就要展示部分内容”的需求时知道该用all还是allSettled还是race而不是所有场景都套同一个函数。最后是能解释清浏览器事件循环里宏任务和微任务的大致调度关系比如setTimeout的回调为什么不会在Promise的then之前执行。这些内容仅靠看教程很难内化必须通过亲手写代码、亲手制造bug、再亲手解决来获得。day78的这次实操让我明显感觉到自己对这些概念的理解比一周前深入了不只一个档次。5.2 学习日记应该记什么而不是记流水账到目前为止写了78天日记我积累了一个重要的经验学习日记不是把每天学了什么列一遍而是要记录“今天的认知变化”。最直接的写法是回答三个问题今天有没有遇到让我觉得“原来如此”的点今天有没有报错是我不能一眼看懂的如果用一句话向昨天的自己介绍今天的收获会是什么我每天日记的格式基本固定当天日期和天数、学习内容概要、代码片段或报错截图、指出的“为什么”、明天计划。这样写的好处是过一个月回看时能快速找到当时的思考过程而不仅仅是知识清单。比如我今天写的就是“Promise状态只能变一次”加上AbortController的封装代码以及第一版和第二版对比的差异说明。这条记录以后如果再遇到请求取消相关的问题直接翻出来就能用。还要留一个“未完事项”。今天我记录下来的后续计划是把请求封装升级成支持取消队列的版本再研究一下如果fetch本身在低版本浏览器里不可用可以用XMLHttpRequest怎么兼容。这些都是从当天学习中自然延伸出来的问题比凭空定计划要更贴合自己的实际节奏。5.3 长期坚持是学习者最容易被低估的杠杆78天看起来不短但真正让我觉得“值了”的不是某个技术点的掌握而是每天固定投入这件事本身。编程入门最大的门槛不是智商、不是英语、不是数学底子而是能不能在遇到挫折时仍然保持每天一点点的推进。学习曲线不是直线上升的更像是连续爬坡加偶尔的陡升。前面一个月可能感觉没什么长进但第二阶段的项目练习会把基础串起来第三阶段接触异步、工程化之后再回头看之前的代码就会有一种“曾经觉得难的东西现在看起来很幼稚”的直观感受。这种反馈是坚持的最好燃料。如果你也在写自己的学习记录我建议不用等准备好了再开始。第1天就写“今天学会了安装编辑器然后照着文档打了三行代码”也没问题。重要的是让记录这件事形成惯性让每一天都有一点东西沉淀下来。最后再分享一个小技巧我会在每个周的星期天用“如果下周只能学3个知识点选哪3个”来倒排优先级。这个做法帮我避免了很多“什么都想学、什么都学不深”的陷阱。异步编程这个主题能排到day78正是因为前面有超过十天的项目练习让我认清了自己最缺的到底是什么。学习这种东西方向对了哪怕慢一点也迟早能到目的地。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价