javaScript之异步发展史一(回调,promise )

jsvascript现在运行的部分和将来运行的部分之间的关系就是异步编程的核心

事件循环(仅浏览器描述不包含node事件循环)

如果javascrip程序发出一个ajax请求,从服务器获取一些数据,那你就在一个函数(通常称为回调函数)中设置好响应代码,然后javascrip引擎会通知宿主环境:这个回调函数暂停执行,你一旦完成网络请求,拿到数据就请调用这个回调函数。
然后浏览器就会设置侦听来自网络的响应,拿到数据之后,就会把回调函数插入到事件循环,以此实现对这个回调的调度执行。(将来执行)(定时器回调函数也是类似)

用一段伪代码来描述 一下事件循环的概念

//eventLoop 是一个用作队列的数组
//先进,先出
var eventLoop = [];
var event;
while(true){//永远执行
    //一次tick
    if(eventLoop.length>0){
        event = eventLoop.shift();//拿到队列中的下一个事件
        //现在开始执行下一个事件
        try{
            event();
        }catch(err){
            reportError(err);//捕获异常函数
        }
    }
}

循环的每一轮称一个tick,对每个tick而言,如果在队列有等待事件,就会从队列中摘下一个事件并执行,这些事件就是回调函数。

setTimeout(...)并没有立刻把你的回调函数挂到事件循环中,它所做的是设定一个定时器,当定时器到时后,环境会把你的回调函数放到事件循环中,这样在未来的某个时刻的tick会摘下并执行这个回调。

如果这时候事件循环中有20个项目了,你的回调就会等待,它得排在其他项目后面——通常没有抢占得方式支持直接将其排到队首(后面会讲建立在事件循环上新概念任务队列,Promise异步特性来解决精细控制),这也解释了为什么定时器得精度可能不太高,大体来说,只能确保你的回调函数不会在指定时间间隔之前运行,但可能在那个时刻运行,也可能在那之后运行,要根据事件队列得状态所决定。

所以换句话说,程序分成了很多小块,在事件循环中一个一个执行,严格来说和你程序不直接相关得其他事件也可能插入到队列中。

考虑异步出现得问题

var a=1;
var b=2;
function foo(){
    a++;
    b=b*a;
    a=b+3;
}
function bar(){
    b--;
    a=8+b;
    b=a*2;
}
//ajax某个库提供请求
ajax('http://some.url.1',foo);
ajax('http://some.url.2',bar);

同一段代码有两个可能输出意味着还是存在不确定性。
在JavaScript得特性中,这种函数顺序得不确定性就是通常所说得竞态条件(race condition),foo()和bar()相互竞争,看谁先运行,具体来说就是,因为无法可靠预测a和b得最终结果,所以才是竞态条件。

通过异步事件循环可以解决以下问题,考虑

var res=[];
//从ajax取得记录结果
function response(data){
    res = res.concat(data.map(function(val){
        return val*2;
    }))
}
//ajax某个库提供请求
ajax('http://some.url.1',response);
ajax('http://some.url.2',response);

如果返回数据记录几千条或更少,这不算什么,但是假如有1000万条,就可能需要运行相当一段时间了(移动设备需要时间更长,等等)。
这样代码运行时,会阻塞其他操作,界面上所有其他代码都不能运行调用或者ui刷新(类似卡死)。
这里有个比较简单解决方案

var res=[];
function response(data){
    var chunk = data.splice(0,1000);
    res = res.concat(chunk.map((val)=>{
        return val*2;
    }));
    if(data.length>0){
        setTimeout(function(){
            response(data);
        },0)
    }
}

这样进行拆分多个异步任务形式,不阻塞JavaScript引擎来提高站点响应性能。
当然,我们没有协调这些'进程'的顺序,所以结果的顺序是不可预测的。

任务

在es6中,有一个新的概念建立在事件循环队列之上,叫做任务队列(job queue),这个概念给大家带来最大影响可能是Promise的异步特性。

从概念上描述应该是: 它是挂在事件循环队列的每个tick之后的一个队列,在事件循环的每个tick中,可能出现的异步动作(例如Promise)不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。
这就像是在说:这里还有一件事将来要做,但要确保在其他任何事情发生之前就完成它(类似插队,插入到了当前tick 的任务队列)。

一个任务可能引起更多的任务被添加到同一个队列末尾。所以理论上来说,任务循环(job loop)可能无限循环(一个任务总是添加另一个任务,以此类推),进而导致程序没有足够资源,无法转移到下一个事件循环tick。

设想一个调度任务(不要hack)的api,称为schedule(...)

