Loading

promise+generator函数优化异步编程的底层原理分析

前言

异步编程是每个前端都绕不过去的一个东西,异步代码其实就是一部分现在运行,而另一部分在将来运行,而程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。 其实说到底它就是讨论着一个问题:如何表达和控制持续一段时间的程序行为。

1. 1 传统回调异步编程

下图就是典型的回调函数处理异步问题的代码:

传统回调函数控制异步代码是我们曾经最常用到的方式了,之所以说是曾经,是因为回调函数方法有一些缺陷,而且现在也有了一些更好的代替技术。下面我们就来讨论一下它有什么缺陷。

1.2 传统回调的问题

1.2.1 变形的代码

正如上图所示,代码一旦变得复杂起来,回调函数就会一层一层的套起来,变得非常难看。

1.2.2 顺序的大脑

仔细思考就会发现,我们的大脑是以异步方式运作的,例如打车这件事:掏出手机打车,然后在等车的过程中也许会忍不住刷个b站视频,直到车来了才又想起打车这件事。 但我们的大脑思考方式还是以顺序同步的方式进行,例如:打车的时候我会想:我要先打车回家,然后吃饭,最后睡觉。 事实上异步的思维本就比较违反大脑的思维方法。这样就出现了一个问题:回调函数写出来正是逼迫人用异步的思维想问题。这种思考/计划的意识流对我们中绝大多数人来说是不自然的。或许大家会说平时思考异步过程也挺容易的,但是一旦回调嵌套多了,过段时间之后你就会变的难以理清楚这些异步流程。既限制了思维,代码又难以维护。这就是为什么精确编写和追踪使用回调的异步Javascript代码如此困难,因为这并不是我们大脑思考方式。

1.2.3 信任问题

上面那些问题只是回调问题的一部分,还有一些更深入的问题需要考虑。请看下面例子:

// A
ajax("..", function (..) {
    // C
})
// B

假设代码中 ajax() 是第三方库提供的函数,那么A,B发生在现在,在Javascript主程序的直接控制之下。而C会延迟到将来执行,并且是在第三方函数ajax()控制之下的。请不要忽视这一细节,这是回调驱动设计最严重的问题。这被称为控制反转,也就是把自己程序的一部分交给第三方。而你并不知道第三方代码内部是怎样的逻辑。举个例子:加入你是银行的程序员,要开发付款模块。你需要调用某个第三方库里的函数以便追踪这个交易。你发现可能为了提高性能,该库提供了一个看似用于异步追踪的工具,需要传入回调函数,也就是付款函数。但上线之后发现有人反馈点击付款后付款5次!,而你深入研究后才发现原来这个三方库出于某种原因把你的回调函数调用了5次。当然还有更多意想不到的事。总之控制反转非常危险。你只能在回调函数中加以防范,去做一些判断。

那么接下来我们继续分析下面改进的技术能否解决上述问题。

2. Promise

2.1 Promise基本原理

上面提到了我们把回调提供给第三方,期待其能够调用回调,实现正确的功能。但是结果却不一定像想象的那样,所以我们要解决控制反转问题,毕竟信任很脆弱,也很容易丢失。

但是我们能够把控制反转再反转回来,会怎样呢?如果我们不把自己程序的回调传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么,那会怎么样呢?这种范式就称为Promise。

让我们来回顾一下Promise具有怎样的性质,提供了那些功能:

1.  Promise有三种状态:pending,fulfilled,rejected。pending是初始状态,fulfilled,rejected是最终态。且一个Promise 从pending状态变换只能进行一次,要么变成fulfilled,要么rejected。

2. Promise提供一些api如:实例方法then(). 静态方法 Promise.resolve() ,  Promise.all(),  Promise.race()等。其中then() 是核心方法,用来访问 Promise 变换状态后的结果,并且then也会返回Promise这样就可以进行链式调用。

2.2 Promise解决了哪些问题

2.2.1. 一定程度上解决了回调嵌套的问题,例如上面的回调函数形式代码可以写成这样:

 

// A

var http = new Promise(function(resolve, reject) {
   ajax(..., function(res) {
        resolve(res)
    }) 
})

http
.then(function(C) {
    console.log(C)
})

// B

 

 

 

2.2.2. 解决了信任问题,针对上面支付功能回调的问题,我们来一一分析:

1. 调用过早

// A
ajax("..", function (..) {
    // C
})
// B
// 第三方 ajax 不能保证自己是异步执行,也有可能同步执行。

在这类问题中,一个任务有时同步完成,有时异步完成,这可能会导致竞态条件。根据Promise的原理来看,即使是立即完成的Promise也无法被同步观察到。因为Promise的then里的回调始终是异步执行的。

2. 调用过晚

 

p.then( function() {
   p.then( function() {
        console.log("C")
    }) 
    console.log("A")
})
p.then( function() {
    console.log("B")
})
// A B C 这里,“C”无法打断或抢占“B”,因为Promise的运作方式。

 

