Loading

你不知道的JavaScript——异步编程(中)Promise

宏任务和微任务

PS:这里可以选择性忽略,直接从第二块内容看起。

上一篇笔记介绍了JS中的异步任务执行模型,但那个模型对现在的JS来说,有些落后了,如今的JS有了Promise等更多更先进的异步编程工具,这个模型当然也要随之更新。

新增的内容就是微任务队列(MicroTasks Queue V8术语),同时把之前的异步任务队列称作宏任务队列(MarcoTasks Queue),而以前那些风格的异步任务,比如setTimeoutonClickajax等,都被称为一个宏任务,进入宏任务队列,而新增的一些异步编程方法,比如PromisequeueMicrotask等,它们创建出来的任务都会被称作一个微任务,进入微任务队列。

我们可以把主进程,也就是当前JS文件中的所有代码看成最初的宏任务,因为JS引擎总是最先执行它,如果看了上一篇笔记,应该会觉得这个说法顺理成章,通过这个我们能看出宏任务在JS中的地位好像是要比微任务高一些,因为程序的入口是一个宏任务,而且第一批微任务肯定是由这个宏任务中的代码创建出来的。

其实,宏任务和微任务的关系如下图:

script(就是我们所说的第一个宏任务)、mousemovesetTimeout都是宏任务,每个宏任务中可能会创建出一些微任务,JS保证下一个宏任务开始执行时,当前宏任务创建的微任务都被执行完毕。

我再来完整的捋一遍这个过程:

  1. 事件循环机制从宏任务队列中出队一个宏任务去执行
  2. 宏任务执行过程中可能会创建其他的宏任务也可能创建微任务,分别将他们入到宏任务和微任务的队列中
  3. 当宏任务执行完毕,去执行微任务列表中的所有微任务,当微任务列表被清空时,标志着本次宏任务创建的所有微任务都执行完毕了,进行下一步
  4. 渲染DOM
  5. DOM渲染完毕,当前这个事件才算结束,回到第一步

注意下,DOM刷新是在微任务队列中的所有微任务执行完毕时刷新,所以如果你有一些异步更新UI的操作,如实现一个进度条,使用微任务虽然是异步执行,但UI上是看不到过程的效果的,只能看到最后一个微任务更新的进度。

在ES6规范中,宏任务被称为task,而微任务被称为job,也能看出微任务相对宏任务来说更轻量。

宏任务包含

script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)

微任务包含

Promise.then/catch/finally的回调
Object.observe
MutaionObserver
process.nextTick(Node.js 环境)

知道了微任务会在啥时候执行,咱就可以整点儿新玩意儿了。

Promise是什么

不会过多的介绍Promise的语法和API,只是从原理层面介绍

Promise,就是——承诺!

不是。。。。就是承诺啊。

Promise还真就是承诺,假如你想进行一次网络请求,你调用了一个库,但是网络请求要好一会儿才能响应,所以你的库让你先去干别的,并且给了你一个凭证,这个凭证保证网络请求完毕库来通知你的时候,你肯定能拿到一个东西,要么就是网络请求的响应,要么就是请求没成功,它给你失败的原因。这多像餐厅点单的小票啊。

var p = ajax(url); // 调用,返回一个凭证,是一个Promise对象

p.then(
    function fulfilled(response){
        // 成功
    },
    function rejected(reason){
        // 失败
    }
);

上面是使用Promise风格编写的网络请求伪代码,在这种风格中,我们不再给库传递一个回调,让库做完该做的操作后主动调用回调函数,而是让库返回一个凭证——Promise对象,然后我们将回调函数传递给这个凭证的then方法,然后库做完操作后去跟这个第三方凭证打交道,告诉它我做完了,第三方凭证再来告诉我们。

如果用回调模式,大概是这么写的

ajax(url,function success(response){
    // 成功
},function faild(err){
    // 失败
})

给我的感觉就是,我们不再和库直接联系了,我们的回调方法并不是库来直接调用,而是通过一个中间人,也就是那个凭证p,但是有什么用呢,解决不了信任问题啊,而关于回调地狱,它能解决分离回调设计也能解决。好像现在为止还是编码风格的改变而已啊。

