异步

102 阅读9分钟

序言

异步编程从来都是JS中需要深刻理解的一点,而实现异步的方式,也是随着JS语言的更新而产生了许多变化,变得更加容易,更简洁,也更抽象。因而不理解这背后的原理,异步对我们来讲就是“魔法”一样的东西。下面开始

理解异步

异步编程的核心:程序中现在运行的部分和将来运行的部分之间的关系

为什么使用异步,首先一点,如果使用同步,那么它会锁定浏览器的操作,阻塞所有的用户行为,这显然是不行的。其次,当我们需要一段代码在响应某个事件(定时器,事件点击)时执行,这就是我们就需要一个在将来执行的函数,这时候就需要异步。

事件循环

JS引擎依赖于宿主环境中,而这些环境都提供了一种机制来处理程序中多个块的执行,且每次执行调用JS引擎,这种机制就是事件循环。

那事件循环又是怎么工作的呢,就是在一段持续的循环中,每一次循环里如果队列中等待事件,那么就会拿到这个事件并执行,这个事件就是回调函数。而我们之前用的定时器setTimeout 并没有把你的回调函数挂在事件循环队列中。它是在设定的时间到时,把你的回调函数放在事件循环里面。

这也就是定时器一般会在设定的时间执行,或者在之后,要根据事件队列的状态而定。

任务队列

这是ES6的新概念,它是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件 添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个任务。

举个例子就是,事件循环队列类似于一个游乐园游戏:玩过了一个游戏之后,你需要重新到队尾排队才能 再玩一次。而任务队列类似于玩过了游戏之后,插队接着继续玩。

而一个任务可能导致更多任务添加到同个队列的末尾。所以理论上,任务循环有无限循环的说法。

用schedule表示一个任务。

console.log( "A" );
     setTimeout( function(){
         console.log( "B" );
}, 0 );
// 理论上的"任务API" 
schedule( function(){
    console.log( "C" );
    schedule( function(){
     console.log( "D" );
    } ); 
});

打印结果是 ACDB,因为任务处理是在当前 事件循环 tick 结尾处,而定时器触发是为了调度下一个事件循环 tick。注意后面的Promise 的异步特性是基于任务的

回调

大部分用作回调使用的都是函数,而回调也是编写和处理异步的最常用的方式。

 listen( "click", function handler(evt){
     setTimeout( function request(){
         ajax( "http://some.url.1", function response(text){
             if (text == "hello") {
                 handler(); }
             else if (text == "world") {
                 request();
            } } );
     }, 500) ;
} );

记得这种形式吧,三个函数嵌套的处理异步的步骤。这种代码常称为“回调地狱”,也叫“毁灭金字塔”(从缩进的角度)。但是回调的问题不仅于此。

更多的是,一旦嵌套了多层回调,代码就会变得非常复杂,难以理解且无法维护和更新。这才是回调地狱的问题所在。

还有就是代码中一旦使用了第三方接口的回调,我们不能过于信任第三方的代码,因为他们也有可能导致bug。这就会出现“控制反转”

Promise

Promise 是一种封装和组合未来值的易于复用的机制,而且Promise一直保持其决议结果不变
Promise 的诞生是可以大大解决上述回调的问题,即缺乏顺序性和可信任性。

Promise 是有 new Promise 创建的,但是无法使用 instaceof 来检查,因为promise 有可能是来自于其他浏览器窗口,或者其他的库的promise。因此识别promise,就是定义某种称为thenanle的东西,将其定义为任何具有 then(..) 方法的对象和函数。

if ( 
    p!== null && 
    ( typeof p === "object" || typeof p === "function") && 
    typeof p.then === "function" 
 ) {
    // 假定这是一个thenable!
}else {
    // 不是thenable
}

这种根据一个值的形态对这个值的类型做出一些假定的类型检查叫做,“鸭子类型”。即,“如果它看起来像只鸭子,叫起来 像只鸭子,那它一定就是只鸭子”。但是这明显也是有缺陷的,它可能把Promise 的东西识别为了 Promise,仅仅因为它们带有thenable。

Promise 还主要用于处理一下问题。

  1. 调用回调过早;
  2. 调用回调过晚(或不被调用);
  3. 调用回调次数过少或过多;
  4. 未能传递所需的环境和参数;
  5. 吞掉可能出现的错误和异常。

这些在回调里面的问题,Promise都可以处理掉。

Promise 的很重要但是常常被忽略的一个细节是Promise.resolve(..)就能解决我们所谓的信任问题,Promise.resolve(..) 传递一个非 Promise、非 thenable 的立即值,会得到填充这个值的Promise,如果是真的promise,则会返回其本身。

Promise.resolve( foo( 42 ) ) 
.then( function(v){
     console.log( v );
 } );

那我们在处理第三方的对接时,就能保证这是符合我们要的操作形式。

Promise的链式流

这主要两个特性

  1. 每次你对 Promise 调用 then(..),它都会创建并返回一个新的 Promise,我们可以将其链接起来;
  2. 不管从 then(..) 调用的完成回调返回的值是什么,它都会被自动设置为被链接 Promise的完成。
  3. 在完成或拒绝处理函数内部,如果返回一个值或抛出一个异常,新返回的(可链接的)Promise 就相应地决议。
  4. 如果完成或拒绝处理函数返回一个 Promise,它将会被展开,这样一来,不管它的决议 值是什么,都会成为当前 then(..) 返回的链接 Promise 的决议值。