(//当前的tick
    console.log('a');
    setTimeout(function(){console.log('b')},0);//回调 添加到事件循环tick
    schedule(function(){//添加到当前tick的任务队列
        console.log('c');
        schedule(function(){//上面任务导致这个添加到当前tick的任务队列,也就是说一个任务总时添加另一个任务
            console.log('d');
        })
    })
)

实际打印结果 a c d b,因为任务处理是在当前事件循环tick结尾处,且定时器触发是为了是为了调度下一个事件循环tick(如果可用的话!)

Promise的异步特性就是基于任务的。

小结:一旦有事件需要运行,事件循环就会运行,知道队列清空,事件循环每一轮称为一个tick,用户交互,io和定时器会向事件队列添加事件。
任意时刻,一次只能从队列处理一个事件,执行事件的时候,可能直接间接引发一个或多个后续事件。

多个事件交替执行,要确保执行顺序防止竞态出现。

1.回调

考虑

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

这里三个异步操作形成的链,这种代码通常被称为回调地狱,在线性顺序追踪这段代码时,不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以查看流程。而且这还是简化的形式,真实的异步javascript程序代码要混乱的多,这使得这种追踪的难度会成倍的增加。

信任问题

例如使用一个第三方库,传入回调函数

analytics.trackPurchase(purchaseData,function(){
    chargeCreditCard();
})

我们把程序一部分执行权交给某个第三方,这被称为控制反转

例如上面代码:假如这个库的维护者误传了代码,导致回调执行多次,这就是极严重的bug。

这种 把执行权交给第三方库会产生种种有可能发生的问题:

1.调用回调过早。
2.调用回调过晚(或没有调用)。
3.调用次数过多或过少。
4.没有把所需环境/或参数成功传给你
5.吞掉可能出现的错误或者异常
...
这感觉就像是一个麻烦列表,所以我们可以逐渐意识到,对于被传给我们无法信任工具库的每个回调,我们都将不得不创建大量的混乱逻辑来兼容。

我们可以发明一些特定逻辑来解决这些信任问题,比如:
1.分离回调,一个成功,一个错误。
2.node风格,回调第一个参数保留错误对象(如果有的话),成功的话,这个参数就会被置空。
但是其难度高于应有的水平,可能会产生更笨重,更难维护的代码,并且缺少足够的保护,其中的损害直到收到bug的影响才会发现。

我们需要一个通用解决方案,不管我们创造多少回调,这一方案都可以复用,且没有重复代码的开销。

Promise

回调是把控制权交给第三方,接着期待其能够调用回调,实现正确功能(控制反转)。
但是假如我们能够把控制反转再反转回来,是不是就可以解决这个问题了,如果我们不把回调提供给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己代码来决定下一步做什么,那会怎么呢,这种范式就被称为Promise

未来值

假如我们向第三方发送一个请求,通常我不能立刻拿到结果而是第三方给返回了一个 标识(promise),保证最终我会得到我的结果。
这是一个未来值,换句话说一旦我需要的结果准备好了,我就用我的标识换取这个结果。
但是还有另一种结果,执行失败了。
这也可以看到未来值一个重要特性:可能成功,可能失败。

现在值和将来值
同步异步行为(Zalgo)

var x,y=2;
ajax('...',function(res){
    x=res;
})
console.log(x+y)//NaN

y是现在值 x是将来值,通过回调方式代码会异常丑陋,Promise中为了统一处理现在和将来,我们把他都变成了将来,即所有操作都是异步的了。

Promise.all([Promise.resolve(y),fetch('...')]).then(function(values){
    console.log(values[0]+value[1]);
})

向我们之前说的可能会有拒绝,完成值总时编程给出的,而拒绝值,通常被称为拒绝原因(reject),可能是程序逻辑设置的,也可能是从运行异常隐式得出的值。

//new时传入函数是立即执行得
new Promise(function(resolve,reject){...}).then(function(res){//完成处理函数

},function(err){//拒绝处理函数

})

从外部看,由于promise封装了依赖时间的状态——等待底层值得完成或拒绝,所以promise本身和时间是无关得。因此promise可与i按照可预测得方式组合,而不用关心时序。
另外,一旦promise决议,他就永远保持这个状态,此时它就成了不变值(immutable value),可以根据需求多次查看。
promise决议后就是外部不可变得值,我们可以安全得把这个值传递给第三方,并确信它不会被有意无意修改,特别是对于多方查看同一个promise决议得情况,这是promise设计最基础和最重要得因素,我们不应该随意忽略这一点。

调用过早