一个Promise决议后,这个 Promise 上所有通过then() 注册的回调都会严格的在下一个异步时机点上依次被立即调用。

3. 回调未调用

这个问题是比较常见的。首先,没有任何事能阻止Promise向你通知它的决议,并且相应的成功或者失败的回调一定会执行。其次如果Promise本身永远无法决议,那我们可以使用一些手段,让其在超时后返回失败的决议。Promise中有一种称为竞态的高级抽象机制:

 

// 用于超时一个Promise的工具
function timeoutPromise() {
    return new Promise( function(resolve, reject) {
        setTimeout(function () {
            reject("Timeout!");
        }, delay)
    })
}

//设置foo() 超时
Promise。race([
    foo(),
    timeoutPromise(3000)    // 给foo三秒钟时间
])
.then(
    function() {
        // foo() 及时完成
    },
    function(err) {
        // foo未能完成,或者被拒绝。err查看错误。
    }
)

 

4. 调用次数过少或过多

这类型错误比较好解释,根据Promise的性质:then() 回调只能被正确调用一次,0次的情况和前面一样。多次的情况,如果你设置了多次resolve() 那Promise决议后只会调用第一个resolve() 并且忽略其余resolve。

5. 未能传递参数/环境值

Promise最多只能有一个决议值(完成或者拒绝),如果你没有用任何值显示的决议,那么这个值就是undefind,另外还要注意,如果你resolve,reject传入的值有多个,那么他们会忽略第一个以后的参数。所以你必须把想要传递的值封装成数组或者对象。

2.3 Promise的局限性

2.3.1 单决议

Pomise最本质的一个特征是∶Promise只能被决议一次(完成或拒绝)。在许多异步情况中,你只会获取一个值一次,所以这可以工作良好。但是,还有很多异步的情况适合另一种模式——一种类似于事件和/或数据流的模式。比如一个点击事件,如果使用promise封装,则在点击时只能执行一次。点击第二次时,promise已经决议了。只能重新设置一个promise,这样代码会很臃肿难看。

 

 

3. Promise与生成器

3.1 生成器基本原理

3.1.1 协程

在熟悉协程之前我们先来了解一下进程和线程,以及为什么需要协程。

进程(Process):是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器。

线程:是操作系统能够进行运算调度的最小单位,首先我们要清楚线程是隶属于进程的,被包含于进程之中。一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的。

正是由于进程和线程是系统级的调度单位,所以在一些任务场景中比如 IO操作 线程切换性能开销很大,所以协程正是为了解决这一问题的。

协程:我们又称为微线程,协程它不像线程和进程那样,需要进行系统内核上的上下文切换,协程的上下文切换是由开发人员决定的。协程是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。

当然协程也不是万能的,它需要配合异步 I/O 才能发挥最好的效果,对于操作系统而言是不知道协程的存在的,它只知道线程。需要注意,如果一个协程遇到了阻塞的 I/O 调用,这时会导致操作系统让线程阻塞,那么在这个线程上的其它协程也都会陷入阻塞。

3.1.2 generator函数

在我们的普遍认知中,一个 javascript 函数一旦开始执行,就会运行到结束,期间不会有其他代码能够打断它并插入其间。不过ES6引入了一个全新的函数类型,它颠覆了这种普遍的运行方式,这类新的函数被称之为生成器。事实上 ES6 的 generator就是协程的实现,只不过generator是协程的子集,只有部分能力。所以它也被成做"半协程"。下面这段代码就是一个generator函数,它不同于普通函数,是可以暂停执行的,所以函数名之前要加星号,以示区别。

 

function* gen(x){
  var y = yield x + 2;
  return y;
}

 

整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。Generator 函数的执行方法如下。

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g 。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针 g 的 next 方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的 yield 语句,上例是执行到 x + 2 为止。

换言之,next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息( value 属性和 done 属性)。value 属性是 yield 语句后面表达式的值,表示当前阶段的值;done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。

 

3.2 生成器解决了哪些问题

3.2.1 Generator 函数的双向数据交换和错误处理

 

Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。

next 方法返回值的 value 属性,是 Generator 函数向外输出数据;next 方法还可以接受参数,这是向 Generator 函数体内输入数据。

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

上面代码中,第一个 next 方法的 value 属性,返回表达式 x + 2 的值(3)。第二个 next 方法带有参数2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量 y 接收。因此,这一步的 value 属性,返回的就是2(变量 y 的值)。

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){ 
    console.log(e);
  }
  return y;
}

var g = gen(1);
g.next();
g.throw'出错了');
// 出错了

上面代码的最后一行,Generator 函数体外,使用指针对象的 throw 方法抛出的错误,可以被函数体内的 try ... catch 代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

3.2.2 异步迭代生成器

让我们来重新会想一下之前的一个场景:

function foo(x,y,cb) {
    ajax("http://some.url.1/?x=" + x + "&y" + y,  cb);
}

foo( 11, 31, function(err, text) {
    if (err) {
        console.error(err);    
    }
    else {
        console.log( text )
    }
})