要硬说好处吧,就是库和调用者解耦了,库不关心调用者是谁,不关心回调函数是啥,而调用者也不需要太多的关心库的细节,只是遵守Promise固定的写法就行。

解决信任问题

回调的信任问题主要有如下几个方面(参见上一篇):

  • 调用回调过早
  • 调用回调过晚
  • 调用回调次数过多或过少
  • 未能传递所需要的环境和参数
  • 吞掉可能出现的错误和异常

我先泼一盆冷水,别指望Promise能解决库故意的提早调用或过晚调用的问题,它能解决的只是因为回调模式可能出现的疏忽产生的一些问题,故意造出来的问题神仙也解决不了,防出错不防手贱(手动狗头)。

调用回调过早

回调模式可能出现的回调过早的问题出现在回调有可能被同步调用也有可能被异步调用的时候。上一篇提到过。

var a = 0;

ajax(url,function(){
    console.log(a);
});

a++;

这个回调如果被同步调用,那么a++将会最后执行,如果是异步,那么console.log将会被最后执行,这种不确定性可能会造成很大的问题。

但是现在有中间人了,就算库函数里面的代码都是同步的,中间人也可以拿到它给的结果后异步的去调用我们在then方法中传递的回调函数。实际上这也正是它的工作方式,Promise.then保证回调永远被异步调用。

调用回调过晚

以下是用传统的回调方式编写的伪代码,假设ajax是一个可用的并且支持缓存的网络请求函数。

我想要的顺序是data1->data2->data3,但是如果url3被缓存过,它以同步的模式来回调的话,那么输出的结果就是data1->data3->data2,代码不按照我们预期的想法去运行。

ajax(url1,function(data1){
    ajax(url3,function(data3){
        console.log(data3);
    })
    console.log(data1);
});

ajax(url2,function(data2){
    console.log(data2);
});

而换做Promise,自然也就没这个问题了,每个都是异步回调。

调用回调次数过多或过少

来看看调用次数过多或过少(未被调用)会在什么情况下出现

  1. 库函数发生了异常,没能及时处理,导致函数异常退出,回调根本没调用
  2. 库函数不小心或故意的调用了几次回调函数
  3. 库函数忘记或故意没有在处理结束后调用回调函数

对于第一个问题,Promise.then有两个参数,第一个是成功回调,第二个是失败回调,如下:

ajax(url).then(
    function fulfilled(data){
        // handle data
    },
    function rejected(err){
        // handle error
    }
);

如果库函数执行成功,并通知了promise它成功了,那么fulfill会被调用。如果库函数执行过程中产生了异常,并且未捕获,导致代码执行失败,或者库函数发觉它的执行失败了,主动通知promise它失败了,reject都会被安全的回调。

如果是未捕获的异常,那么err就是这个异常对象,如果是库函数自己通知promise,那么err就是库函数提供的关于错误的描述。

第二个问题,应该先来看看库函数是如何使用Promise工作的才能解释。

下面是一个真实的使用Promise封装ajax请求的代码:

function ajax(url){
    return new Promise((resolve, reject) => {
        var xhr = new XMLHttpRequest();
        var method = method || "GET";
        var data = data || null;
        xhr.open(method, url, true);
        xhr.onreadystatechange = function() {
            if(xhr.status === 200 && xhr.readyState === 4){
                resolve(xhr.responseText);
            } else {
                reject(xhr.responseText);
            }
        }
        xhr.send(data);
    });
}

ajax返回了一个Promise对象,这个对象参数中的方法就是你要去干的事儿,对于ajax就是根据用户传入的URL发起网络请求,你传入的这个方法会被立即同步调用,所以它就像你直接写在外面一样,但你因为要和Promise协作,所以只能这么写,千万不要想多,我之前就是因为想太多了然后每一步都很纠结。

而且,Promise会通过参数传递给你两个函数,resolve代表你的库函数告诉Promise,我已经成功的完成了任务,也就是成功的完成了网络请求,而reject表示你的库函数告诉Promise,我执行的时候出了点问题,导致它无法正常返回结果,比如要请求的网页已经不存在了。简而言之,如果你成功了,就调用resolve并传入结果,失败了就reject并传入原因。

