使用Promise告别回调函数|浏览器篇
# 使用Promise告别回调函数
30 秒速记
Promise主要改善异步代码的组织方式,不会把异步任务变成同步任务,也不会消除任务本身的耗时。- 它把异步操作抽象为一个可传递的结果对象,并用成功与失败两种路径描述最终状态。
- 依赖关系可以通过链式调用展开,业务步骤不必持续嵌套在前一步的回调内部。
- 链路中的失败可以沿调用链传递并集中处理,从而减少每一步重复声明错误分支。
Promise仍依赖事件循环执行后续处理;它解决的是可读性与控制流问题,不是线程调度或性能问题。
Promise解决的是异步代码难组织的问题,让连续操作和错误处理不必层层嵌套。 它把异步结果包装成可传递的对象,通过链式调用表达前后依赖,失败也能沿调用链交给统一位置处理。比如多个异步步骤存在先后关系时,代码会比连续回调更容易阅读和维护。需要注意,Promise仍由事件循环调度,不会让任务同步执行,也不会缩短任务本身的耗时。
在上一篇文章中我们聊到了微任务是如何工作的,并介绍了 MutationObserver 是如何利用微任务来权衡性能和效率的。今天我们就接着来聊聊微任务的另外一个应用Promise,DOM/BOM API 中新加入的 API 大多数都是建立在 Promise 上的,而且新的前端框架也使用了大量的 Promise。可以这么说,Promise 已经成为现代前端的“水”和“电”,很是关键,所以深入学习 Promise 势在必行。
不过,Promise 的知识点有那么多,而我们只有一篇文章来介绍,那应该怎么讲解呢?具体讲解思路是怎样的呢?
如果你想要学习一门新技术,最好的方式是先了解这门技术是如何诞生的,以及它所解决的问题是什么。了解了这些后,你才能抓住这门技术的本质。所以本文我们就来重点聊聊 JavaScript 引入 Promise 的动机,以及解决问题的几个核心关键点。
要谈动机,我们一般都是先从问题切入,那么 Promise 到底解决了什么问题呢?在正式开始介绍之前,我想有必要明确下,Promise 解决的是异步编码风格的问题,而不是一些其他的问题,所以接下来我们聊的话题都是围绕编码风格展开的
面试官追问
追问 1接口负责人说把商品请求从回调函数改成 Promise,首屏网络耗时就会从 800ms 降下来;你会接受这个优化结论吗?
不会,Promise 解决的是异步结果的编码和组织方式,不会直接缩短网络或服务端处理时间。改造后应以可读性、组合关系和错误路径是否更清晰来验收,不能把请求耗时下降归因于语法形式。
追问 2结算页有请求、库存校验和结果渲染三段异步流程,回调层层嵌套且失败分支散落;你会要求怎样评估 Promise 改造?
这类存在依赖链和多处分支的流程适合评估 Promise,重点是把结果传递与后续步骤组织成清晰链路。改造仍需保留每一步的成功、失败和业务状态处理;Promise 只改善编码结构,不会自动修复流程逻辑。
追问 3一个旧页面只有一次请求和一次回调,团队为统一技术栈要求全部包装成 Promise;你会无条件支持吗?
不会无条件支持,单层回调若已足够清晰,包装后的结构收益可能有限。只有后续需要组合异步步骤、传递依赖结果或集中管理错误路径时,Promise 的编码优势才更明显;统一形式也会带来改造和回归成本。
追问 4线上把回调改成 Promise 后仍出现输出顺序与开发预期不一致,有人据此判断 Promise 没有生效;你会先查什么?
应先还原同步代码、异步结果产生点以及后续处理的实际调度顺序,而不是仅按源码行号推断。Promise 是微任务机制的重要应用,后续处理仍受事件循环时机约束;改写编码风格不等于同步执行。
追问 5架构评审中一方主张 Promise 能解决所有异步故障,另一方坚持继续使用多层回调;你会怎样划定方案边界?
两种说法都过度了,Promise 的核心价值是改善异步编码风格,使依赖关系和结果交付更易组织。它不会让任务更快,也不会自动消除网络失败或业务错误;简单流程可保留清晰回调,复杂链路则更值得结构化改造。
# 异步编程的问题:代码逻辑不连续
30 秒速记
- 页面主线程发起耗时操作后,由主线程之外的执行单元处理,主线程可以继续消费其他任务。
- 异步操作完成后,相关任务进入消息队列,等待事件循环取出,再执行注册的回调。
- 单次
XMLHttpRequest需要分别监听成功、错误和超时等结果,配置代码与业务处理容易交错。 - 回调会把发起操作与处理结果拆到不同位置;分支越多,控制流越难按业务顺序阅读。
- 异步回调避免耗时操作长期占用主线程,但代价是代码连续性下降,需要进一步封装控制流。
异步编程的问题在于,任务的发起和结果处理被回调拆开,代码很难按业务顺序连续阅读。 耗时操作会交给主线程之外的执行单元,完成后再进入消息队列,由事件循环取出并触发回调。比如一次 XMLHttpRequest 既要处理成功,也要监听错误和超时,配置与业务逻辑很容易交错。这样能避免耗时任务长期占用主线程,但分支和回调越多,控制流越难维护,通常需要进一步封装。
首先我们来回顾下 JavaScript 的异步编程模型,你应该已经非常熟悉页面的事件循环系统了,也知道页面中任务都是执行在主线程之上的,相对于页面来说,主线程就是它整个的世界,所以在执行一项耗时的任务时,比如下载网络文件任务、获取摄像头等设备信息任务,这些任务都会放到页面主线程之外的进程或者线程中去执行,这样就避免了耗时任务“霸占”页面主线程的情况。你可以结合下图来看看这个处理过程:

