在上一篇文章中我们聊到了微任务是如何工作的,并介绍了 MutationObserver 是如何利用微任务来权衡性能和效率的。今天我们就接着来聊聊微任务的另外一个应用 Promise,DOM/BOM API 中新加入的 API 大多数都是建立在 Promise 上的,而且新的前端框架也使用了大量的 Promise。可以这么说,Promise 已经成为现代前端的 “水” 和 “电”,很是关键,所以深入学习 Promise 势在必行。
不过,Promise 的知识点有那么多,而我们只有一篇文章来介绍,那应该怎么讲解呢?具体讲解思路是怎样的呢?
如果你想要学习一门新技术,最好的方式是先了解这门技术是如何诞生的,以及它所解决的问题是什么。了解了这些后,你才能抓住这门技术的本质。所以本文我们就来重点聊聊 JavaScript 引入 Promise 的动机,以及解决问题的几个核心关键点。
要谈动机,我们一般都是先从问题切入,那么 Promise 到底解决了什么问题呢?在正式开始介绍之前,我想有必要明确下,Promise 解决的是异步编程风格的问题,而不是一些其他的问题,所以接下来我们聊的话题都是围绕编码风格展开的。
首先我们来回顾下 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();
我们执行上面这段代码,可以正常输出结果的。但是,这短短的一段代码里面竟然出现了三次回调。这么多的回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉,这就是异步回调影响到我们的编码方式。
那有什么方法可以解决这个问题吗?当然有,我们可以封装这堆凌乱的代码,降低处理异步回调的次数。
由于我们重点关注的是输入内容(请求信息)和输出内容(回复信息),至于中间的异步请求过程,我们不想在代码里面体现太多,因为这会干扰核心的代码逻辑。整体思路如下图所示:
从图中你可以看到,我们将 XMLHttpRequest 请求过程的代码封装起来了,重点关注输入数据和输出结果。
那我们就按照这个思路来改造代码。
首先,我们把输入的 HTTP 请求信息全部保存到一个 request 的结构中,包括请求地址、请求头、请求方式、引用地址、同步请求还是异步请求、安全设置等信息。request 结构如下所示:
// makeRequest 用来构造 request 对象 function makeRequest(request_url){ let request = { method: ‘Get‘, url: request_url, headers: ‘‘, body: ‘‘, credentials: false, sync: true, responseType: ‘text‘, referrer: ‘‘ } return request }
然后就可以封装请求过程了,这里我们将所有的请求细节封装进 XFetch 函数,XFetch 代码如下所示:
// [in] request,请求信息,请求头、延时值,返回类型等 // [out] resolve,执行成功,回调该函数 // [out] reject 执行失败,回调该函数 function XFetch(request, resolve, reject) { let xhr = new XMLHttpRequest() xhr.ontimeout = function (e) { reject(e) } xhr.onerror = function (e) { reject(e) } xhr.onreadystatechange = function () { if ( xhr.status = 200 ) // 这处判断作者写错了,应该是 xhr.statue == 200 resolve(xhr.response) } xhr.open(request.method, URL, request.sync); xhr.timeout = request.timeout; xhr.responseType = request.responseType; // 补充其他请求信息 // ... xhr.send() }
这个 XFetch 函数需要一个 request 作为输入,然后还需要两个回到函数 resolve 和 reject,当请求成功时回调 resolve 函数,当请求出现问题时回调 reject 函数。
有了这些,我们就可以来实现业务代码了,具体的实现方式如下所示:
XFetch(makeRequest(‘https://time.geekbang.org‘), function resolve(data) { console.log(data) }, function reject(e){ console.log(e) })
上面的示例代码已经比较符合人的线性思维了,在一些简单的场景下运行效果也是非常好的,不过一旦接触到稍微复杂点的项目时,你就会发现,如果嵌套了太多的回调函数就很容易使得自己陷入了回调地狱,不能自拔。你可以参考下面这段让人凌乱的代码:
XFetch(makeRequest(‘https://time.geekbang.org/?category‘), function resolve(response) { console.log(response) XFetch(makeRequest(‘https://time.geekbang.org/column‘), function resolve(response) { console.log(response) XFetch(makeRequest(‘https://time.geekbang.org‘), function resolve(response) { console.log(response) }, function reject(e) { console.log(e) }) }, function reject(e) { console.log(e) }) }, function reject(e) { console.log(e) })
这段代码是先请求time.geekbang.org/?category,如果请求成功的话,那么再请求time.geekbang.org/column,如果再次请求成功的话,就继续请求time.geekbang.org。也就是说这段代码用了三层嵌套请求,就已经让代码变得混乱不堪,所以,我们还需要解决这种嵌套调用后混乱的代码结构。
这段代码之所以看上去很乱,归结其原因有两点:
原因分析出来后,那么问题的解决思路就很清晰了:
这么讲可能有点抽象,不过 Promise 已经帮助我们解决了这两个问题。那么接下来我们就来看看 Promise 是怎么消灭嵌套调用和合并多个任务的错误处理的。
首先,我们使用 Promise 来重构 XFetch 的代码,示例代码如下所示:
function XFetch(request) {
function executor (resolve, reject) {
let xhr = new XMLHttpRequest()
xhr.open(‘GET‘, request.url, true)
xhr.ontimeout = function (e) { reject(e) }
xhr.onerror = function (e) { reject(e) }
xhr.onreadystatechange = function () {
if ( this.readyState === 4 ){
if ( this.status === 200 ) {
resolve(this.responseText, this)
} else {
let error = {
code: this.status,
response: this.response
}
reject(error, this)
}
}
}
xhr.send()
}
return new Promise(executor)
}
接下来,我们再利用 XFetch 来构造请求流程,代码如下:
var x1 = XFetch(makeRequest(‘https://time.geekbang.org/?category‘))
var x2 = x1.then(value => {
console.log(value)
return XFetch(makeRequest(‘https://www.geekbang.org/column‘))
})
var x3 = x2.then( value => {
console.log(value)
return XFetch(makeRequest(‘https://time.geekbang.org‘))
})
x3.catch( error => {
console.log(error)
})
你可以观察上面这两段代码,重点关注下 Promise 的使用方式。
以上简单介绍了 Promise 一些主要的使用方法,通过引入 Promise,上面这段代码看起来就非常线性了,也非常符合人的直觉,是不是很酷?基于这段代码,我们就可以来分析 Promise 是如何消灭嵌套回调和合并多个错误处理了。
我们先来看看 Promise 是怎么消灭嵌套回调的。产生嵌套函数的一个主要原因是在发起任务请求时会带上回调函数,这样当任务处理结束之后,下个任务就只能在回调函数中来处理了。
Promise 主要通过下面两步解决嵌套回调问题的。
首先,Promise 实现了回调函数的延时绑定。
回调函数的延时绑定在代码上体现就是先创建 Promise 对象 x1,通过 Promise 的构造函数 executor 来执行业务逻辑;创建好 Promise 对象 x1 之后,再使用 x1.then 来设置回调函数。示范代码如下:
// 创建 Promise 对象 x1,并在 executor 函数中执行业务逻辑 function executor(resolve, reject) { resolve(100) } let x1 = new Promise(executor) // x1 延迟绑定回调函数 onResolve function onResolve(value) { console.log(value) } x1.then(onResolve)
其次,需要将回调函数 onResolve 的返回值穿透到最外层。
因为我们会根据 onResolve 函数的传入值来决定创建什么类型的 Promise 任务,创建好的 Promise 对象需要返回到最外层,这样就可以摆脱嵌套循环了。你可以先看看下面的代码:
现在我们知道了 Promise 通过回调函数延迟绑定和回调函数返回值穿透的技术,解决了循环嵌套。
那接下来我们再来看看 Promise 是怎么处理异常的,你可以回顾上篇文章思考题留的那段代码,我把这段代码也贴在文中了,如下所示:
function executor(resolve, reject) { let rand = Math.random(); console.log(1) console.log(rand) if (rand > 0.5) resolve() else reject() } var p0 = new Promise(executor); var p1 = p0.then((value) => { console.log("succeed-1") return new Promise(executor) }) var p3 = p1.then((value) => { console.log("succeed-2") return new Promise(executor) }) var p4 = p3.then((value) => { console.log("succeed-3") return new Promise(executor) }) p4.catch((error) => { console.log("error") }) console.log(2)
这段代码有四个 Promise 对象:p0 ~ p4。无论哪个对象里面抛出异常,都可以通过最后一个对象 p4.catch 来捕获异常,通过这种方式可以将所有 Promise 对象的错误合并到一个函数来处理,这样就解决了每个任务都需要单独处理异常的问题。
之所以可以使用最后一个对象来捕获所有异常,是因为 Promise 对象的错误具有 “冒泡” 性质,会一直向后传递,直到被 onReject 函数处理或 catch 语句捕获为止。具备了这样 “冒泡” 的特性后,就不需要在每个 Promise 对象中单独捕获异常了。至于 Promise 错误的 “冒泡” 性质是怎么实现的,就留给你课后思考了。
通过这种方式,我们就消灭了嵌套调用和频繁的错误处理,这样使得我们写出来的代码更加优雅,更加符合人的线性思维。
浏览器工作原理:浅析页面循环系统 - Promise原理解析
原文:https://www.cnblogs.com/goloving/p/14738653.html