好,下面看看这种机制是如何解决不小心调用多次回调函数的问题的。

因为库函数并不直接和调用者传入的回调协作,而是和Promise给的resolvereject合作,所以Promise完全可以对库函数的行为做一些限制。

Promise只认这两个函数中第一个被回调的那个,并保存它的结果,当其中一个被库函数第一次回调了,以后的所有回调都不奏效了,都会被吞没。而无论调用者何时使用then去获取这个结果,Promise都会返回同一个结果。

第三个,回调不被调用的问题,Promise提供了竞速机制,Promise.race([promiseList])会获取Promise列表中最快执行完毕那个,并且返回它的结果,抛弃其他的Promise。然后可以这样解决这个问题:

function timeoutPromise(dly){
    return new Promise(function(resolve,reject){
        setTimeout(function(){
            reject('Timeout!!');
        },dly);
    });
}

Promise.race([
    ajax(url),
    timeoutPromise(3000)
]).then(
    function fulfilled(data){
        // 执行成功
    },
    function rejected(err){
        // 执行失败或超时
    }
)

未能正确传递参数或环境值

resolvereject只能有一个参数,其他的都会被忽略。它保证了调用者收到的参数是一致的,如果未传递参数,则是undefined

吞掉错误或异常

对于使用Promise规范的库函数来说,如果库函数中出现了未捕获的异常,Promise会捕获它,并自动调用reject传递给调用方,保证它不会被吞没。这在前面说过。

var p = new Promise(function(resolve){
    foo.bar(); // 没有foo,会报错
    resolve(21); // 这里永远不会被执行到
});

p.then(
    function fulfilled(){
        // 这里永远不会被执行到
    },
    function rejected(err){
        // 将会执行这里,收到一个TypeError
    }
);

对于库函数调用者来说,如果它在then中的回调里触发了异常并没有捕获,那么会发生啥呢。

var p = new Promise(function(resolve){
    resolve(21); 
});

p.then(
    function fulfilled(data){
        foo.bar(); // 没有foo 抛出异常
        console.log(data); // 这里永远不会被执行到
    },
    function rejected(err){
        // 这里永远不会被执行到
    }
);

嘶,看似异常被吞没了。有好多人第一次会认为reject会被执行,但是仔细考虑下,错误发生的时候Promise状态已定,是成功的状态,并且Promise规约约定状态一旦定下就无法修改,所以这时如果reject被执行,那不违反约定了吗,那不是Promise的状态被修改了吗。

那它去哪了?

p.then本身也返回一个Promise用于链式调用,它是上一个then操作的结果,这个后面可能会介绍,不过下面的代码说明了异常究竟被传递到了哪里

p.then(
    function fulfilled(data){
        foo.bar(); // 没有foo 抛出异常
        console.log(data); // 这里永远不会被执行到
    },
    function rejected(err){
        // 这里永远不会被执行到
    }
).then(
    function fulfilled(data){
        // 这里永远不会被执行到
    },
    function rejected(err){
        // 这里接收到错误
    }
);

终极问题

看起来Promise确实通过这种委托第三方的模式解决了一些问题,因为库和调用者的所有往来都由Promise对象监管,但是还有一个问题,我调用这个库,就说明我还是相信它会给我返回一个可用的Promise,凭什么相信它?JS是个弱类型的语言,甚至返回一个有then方法的对象(thenable对象)就能让调用者分不清它是不是Promise对象,如果你用instanceof,通过阅读前几篇的文章你也可以找到一些办法瞒天过海。

Promise.resolve方法可以将你传递进去的任何参数安全的转换成Promise对象。这下你就可以消除疑虑了。

对于一个非Promise和非thenable的立即值,如Promise.resolve(10),会被转换成类似这样的Promise:

new Promise(function(resolve,reject){
    resolve(10);
})

对于一个Promise,直接返回这个Promise。

对于一个非Promise的thenable值,Promise.resolve会试图跟踪这个thenable中的then函数,获取它的最终状态,然后转换成Promise。我不知道这个跟踪是怎么实现的,实在学不动了,等过几天瞅瞅源代码。

链式流