上图展示的是一个标准的异步编程模型,页面主线程发起了一个耗时的任务,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。等该进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。
这就是页面编程的一大特点:异步回调。
Web 页面的单线程架构决定了异步回调,而异步回调影响到了我们的编码方式,到底是如何影响的呢?
假设有一个下载的需求,使用 XMLHttpRequest 来实现,具体的实现方式你可以参考下面这段代码:
// 执行状态
function onResolve(response){console.log(response) }
function onReject(error){console.log(error) }
let xhr = new XMLHttpRequest()
xhr.ontimeout = function(e) { onReject(e)}
xhr.onerror = function(e) { onReject(e) }
xhr.onreadystatechange = function () { onResolve(xhr.response) }
// 设置请求类型,请求 URL,是否同步信息
let URL = 'https://time.geekbang.com'
xhr.open('Get', URL, true);
// 设置参数
xhr.timeout = 3000 // 设置 xhr 请求的超时时间
xhr.responseType = "text" // 设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")
// 发出请求
xhr.send();
我们执行上面这段代码,可以正常输出结果的。但是,这短短的一段代码里面竟然出现了五次回调,这么多的回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉,这就是异步回调影响到我们的编码方式。
那有什么方法可以解决这个问题吗?当然有,我们可以封装这堆凌乱的代码,降低处理异步回调的次数。
面试官追问
追问 1相册页正在上传一个大文件,进度区还没显示完成,但按钮和滚动仍能响应;产品经理据此认定上传已经结束,你会怎么纠正这个判断?
页面仍可交互只说明网络任务没有持续占用主线程,不能证明上传已经完成。耗时任务会在主线程之外处理,完成后再把对应任务加入消息队列;若主线程仍在执行其他任务,完成回调和提示都会继续等待。
追问 2支付页的 XMLHttpRequest 同时注册了 ontimeout、onerror 和 onreadystatechange,评审者想只留状态变化回调来减少代码,你会接受吗?
不能仅为减少回调就删除超时和网络错误路径,它们表达的结束原因并不相同。封装层可以对业务统一暴露成功与失败,但底层仍要识别各类结果并避免失败路径误触发成功处理,否则诊断信息会丢失。
追问 3下载页需要先更新“开始下载”,再等待文件返回并渲染结果,业务方要求代码视觉上严格从上到下执行,于是提议把 xhr.open 的异步参数改为同步,你怎么取舍?
不应为了代码外观线性而改成同步请求,因为耗时操作会占住页面主线程,破坏异步模型保障交互响应的目标。更合适的做法是保留异步执行并封装回调细节;代价是控制流仍需通过回调或更高层抽象表达。
追问 4上传完成事件已经由网络侧返回,但线上提示偶发延迟数秒,接口耗时记录却正常;你会先沿哪条执行链排查?
应先检查主线程当时是否被长任务占用,以及完成任务进入消息队列后是否迟迟未被事件循环取出。网络任务结束不等于回调立即执行;若主线程繁忙,提示延后属于调度等待,继续优化接口本身未必有效。
追问 5一个页面只有一次请求,代码却有成功、超时、网络错误和状态变化等多处回调;架构师认为“回调数量多就是异步模型设计错误”,你会怎么回应?
多条回调首先反映异步任务存在多种完成路径,并不说明异步模型本身错误。真正影响维护的是请求细节散落、业务逻辑被回调切断;可以把底层事件集中封装,但不能因此抹平必要的状态与失败语义。
# 封装异步代码,让处理流程变得线性
30 秒速记
- 把地址、方法、响应类型等输入收敛为
request对象,可以将请求参数与执行细节分开。 XFetch负责创建和配置XMLHttpRequest,并把底层事件转换成resolve与reject两个出口。- 调用方只需提交请求信息并处理成功或失败,业务代码不再重复请求初始化和事件绑定。
- 这种封装降低了单次异步操作的噪声,但回调参数依然存在,连续依赖任务仍可能形成嵌套。
- 示例仅表达封装思路,不能直接视为生产实现:其中
xhr.status = 200是赋值而非比较,URL也未从request.url读取。
可以把请求信息收敛到 request 对象,再由 XFetch 统一封装 XMLHttpRequest 的创建、配置和事件处理。 这样输入参数与执行细节分开,调用方只关注请求内容,以及 resolve 和 reject 对应的成功、失败结果,业务代码会更干净。不过它仍然依赖回调,多个前后依赖的异步任务依旧可能出现嵌套。示例只用于说明封装思路,xhr.status = 200 是赋值而非比较,xhr.open 中的地址也应从 request.url 获取。