这个问题主要就是担心代码是否会引入类似Zalgo这样副作用,在这类问题中,一个任务有时同步完成,有时异步完成,这可能会导致竞态条件。
根据定义,Promise不必担心这种问题,因为即使是立刻完成得promise(类似new Promise(function(resolve){resolve(42)}))也无法被同步观察到。
也就是说,对于一个promise调用then时,即使这个promise已经决议,提供给then得回调也总是会被异步调用。
不需要插入自己得setTimeout,promise会自动防止Zalgo出现。

调用过晚

promise创建对象调用resolve()或reject()时,这个promise得then注册得观察回调就会被自动调度,可以确信,这些被调度得回调再下一个异步事件点上一定会被触发。
同步查看时不可能得,所以一个同步任务链无法以这种方式运行来实现按照预期有效延迟另一个回调得发生。也就是说,一个promise决议后,这个promise上有所通过then注册得回到都会再下一异步时机点上依次被调用,这些回调中任意一个都无法延迟或延误对其他回调得调用。
例如

p.then(function(){
    p.then(function(){
        console.log('c')
    })
    console.log('a')
})
p.then(function(){
    console.log('b')
})
//abc

这里,c无法打断或抢占b,这是因为promise得运作方式。(所有都是异步)

promise调度技巧
有的时候不同调用方式,会有很多调度得细微差别。

var p3=new Promise(function(resolve,reject){
    resolve('b')
})
var p1=new Promise(function(resolve,reject){
    resolve(p3)
})
var p2 =new Promise(function(resolve,reject){
    resolve('a')
})
p1.then(function(v){
    console.log(v)
})
p2.then(function(v){
    console.log(v)
})
//ab

p1不是用立即值而是用另一个promise p3决议,后者本身决议值是b,规定得行为是把p3展开到p1,但是是异步的展开,所以,在异步任务中,p1得回调排在p2得回调之后。

回调未调用

首先,没有任何东西(包括javascript错误)能阻止promise向你通知它的决议(如果它决议了),如果你对promise注册一个完成回调,一个拒绝回调,那么promise决议时总会调用其中一个。
但是如果promise本身永远不会决议呢》即使这样,promise也提供了解决方案,其使用了竞态的高级抽象机制:

function timeoutPromise(delay){
    return new Promise(function(resolve,reject){
        setTimeout(function(){
            reject('超时了')
        },delay)
    })
}
//foo(),一个promise ,监听它超时
Promise.race([foo(),timeoutPromise(5000)]).then(function(){
    //及时完成
},function(err){
    //foo被拒绝,或者没事按时完成,通过err来查看
})
//顾名思义,Promse.race就是赛跑的意思,意思就是说,Promise.race([p1, p2, p3])里面哪个结果获得的快,就返回那个结果,不管结果本身是成功状态还是失败状态。

调用过多或者过少

根据定义,回调被调用正确次数应该是1,过少是0,和未被调用是一种情况。
过多很容易解释,promise的定义方式使得它只能被决议一次,如果出于某种原因,promise创建代码试图调用resolve或reject多次,或者试图两者都调用,那么这个promise将会只接受第一次决议,并默默忽略任何后续调用。

由于promise只能被决议一次,所以任何通过then注册的(每个)回调就只会被调用一次。
当然如果你把回调注册了不止一次(比如p.then(f);p.then(f)),那么它被调用次数就会和注册次数相同。

未能传递参数/环境值

promise至多只能有一个决议值(完成或拒绝)

如果没有用任何显式值决议(resolve(),reject()),那么这个值就是undefined,但不管这个值是什么,无论当前或者未来,它都会传给所有注册的(完成或拒绝)回调。

还有就是使用多个参数决议(resolve(),reject()),第一个参数之后的所有参数都会默默忽略。这样是对promise机制的无效使用。
如果要传递多个值,就必须把他们封装在单个值中传递,比如通过一个对象或者数组。

是可信任的promise吗

promise并没有完全摆脱回调,它只是改变了传递回调的位置,我们并不是把回调传给第三方库,而是从第三方得到某个东西(外观上看是一个真正的promise(有可能是promise也有可能是鸭子类型thenable )),然后把回调传给这个东西。

thenable可以自行百度了解下

但是这怎么就能确定比回调更加值得信任呢,如果确定返回的这个东西就是一个promise呢,这难道不是一个(脆弱的)纸牌屋,在里面只能信任我们已经信任的?

关于promise的很重要但是常常被忽略的一个细节是,promise对这个问题已经有一个解决方案,包含在原生es6 promise实现中解决方案就是Promise.resolve(...)。

如果向Promise.resolve(...)传递一个非promise,非thenable的立即值,就会得到一个用这个值填充的promsie。

如果向Promise.resolve(...)传递一个真正的promise,就只会返回一个promise。

如果向Promise.resolve(...)传递了一个非promise的thenable值,前者就会试图展开这个值,而且展开过程会持续到提取出一个具体的非类promise的最终值。