上面也透露过了,Promise可以链式调用,下面来看看更多的调用细节。

返回值

then/catch/finally块的返回值都是Promise,虽然我们还没介绍过catchfinally块。我们知道then中的两个回调只有一个会被调用,就是这个调用的函数的返回值会被安全的展开为一个Promise,并作为then方法的返回值传递给调用链中的下一个then

这个展开的过程和Promise.resolve的过程一致,所以能够保证调用链中每一块的返回值都是一个安全的可用的Promise。

var p = new Promise(function(resolve,reject){
    resolve(10);
});

p.then(
    function fulfilled(data){
        return data * data;
    }
).then(
    function fulfilled(data){
        return data + 5;
    }
).then(
    function fulfilled(data){
        console.log(data); // 105
    }
);

上面的代码说明了Promise链式调用的返回值沿Promise链的走向。它们沿着链一直向下传递。

前文提到了返回值会被像Promise.resolve操作一样的转换为Promise对象,那么也就是说,我们可以在里面返回同步的或异步的Promise。

// 获取文章详情
ajax('http://post.xxx.com/posts?id=104020')
.then(
    function fulfilled(data){
        // 根据文章详情中的字段获取评论
        return ajax(
            'http://post.xxx.com/comments?tk='+data.tk
        );
    }
).then(
    // 这一步返回的是评论列表,处理评论列表
    function fulfilled(data){
        console.log(data)
    }
);

不管是同步的或是异步的Promise,then中的回调一定是被异步调用的,所以不会造成不必要的麻烦。

如果你不给then传一个fulfill函数,那么默认的函数是这样的:

p.then(
    // 默认处理函数
    function fulfilled(value){
        return value;
    }
);

它只是把你传进来的东西原封不动的传递给调用链的下一层。

错误处理

之前也说过一些错误处理方面的东西。就是调用链中上一个块出异常了,会被安全的传到下一个块的错误回调中,也就是reject,你需要做的是在这里对异常进行一些处理,比如记录或者向后端上报,然后使整个调用链回归到正常状态。

如果不传入错误处理函数,那么默认的错误处理函数是这样的:

p.then(
    function fulfilled(){},
    // 默认异常处理函数
    function rejected(err){
        throw err;
    }
)/*.then(
    function fulfill(){},
    function reject(err){
        // 这里接收到异常
    }
);*/

只是把异常继续进行抛出,那这个异常就会再被传递到调用链中的下一步。

注意!!!如果你在创建Promise的过程中出了错,那你得到的应该是一个立即抛出的异常,而不是得到一个被reject了的Promise。因为前面提到过,创建Promise的语句是会被同步立即执行的。创建Promise在如下情况下会出错:

  1. new Promise(null)
  2. Promise.all()
  3. Promise.race(42)
  4. ...

修改之前的回调地狱

虽然还是会有很多模板代码,但是相对于之前的回调地狱,可能已经很清爽了。我们把上一篇笔记中的回调地狱的问题给拿过来使用Promise来解决。

早上起来,开车去商店,我的车可能没油了,如果没油先去加油站加油。然后去超市买牛奶,检查牛奶是否售罄,如果没有,就去另一家超市,我需要掉头去另一家超市。

醒来().then(
    function fulfilled(){
        return 开车();
    }
).then(
    null,
    // 可能没油了
    function rejected(){
        return 加油();
    }
).then(
    // 到此,车肯定有油了
    function fulfilled(){
        return 去超市买牛奶();
    }
).then(
    null,
    // 可能没有牛奶
    function rejected(){
        return 换一家超市买牛奶();
    }
).then(
    // 最后不管买不买到都回家
    function fulfilled(){
        return 回家();
    },
    function rejected(){
        return 回家();
    }
)

如果你使用lambda表达式,会更加清晰:

醒来().then(()=>开车())
.then(null,()=>加油())
.then(()=>去超市买牛奶())
.then(null,()=>换一家超市买牛奶())
.then(()=>回家(),()=>回家());

我们还没有使用catch/finally,使用了那两个之后,语义会更加清晰,也不用做那种为了确保油箱有油而编写的一个语义不明确的(null,()=>加油())这种代码。

