DOM/BOM API 中新加入的 API 大多数都是建立在 Promise 上的,而且新的前端框架也使用了大量的 Promise。
Promise 到底解决了什么问题呢?
Promise 解决的是异步编码风格的问题****
异步编程的问题:代码逻辑不连续
JavaScript 的异步编程模型
页面中任务都是执行在主线程之上的,相对于页面来说,主线程就是它整个的世界,所以在执行一项耗时的任务时,比如下载网络文件任务、获取摄像头等设备信息任务,这些任务都会放到页面主线程之外的进程或者线程中去执行,这样就避免了耗时任务“霸占”页面主线程的情况。
Web 应用的异步编程模型
上图展示的是一个标准的异步编程模型。
异步回调
页面主线程发起了一个耗时的任务,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。等该进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。
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) 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) })
新的问题:回调地狱
如果嵌套了太多的回调函数就很容易使得自己陷入了回调地狱,不能自拔。
归结其原因有两点:
- 第一是嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这样当嵌套层次多了之后,代码的可读性就变得非常差了。
- 第二是任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。
解决思路:
- 第一是消灭嵌套调用;
- 第二是合并多个任务的错误处理。
Promise:消灭嵌套调用和多次错误处理
首先,我们使用 Promise 来重构 XFetch 的代码,示例代码如下所示:
新的问题:回调地狱
如果嵌套了太多的回调函数就很容易使得自己陷入了回调地狱,不能自拔。
归结其原因有两点:
- 第一是嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这样当嵌套层次多了之后,代码的可读性就变得非常差了。
- 第二是任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。
解决思路:
- 第一是消灭嵌套调用;
- 第二是合并多个任务的错误处理。
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,在调用 XFetch 时,会返回一个 Promise 对象。
- 构建 Promise 对象时,需要传入一个executor 函数,XFetch 的主要业务流程都在 executor 函数中执行。
- 如果运行在 excutor 函数中的业务执行成功了,会调用 resolve 函数;如果执行失败了,则调用 reject 函数。
- 在 excutor 函数中调用 resolve 函数时,会触发 promise.then 设置的回调函数;而调用 reject 函数时,会触发 promise.catch 设置的回调函数。
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 是怎么处理异常的
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 与微任务
function executor(resolve, reject) { resolve(100) } let demo = new Promise(executor) function onResolve(value){ console.log(value) } demo.then(onResolve)
对于上面这段代码,我们需要重点关注下它的执行顺序。
执行 new Promise 时,Promise 的构造函数会被执行,不过由于 Promise 是 V8 引擎提供的,所以暂时看不到 Promise 构造函数的细节。
Promise 的构造函数会调用 Promise 的参数 executor 函数。然后在 executor 中执行了 resolve,resolve 函数也是在 V8 内部实现的,执行 resolve 函数,会触发 demo.then 设置的回调函数 onResolve,所以可以推测,resolve 函数内部调用了通过 demo.then 设置的 onResolve 函数。
由于 Promise 采用了回调函数延迟绑定技术,所以在执行 resolve 函数的时候,回调函数还没有绑定,那么只能推迟回调函数的执行。
模拟实现一个 Promise,我们会实现它的构造函数、resolve 方法以及 then 方法。
function Bromise(executor) { var onResolve_ = null var onReject_ = null // 模拟实现 resolve 和 then,暂不支持 rejcet this.then = function (onResolve, onReject) { onResolve_ = onResolve }; function resolve(value) { //setTimeout(()=>{ onResolve_(value) // },0) } executor(resolve, null); }
观察上面这段代码,我们实现了自己的构造函数、resolve、then 方法。接下来我们使用 Bromise 来实现我们的业务代码,实现后的代码如下所示:
function executor(resolve, reject) { resolve(100) } // 将 Promise 改成我们自己的 Bromsie let demo = new Bromise(executor) function onResolve(value){ console.log(value) } demo.then(onResolve)
执行这段代码,我们发现执行出错,输出的内容是:
Uncaught TypeError: onResolve_ is not a function at resolve (<anonymous>:10:13) at executor (<anonymous>:17:5) at new Bromise (<anonymous>:13:5) at <anonymous>:19:12
之所以出现这个错误,是由于 Bromise 的延迟绑定导致的,在调用到 onResolve_ 函数的时候,Bromise.then 还没有执行,所以执行上述代码的时候,当然会报“onResolve_ is not a function“的错误了。
也正是因为此,我们要改造 Bromise 中的 resolve 方法,让 resolve 延迟调用 onResolve_。
要让 resolve 中的 onResolve_ 函数延后执行,可以在 resolve 函数里面加上一个定时器,让其延时执行 onResolve_ 函数,你可以参考下面改造后的代码:
function resolve(value) { setTimeout(()=>{ onResolve_(value) },0) }
上面采用了定时器来推迟 onResolve 的执行,不过使用定时器的效率并不是太高,好在我们有微任务,所以 Promise 又把这个定时器改造成了微任务了,这样既可以让 onResolve_ 延时被调用,又提升了代码的执行效率。 这就是 Promise 中使用微任务的原由了。
思考时间
- Promise 中为什么要引入微任务?
- Promise 中是如何实现回调函数返回值穿透的?
- Promise 出错后,是怎么通过“冒泡”传递给最后那个捕获异常的函数?