从Promsie.resolve(...)得到的是一个真正的promise,是一个可以信任的值(返回的总是一个promise),如果传入的已经是真正的promise,那么你得到就是它本身,所以通过Promsie.resolve(...)过滤来获取可信任性完全没有坏处。

假设我们要调用一个工具foo(),且不确定得到的返回值是否是一个可信任的行为良好的promise,但我们可以知道它至少是一个thenable,那么:

//不要只是这么做
foo(42).then(function(v){
    console.log(v)
})

//而是如下
Promoise.resolve(foo(42)).then(function(v){
    console.log(v)
})

对于用Promise.resolve(...)为所有函数的返回值(不管是不是thenable)都封装一层,另一个好处是,这样很容易把函数调用规范为定义良好的异步任务,如果foo(42)有时返回是一个立即值(不是异步调用),有时返回promise,那么Promise.resolve(foo(42))就能保证总会返回一个promise结果,而且可以避免的Zalgo就能得到更好的代码。

链式流

每次对Promose调用then(...),它都会返回一个新的promise,我们可以将其链接起来;
不管从then(...)调用的完成回调(第一个参数)返回的值是什么,他都会自动设置为被链接promise的完成。

例如

var p = Promise.resolve(21);
var p2 = p.then(function(v){
    return v*2;
})
p2.then(function(v){
    console.log(v)//42
})

使用return会立即完成链接的promise,如果不想立刻完成链接可以返回promise对象。

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

决议(resolve) 完成(fulfill) 拒绝(reject)
new Prmose(function(resolve,reject){}) 使用到了resolve,reject
命名从技术来说,这无关紧要,但是从团体开发来说,不要使用有可能产生歧义的命名。
拒绝 很容易决定,几乎所有文献都命名为reject;
第一个参数为什么叫决议(resolve)不叫fulfill,因为resolve有可能返回一个错误reject出来,例如:resolve(Promise.reject('错误'))

reject不会像resolve一样进行展开,如果向reject传入一个promsie值,它会把这个值原封不动设置为拒绝理由,往后传递,而不是底层的立即值。

错误处理

错误处理最自然的方式自然是try catch,但是它只能是同步的,无法用于异步。(后期讲如何使用try catch 变相监听异步)

为了避免丢失和抛弃的promise错误,有一部分开发同学认为promise链的最后总以一个catch结束,例如:

var p = Promsie.resolve(42);
p.then(function(v){
    //会报错
    console.log(v.toLowerCase)
}).catch(handleErrors)

但是会有一个问题,假如handleErrors内部也有错误,那谁来捕获它。
所以说:Promise 对象的回调链,不管以then方法或catch方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为 Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个done方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。

 Promise.prototype.done = function (onFulfilled, onRejected) {
    this
      .then(onFulfilled, onRejected)
      .catch(function (reason) {
        // 抛出一个全局错误
        setTimeout(() => {
          throw reason
        }, 0) 
      })
  }

promise常用api

Promise.all([..]):需要一个数组参数,由promise实例组成,等待数组里的promise、全部决议才会then,一旦有一个拒绝主promise.all会立即拒绝,并丢弃其他所有promise结果,then回调参数是一个数组,对应all数组里决议的消息列表。

Promise.race([..]):竞态,Promse.race就是赛跑的意思,意思就是说,Promise.race([p1, p2, p3])里面哪个结果获得的快,就返回那个结果,不管结果本身是成功状态还是失败状态。(永远不要传递空数组,不然永远不会决议),和all一样一旦有一个拒绝,race就会拒绝。

none([..]):类似all。不过完成和拒绝的情况互换了。

any([..]):类似all。但是会忽略拒绝,所以只需完成一个而不是全部。

first([..]) last([..])

如何把需要回调的函数封装为支持promise的函数:

if(!Promise.wrap){
    Promise.wrap=function(fn){
        return function(){
            var args = [].slice.call(arguments);
            return new Promsie(function(resolve,reject){
                fn.apply(null,args.concat(function(err,v){//回调
                    if(err){
                        reject(err)
                    }else{
                        resolve(v)
                    }             
                }))
            })
        }
    }
}

var request = Promise.wrap(ajax);
request('http://....').then(..)

wrap 一个promise的工厂

无法取消的promise,只能设置超时,但是很简陋,推荐查看promise抽象库,例如asynquence abort();
单独的promise不应该被取消,但是取消一个序列是合理的(promsie链),因为不会像对待promise那样把序列作为一个单独的不变值来传送。

javaScript之异步发展史二(generator,async)

阅读 122

推荐阅读

世界核平

0 人关注
17 篇文章
专栏主页