下面是使用catch和finally的代码:

醒来().then(()=>开车())
.catch(()=>加油())
.then(()=>去超市买牛奶())
.catch(()=>换一家超市买牛奶())
.finally(()=>回家());

再去看看上一篇笔记中的回调地狱,现在进步已经很大了。

术语:决议、完成以及拒绝

  • 决议(resolve),它的意思是“去做一个决定”
  • 完成(fulfill),它的意思是“事情已经完成”
  • 拒绝(reject),它的意思“(可能是因为某些原因)不做这个事情”

这三个单词前面出现了太多次,为什么它们要出现在那?

fulfill出现在then参数中第一个函数的名字,很好解释,就是Promise在通知我我向库函数发起的任务已经被库函数完成,所以术语——fulfilled用在这里是合适的。

reject出现在new Promise传入的回调的第二个参数和调用者then参数中第二个函数的名字。

对于前者来说,reject用于通知Promise,任务由于某些原因执行失败,所以术语——reject,用在这里是合适的。对于后者,代表库函数通知了Promise执行失败了,而对于调用者then,它代表我发起的任务请求没有成功执行,而是被拒绝了,所以术语——rejected,用在这里是合适的。

resolve出现在new Promise传入的第一个回调参数中,代表库函数要做出决议了,这个决议不一定就是完成的决议,所以没有用fulfill。前面说过,如果传递其他非Promise参数给resolve,这个参数都会被安全的展开并转换成Promise,但如果你传的就是一个Promise,那么这个Promise不会被处理。

所以如果你当初就传递一个已经被拒绝的Promise,那么你做的这个决议就相当于reject

var rejectedPromise =  new Promise(function(resolve,reject){
    reject("Oops!");
});
new Promise(function(resolve,reject){
    resolve(rejectedPromise);
}).then(
    function fulfilled(){
        // 永远不会到达
    },
    function reject(err){
        // Oops!
    }
);

所以术语——resolve用在这里很合适。而不是用fulfill

Promise模式

用着用着Promise,我们仿佛又回到了串行的时候,在Promise调用链中,不管其中一个块是同步还是异步执行的,下一个块都要等它resolvereject才能执行。这是因为在异步编程中下一步操作通常依赖上一步操作,那为啥不直接用同步????

注意,下面会用到术语异步和并行,同步和串行,请注意它们之间微妙的差别,尤其是在JS中的差别,在上一篇笔记中有说到。

Promise.all

但其实在异步编程中,很多操作还是可以并行完成的,比如执行删除帖子操作,这个操作有多选功能,但是后端并没有提供多选删除的API,我们只能发起多个单独的删帖请求,这些单独的删帖请求完全可以并行完成,因为它们之间不存在依赖,而删帖下一步的操作则是依赖于所有这些请求,只有它们全部完成,删帖操作才算成功。所以异步编程的意义在于我们可以根据需求组织代码实现串行和并行的组合。

Promise.all用于实现并行操作,前面简单介绍过,其中可以传入一个Promise列表,并且返回一个Promise,列表中的Promise将会被异步执行,并且只有当它们全部完成,被返回的Promise才会完成,如果其中一个被拒绝,被返回的Promise直接被拒绝,丢弃所有结果。

Promise.all([ajax(post1),ajax(post2),ajax(post3)]).then(
    function fulfilled(data){
        // data是一个列表,包括这三个Promise的结果
    },
    function rejected(err){

    }
);

注意,Promise.all传入的Promise列表中的每一个元素都会经过Promise.resolve过滤,意味着你可以向里面传入任何值,都会被转换成Promise。但是好像使用Promise.all传入一个立即值的意义不大。如果主Promise列表中没有元素,被返回的Promise立即完成。

Promise.race

传入一个Promise列表,只有第一个完成的才会被返回,其他的被丢弃。

练习

可能对宏任务和微任务,Promise的执行时机等问题还有些模糊,来,去试试这些题目:

实话实说,我对这些刻意编造的,平时根本不会这样写代码的题目有些抵触,但对于Promise来说,这确实是练习的一个好方法。

参考

posted @ 2021-07-24 18:24  yudoge  阅读(387)  评论(0编辑  收藏  举报