如果想要通过生成器来表达同样的任务流程控制,可以这样实现。第一眼看上去这样的实现比以前更复杂了,但实际上生成器代码要好得多。

function foo(x,y){
    ajax( "http://some.url.1/?x="+×+"&y=", function(err,data){ 
        if (err){
            // 向*main()抛出一个错误
            it.throw( err );
        } else {
            //用收到的data恢复*main()
            it.next( data );
        }
    })
} function
*main(){ try { var text = yieLd foo( 11,31 ); consoLe.log( text ); } catch (err) { consoLe.error( err ); } } var it= main(); // 这里启动! it.next();

我们看最重要的一段代码:

var text = yield foo( 11, 31 )
console.log( text )

我们调用了函数foo,并从中获取了text,即使foo是异步的。如果不加 yield 那么对于异步执行的 foo 是得不到 text 的。这其中的奥秘就是 yield。 它暂停了main的执行。 而什么时候继续执行下去呢? 在foo函数里已经给出了答案,在我们的代码设计下巧妙的利用了生成器函数双向传递数据的特性,当ajax请求完成后,它会自己向生成器函数发出指令进行下一步执行,无论成功或失败。而在发出指令的同时把请求结果一并传递出去。 这样我们的main函数就可以接收到异步请求执行完毕返回的数据,并传递给 text。 这一过程是非常巧妙的。

 

3.3 生成器和Promise的组合---最佳实践

在前面的讨论中,我们展示了如何异步迭代生成器,这是一团乱麻似的回调在顺序性和合理性方面的巨大进步,但是我们错失了很重要的2点 promise 的可信任性可组合性。不过不用担心,我们我们能够重获这些优点。回想一下之前使用 Promise 封装的Ajax请求:

function foo(x,y){
    return request( "http://some.url.1/?x="+×+"&y="+y );
}

foo( 11,31 )
.then(
    function(text){
        console.Log( text ); 
    },
    function(err){
        console.error( err ); 
    }
}

那么我们怎么让生成器和Promise组合封装异步任务呢? 答案就是使用生成器 yield 出来一个promise,然后迭代器控制代码接受这个 promise。 通过侦听这个 promise 的决议。要么完成:使用next(text) 回复生成器运行,要么失败:使用throw(err) 向生成器抛出一个带有失败原因的错误。想到这一点我们很容易得到以下代码。

首先把支持 Promise 的 foo(..) 和生成器 *main() 放在一起,可以看到生成器函数的样子和之前分析的异步迭代生成器类似,几乎没有改变什么。

function foo(x,y) {
    return request("http://some.url.1/?x="+×+"&y=");
}
// ---------------------
function
*main(){ try { var text= yield foo( 11,31 ); console.Log( text ); } catch (err) { console.error( err ); } }
// ---------------------
var it = main();
var p= it.next().value;
// 等待promise p决议
p.then(
function(text){
    it.next( text );
},
function(err){
it.throw( err );
}
)
 
      

然后就是启动main函数获取返回的promise对象,等待promise决议后,通知 main() 进行下一步,即返回决议的结果。也许看到这样一段代码会感觉比较绕。实际上最新的ES7语法已经替我们封装好了语法糖--async/await。 请看如下代码:

function foo(x,y) { 
 return request( "http://some.url.1/?x=" + x + "&y=" + y ); 
}

async function main() {
    try {
       var text = await foo( 11, 31 );
       console.log( text );
    } catch (err) {
        console.error( err );
    }
}
main();

利用这一语法糖我们无需将main() 函数声明为生成器函数,而是声明为一类新的函数 async函数,并且也无需yield 一个promise。 而是使用 await 关键字去声明一个promise类型的任务,此时async就会自动获知需要做什么,它会暂停等待,直到promise 决策且返回 await 后面任务的结果。

4. 总结

经过以上的分析,我们总结了异步编程的发展过程:

首先最原始的是回调函数形式,它有明显缺陷:1. 代码变难看,2. 思考方式不符合人的大脑,造成很多限制。 3. 有很多信任问题,比如:调用过早,调用过晚,调用次数过多,回调未调用,未能传递环境值/参数。4.  错误处理比较困难。

接着出现了Promise这一包装对象。就像它的名字一样,它对异步任务有了很多严格的限制,也很好的解决了回调函数的信任问题,并对错误处理有了一定改进,也一定程度上缓解了回调嵌套带来的思考困难的问题。但Promise也有一些限制和未能解决的问题,比如单决策限制。以及不能彻底实现异步代码顺序化。

这时生成器函数出现了它利用协程的思想,实现了异步代码的顺序编写。并且优化了错误处理,使其可以直接像同步代码一样使用 try/catch。

最后基于生成器和promise技术的综合使用使得这两种技术的优点结合,构成了异步编程的最佳实践。这在ES7中也通过 async/await 语法糖实现了这一实践。让异步编程变得可信任,易追踪错误,代码整洁,逻辑易于理解。

posted @ 2021-07-15 08:09  图解编程  阅读(245)  评论(0编辑  收藏  举报