var p = Promise.resolve( 21 );
    var p2 = p.then( function(v){
    console.log( v ); // 21
    return v * 2; // 用值42填充p2
} );
// 连接p2
p2.then( function(v){
    console.log( v ); // 42
} );

通过链式的then,我们可以一直把值连接下去。只要保持把先前的 then(..) 连到自动创建的每一个 Promise 即可。

那如果其中一步出问题了怎么处理,比如第二步出错了,就会在第三部的错误处理哪里处理,处理完之后,会在接下去的第四步继续正常执行。

request( "http://some.url.1/" )

.then( function(response1){
    foo.bar(); // undefined,出错!
    return request( "http://some.url.2/?v=" + response1 ); })

.then(
function fulfilled(response2){},
// 捕捉错误的拒绝处理函数 
    function rejected(err){ console.log( err );
    // 来自foo.bar()的错误TypeError
    return 42; 
})

.then( function(msg){
    console.log( msg ); // 42
} );

具体的Promise

var p = new Promise( function(X,Y){ 
// X()用于完成
// Y()用于拒绝 } 
);

Promise 构造函数接收两个函数,x和y,第一个通常用于标识 Promise 已经完 成,第二个总是用于标识 Promise 被拒绝。而通常的命名是 reslove 和 reject。

这个reslove作用很大,之前说过Promise.resolve(..) 会将传入的真正 Promise 直接返回,对传 入的 thenable 则会展开。如果这个 thenable 展开得到一个拒绝状态,那么从 Promise. resolve(..) 返回的 Promise 实际上就是这同一个拒绝状态。所以resolve 不一定表示成功,它表示的是可能是完成或拒绝。

Promise最后可以使用catch 来结束,那么我们链式中出的错会在这里被解决掉。

var p = Promise.resolve( 42 );
p.then(
  function fulfilled(msg){
// 数字没有string函数,所以会抛出错误
      console.log( msg.toLowerCase() );
  }
)
.catch( handleErrors );

但是 万一catch也出错了呢,最好的做法是添加done(..) 函数,done(..) 不会创建和返回 Promise,它拒绝处理函数内部的任何异常都会被作为一个全局未处理错误抛出。但是这最大的问题是它不是标准的ES6的做法。

Promise模式

Promise 提供了一些额外的调用语法供我们直接使用。

  1. Promise.all(),这个方法接受一个数组作为参数,数组值可以是一个个异步请求,随后,返回值会返回按数组值顺序排序的结果。这里主Promise会在所有成员的promise全部完成后才返回结果,如果其中一个失败或者被拒绝的话,那么整个Promise都会失败。
  2. Promise.race(),这个方法单个数组参数,这个数组由一个或多个Promise、thenable或 立即值组成。而返回值会拿第一个返回的数据。与Promise.all()类似,一旦有任何一个Promise决议为完成,Promise.race() 就会完成;一旦有任何一个 Promise 决议为拒绝,它就会拒绝。
设定3秒之后请求超时。
Promise.race( [
    foo(), // 启动foo()
    timeoutPromise( 3000 ) // 给它3秒钟 
])
.then(
    function(){ // foo(..)按时完成! },
    function(err){// 要么foo()被拒绝,要么只是没能够按时完成,
} );
  1. Promise.none(),所有的Promise都要被拒绝,即拒绝转化为完成值。
  2. Promise.any(),只需要完成一个就会有返回值
  3. Promise.first(),只取第一个Promise完成的值
  4. Promise.last(),只取最后一个Promise完成的值

注意,若向Promise.all([])传入空数组,它会立即完成,但Promise. race([]) 会挂住,且永远不会决议。

每个 Promise 实例都有then 和 catch方法,promise决议之后,立即会调用 这两个处理函数之一,取决于Promise的状态。

then(..) 接受一个或两个参数:第一个用于完成回调,第二个用于拒绝回调。两者中的任何一个被省略或者作为为非函数值的话,就会被默认的回调替换,默认完成回调只是把消息传递下去,而默认拒绝回调则只是重新抛出其接收到的出错原因。

catch(..)只接受一个拒绝回调作为参数,并自动替换默认完成回调,类似于then(null,rejected)

then(..) 和 catch(..) 也会创建并返回一个新的 promise。如果过程中,完成或拒绝回调中抛出异常,那么整个promise会被拒绝。如果任意一个回调返回非 Promise、非 thenable 的立即值,这个值会被用作返回 promise 的完成值。如果完成处理函数返回一个 promise 或 thenable,那么这个值会被展开,并作为返回 promise 的决议值。

局限性

  1. 错误处理,Promise的设计是链性的,所以一旦出错误,这个错误会在成员链中传递,而外部没有方法来观察错误。仅仅靠.catch的方法还是不够,因为这个错误也可能被自身修复了,那它就不会在.catch方法中显示。
  2. 单一值,Promise 只能有一个完成值或一个拒绝理由,但是在复杂的场景中,我们需要多个一个数值或者对象来封装这些值,这时候就显得很笨重。
  3. 单决议,Promise 只能被决议一次。而你需要多次不同的决议。
  4. 一旦创建注册就无法取消。
  5. 性能方面会比其他的异步处理方式慢一点,但是瑕不掩瑜。