前端异步编程发展历程

异步编程发展历程之Promise的演进史

介绍

JavaScript是⼀⻔典型的异步编程脚本语⾔,在编程过程中会⼤量的出现异步代码的编写,在JS的整个发展历程中,对异步编程的处理⽅式经历了很多个时代,其中最典型也是现今使⽤最⼴泛的时代,就是Promise对象处理异步编程的时代。

那么什么是Promise对象呢?

Promise是ES6版本提案中实现的异步处理⽅式,对象代表了未来将要发⽣的事件,⽤来传递异步操作的消息。

为什么使⽤Promise对象

举例:
在过去的编程中JavaScript的主要异步处理⽅式,是采⽤回调函数的⽅式来进⾏处理,想要保证n个步骤的异步编程有序进⾏,会出现如下的代码(以setTimeout为例)

setTimeout(function(){
    //第⼀秒后执⾏的逻辑
    console.log('第⼀秒之后发⽣的事情')

    setTimeout(function(){
        //第⼆秒后执⾏的逻辑
        console.log('第⼆秒之后发⽣的事情')

        setTimeout(function(){
            //第三秒后执⾏的逻辑
            console.log('第三秒之后发⽣的事情')
        },1000)
    },1000)
},1000)

参考上⾯的代码,如果分3秒每间隔1秒运⾏1个任务,这三个任务必须按时间顺序执⾏,并且每个下⼀秒触发前都要先拿到上⼀秒运⾏的结果,那么我们不得不将代码编写为以上案例代码。该写法主要是为了保证代码的严格顺序要求,这样就避免不了⼤量的逻辑在回调函数中不停的进⾏嵌套,这也是我们经常听说的“回调地狱”。

举例:在编程中setTimeout的“例子”其实使⽤场景极少,在前端开发过程中使⽤最多的异步流程就是AJAX或其他异步请求,当系统中要求某个⻚⾯的n个接⼝保证有序调⽤的情况下就会出现下⾯的情况:

$.ajax({
    url:'/***',
    success:function(res){
        var xxId = res.id
        //获取该类型的数据集合,必须等待回调执⾏才能进⾏下⼀步
        $.ajax({
            url:'/***',
            data:{
                xxId:xxId,//使⽤上⼀个请求结果作为参数调⽤下⼀个接⼝
            },
            success:function(res1){
                //得到指定类型集合
                ...
            }
        })
    }
})

这种情况在很多⼈的代码中都出现过,如果流程复杂化,在⽹络请求中继续夹杂其他的异步流程,那么这样的代码就会变得难以维护了。其他的“例子”诸如Node中的原始fs模块,操作⽂件系统这种就不举了,所以之所以在ECMA提案中出现Promise解决⽅案,就是因为此类代码导致了JS在开发过程中遇到的实际问题:【回调地狱】。其实解决回调地狱的⽅式还有其他⽅案,本节我们不多做介绍中间的过渡⽅案,核⼼介绍Promise流程控制对象,因为他是解决回调地狱的⾮常好的⽅案。

使⽤Promise如何解决异步控制问题

前⾯的章节仅仅是抛出了问题,并没有针对问题作出⼀个合理的回答。下⾯阐述⼀下如何使⽤Promise对象解决回调地狱问题。

在阐述之前,我们先对Promise做⼀个简单的介绍:

Promise对象的主要⽤途是通过链式调⽤的结构,将原本回调嵌套的异步处理流程,转化成“对象.then().then()…”的链式结构,这样虽然仍离不开回调函数,但是将原本的回调嵌套结构,转化成了连续调⽤的结构,这样就可以在阅读上编程上下左右结构的异步执⾏流程了。(流数据结构:利于人的思维)

举例:

//使⽤Promise拆解的setTimeout流程控制
const p = new Promise(function(resolve){
    setTimeout(function(){
        resolve()
    },1000)
})

p.then(function(){
    //第⼀秒后执⾏的逻辑
    console.log('第⼀秒之后发⽣的事情')
    
    return new Promise(function(resolve){
        setTimeout(function(){
            resolve()
        },1000)
    })
}).then(function(){
    //第⼆秒后执⾏的逻辑
    console.log('第⼆秒之后发⽣的事情')
    
    return new Promise(function(resolve){
        setTimeout(function(){
            resolve()
        },1000)
    })
}).then(function(){
    //第三秒后执⾏的逻辑
    console.log('第三秒之后发⽣的事情')
})

结合代码案例我们发现使⽤了Promise后的代码,将原来的3个setTimeout的回调嵌套,拆解成了三次then包裹的回调函数,按照上下顺序进⾏编写。这样我们从视觉上就可以按照⼈类的从上到下从左到右的线性思维来阅读代码,这样很容易能查看这段代码的执⾏流程,代价是代码的编写量增加了接近1倍。

Promise介绍

从上⾯的案例介绍得知Promise的作⽤是解决“回调地狱”,他的解决⽅式是将回调嵌套拆成链式调⽤,这样便可以按照上下顺序来进⾏异步代码的流程控制。那么Promise是如何实现这个能⼒的呢?

Promise的结构

Promise对象是⼀个JavaScript对象也是一个构造函数,在⽀持ES6语法的运⾏环境中作为全局对象提供,他的初始化⽅式如下:

// fn:是初始化过程中调⽤的函数他是同步的回调函数
const p = new Promise(fn)

关于回调函数

这⾥涉及到⼀个概念:JavaScript语⾔中,有⼀个特殊的函数叫做回调函数(函数是JavaScript一等公民)。回调函数的特点是把函数作为变量看待,由于JavaScript变量可以作为函数的形参并且函数可以通过声明变量的⽅式匿名创建,所以我们可以在定义函数时将⼀个函数的参数当作函数来执⾏,进⽽在调⽤时在参数的位置编写⼀个执⾏函数,代码如下:

//把fn当作函数对象那么就可以在test函数中使⽤()执⾏他
function test(fn){
    fn()
}

//那么运⾏test的时候fn也会随着执⾏,所以test中传⼊的匿名函数就会运⾏
test(function(){
    ...
})

上⾯的代码结构,就是JavaScript中典型的回调函数结构。按照我们在事件循环中介绍的JavaScript函数运⾏机制,会发现其实回调函数本身是同步代码,这是⼀个需要【重点理解】的知识点。

通常在编写JavaScript代码时,使⽤的回调嵌套的形式⼤多是异步函数,所以⼀些开发者可能会下意识的认为,凡是回调形式的函数都是异步流程。其实并不是这样的,真正的解释是(重点!!!):JavaScript中的回调函数结构,默认是同步的结构,由于JavaScript单线程异步模型的规则,如果想要编写异步的代码,必须使⽤回调嵌套的形式才能实现,所以回调函数结构不⼀定是异步代码,但是异步代码⼀定是回调函数结构。

那么为什么异步流程都需要回调函数?

在JavaScript中,异步流程通常需要回调函数的原因在于JavaScript的运行机制和事件循环(Event Loop)模型。JavaScript是一种单线程语言,这意味着在任何给定时刻,只有一个任务(或操作)可以被执行。

然而,很多操作,特别是那些涉及I/O(输入/输出)的操作,如网络请求、文件读写、定时器等,都需要花费一定的时间来完成,这些操作被称为异步操作。

由于JavaScript的单线程特性,如果异步操作被执行时阻塞了主线程,那么浏览器或Node.js环境将无法响应用户交互或其他任务,这将导致应用程序的响应性变差。

为了避免这种情况,JavaScript采用了事件循环模型和异步非阻塞的执行方式来处理这些操作。

回调函数在这里扮演了关键角色,原因如下:

  1. 非阻塞性:异步操作不会阻塞主线程,这意味着JavaScript可以继续执行后续的同步代码。当异步操作完成时,相关的回调函数将被放入任务队列(Task Queue)中等待执行。
  2. 事件循环:JavaScript的事件循环会不断地检查任务队列,一旦主线程空闲(即没有正在执行的同步代码),事件循环就会从任务队列中取出回调函数并执行它们。这样可以确保异步操作的结果被及时处理,而不会影响主线程的流畅运行。
  3. 处理结果:回调函数提供了一种机制,允许开发者定义异步操作完成后应该执行的代码。这使得开发者可以处理异步操作的结果,或者在操作失败时执行错误处理逻辑。
  4. 控制流程:在某些情况下,异步操作的顺序和结果对程序的逻辑至关重要。回调函数允许开发者根据异步操作的完成情况来控制程序的流程。

然而,回调函数也带来了一些问题,如回调地狱(callback hell),这是指当多个异步操作嵌套或链接在一起时,代码变得难以阅读和维护。为了解决这些问题,JavaScript引入了Promise和其他等更现代的异步编程解决方案,它们提供了更清晰和更易于管理的方式来处理异步流程。

解读Promise结构

从运⾏流程上我们会发现new Promise中的回调函数确实是在同步任务中执⾏的,其次是如果这个回调函数内部没有执⾏resolve或者reject那么p对象的后⾯的回调函数内部都不会有输出,⽽运⾏resolve函数之后.then和.finally就会执⾏,运⾏了reject之后.catch和.finally就会执⾏。

剖析对象结构

Pomise对象相当于⼀个未知状态的对象,他的定义就是声明⼀个等待未来结果的对象,在结果发⽣之前他⼀直是初始状态,在结果发⽣之后他会变成其中⼀种⽬标状态,它的名字Promise中⽂翻译为保证。

很多国外的电影台词都会涉及到Promsie这个单词,⽐如⼩明发现了邻居张三的妻⼦出轨了,在某天喝酒的时候⼩明和张三说:I saw your wife played with other man! I promise! I saw it!张三当然会说:No!shit!I can not trust you!dame!(语义⾃⾏理解),Promise在英⽂中是绝对保证的意思,所以在编程中Promise对象是⼀个⾮常严谨的对象,⼀定会按照约定执⾏,不会出现任务灵异问题(除使⽤不当外)

那么Promise本身具备三种状态:

  • pending:初始状态,也叫就绪状态,这是在Promise对象定义初期的状态,这时Promise仅仅做了初始化并注册了他对象上所有的任务。

  • fulfilled:已完成,通常代表成功执⾏了某⼀个任务,当初始化函数中的resolve执⾏时,Promise的状态就变更为fulfilled,并且then函数注册的回调函数会开始执⾏,resolve中传递的参数会进⼊回调函数作为形参。

  • rejected:已拒绝,通常代表执⾏了⼀次失败任务,或者流程中断,当调⽤reject函数时,catch注册的回调函数就会触发,并且reject中传递的内容会变成回调函数的形参。

三种状态之间的关系:

Promise中约定,当对象创建之后同⼀个Promise对象只能从pending状态变更为fulfilled或rejected中的其中⼀种,并且状态⼀旦变更就不会再改变,此时Promise对象的流程执⾏完成并且finally函数执⾏。

总结

分析了对象结构和状态后,我们了解了Promise的异步回调部分如何执⾏,取决于我们在初始化函数中的操作,并且初始化函数中⼀旦调⽤了resolve后⾯再执⾏reject也不会影响then执⾏,catch也不会执⾏,反之同理。

⽽在初始化回调函数中,如果不执⾏任何操作,那么promise的状态就仍然是pending,所有注册的回调函数都不会执⾏。

关于链式调⽤

链式调⽤这个⽅式最经典的体现是在JQuery框架上,到现在仍然很多语⾔都在使⽤这种优雅的语法(不限前端还是后台),所以我们来简单认识⼀下什么是链式调⽤,为什么Promise对象可以.then().catch()这样调⽤。为什么还能.then().then()这样调⽤,他的原理⼤概是这样的。

function MyPromise(){
    return this
}
MyPromise.prototype.then = function(){
    console.log('触发了then')
    return this
}
new MyPromise().then().then().then()

其实他的本质就是在我们调⽤这些⽀持链式调⽤的函数的结尾时,他⼜返回了⼀个包含他⾃⼰的对象或者是⼀个新的⾃⼰,这些⽅式都可以实现链式调⽤。

Promise使⽤注意事项

在⽹⻚中运⾏如下代码查看返回结果:

const p = new Promise(function(resolve,reject){
    resolve('我是Promise的值')
})
console.log(p)

控制台上会得到如下内容:

> Promise {<fulfilled>: '我是Promise的值'}
    [[Prototype]]: Promise
    [[PromiseState]]: "fulfilled"
    [[PromiseResult]]: "我是Promise的值"

[[Prototype]]代表Promise的原型对象

[[PromiseState]]代表Promise对象当前的状态

[[PromiseResult]]代表Promise对象的值,分别对应resolve或reject传⼊的结果

链式调⽤的注意事项

运⾏如下代码并查看结果:

//通过⼀个超⻓的链式调⽤我们学习⼀下链式调⽤的注意事项
const p = new Promise(function(resolve,reject){
    resolve('我是Promise的值')
})

console.log(p, '==p==')

p.then(function(res){
    //该res的结果是resolve传递的参数
    console.log(res, '==第一个res==')
}).then(function(res){
    //该res的结果是undefined
    console.log(res, '==第二个res==')
    
    return '123'
}).then(function(res){
    //该res的结果是123
    console.log(res,'==第三个res==')
    
    return new Promise(function(resolve){
        resolve(456)
    })
}).then(function(res){
    //该res的结果是456
    console.log(res, '==第四个res==')
    
    return '我是直接返回的结果'
})
.then()
.then('我是字符串')
.then(function(res){
    //该res的结果是“我是直接返回的结果”
    console.log(res, '==第五个res==')
})

控制台上会输出如下结果:

> Promise {<fulfilled>: '我是Promise的值'}  ==p==
我是Promise的值  ==第一个res==
undefined  ==第二个res==
123  ==第三个res==
456  ==第四个res==
我是直接返回的结果  ==第五个res==

根据现象我们可以分析出链式调⽤的基本形式(极其重要):

  1. 只要有then()并且触发了resolve,整个链条就会执⾏到结尾,这个过程中的第⼀个回调函数的参数是resolve传⼊的值

  2. 后续每个函数都可以使⽤return返回⼀个结果,如果没有返回结果的话下⼀个then中回调函数的参数就是undefined

  3. 返回结果如果是普通变量,那么这个值就是下⼀个then中回调函数的参数

  4. 如果返回的是⼀个Promise对象,那么这个Promise对象resolve的结果会变成下⼀次then中回调的函数的参数

  5. 如果then中传⼊的不是函数或者未传值,Promise链条并不会中断then的链式调⽤,并且在这之前最后⼀次的返回结果,会直接进⼊离它最近的正确的then中的回调函数作为参数

中断链式调⽤

链式调⽤可以中断吗?答案是肯定的,我们有两种形式可以让.then的链条中断,如果中断还会触发⼀次.catch的执⾏。查阅下⾯的案例学习:

const p = new Promise(function(resolve,reject){
    resolve('我是Promise的值')
})

console.log(p,'==p==')

p.then(function(res){
    console.log(res, '==第一个res==')
}).then(function(res){
    //有两种⽅式中断Promise
    // throw('我是中断的原因')
    return Promise.reject('我是中断的原因')
}).then(function(res){
    console.log(res, '==第二个res==')
}).then(function(res){
    console.log(res, '==第三个res==')
}).catch(function(err){
    console.log(err, '==err==')
})

结果如下:

> Promise {<fulfilled>: '我是Promise的值'}  ==p==
我是Promise的值  ==第一个res==
我是中断的原因  ==err==

我们发现中断链式调⽤后会触发catch函数执⾏,并且从中断开始到catch中间的then都不会执⾏,这样链式调⽤的流程就结束了,中断的⽅式可以使⽤抛出⼀个异常或返回⼀个rejected状态的Promise对象。

中断链式调⽤是否违背了Promise的精神?

我们在介绍Promise的时候强调了他是绝对保证的意思,并且Promise对象的状态⼀旦变更就不会再发⽣变化。当我们使⽤链式调⽤的时候正常都是then函数链式调⽤,但是当我们触发中断的时候catch却执⾏了。按照约定规则then函数执⾏,就代表Promise对象的状态已经变更为fulfilled了,但是catch函数执⾏时,Promise对象应该是rejected状态啊!这不科学。

在得到科学的解释前,先下⾯举个例⼦:

const p = new Promise(function(resolve,reject){
    resolve('我是Promise的值')
})

const p1 = p.then(function(res){})

console.log(p, '==p==')
console.log(p1, '==p1==')
console.log(p1===p, '==验证p1是否和p全等==')

当我们运⾏上⾯的代码时,控制台会出现如下的打印信息:

> Promise {<fulfilled>: '我是Promise的值'}  ==p==
> Promise {<pending>}  ==p1==
false  ==验证p1是否和p全等==

我们会发现返回的p和p1 的状态本身就不⼀样,并且他们的对⽐结果是false,这就代表他们在堆内存中开辟了两个空间,p和p1对象分别保存了两个Promise对象的引⽤地址,所以then函数虽然每次都返回Promise对象,来实现链式调⽤,但是then函数每次返回的都是⼀个新的Promise对象。

这样便解释的通了!也就是说每⼀次then函数在执⾏时,我们都可以让本次的结果在下⼀个异步步骤执⾏时,变成不同的状态,⽽且这也不违背Promise对象最初的约定。

总结

根据以上的分析我们已经掌握了Promise在运⾏时的规则,这样就能解释的通,为什么最初通过Promise控制setTimeout每秒执⾏⼀次的功能可以实现,这是因为当我们使⽤then函数进⾏链式调⽤时,可以利⽤返回⼀个新的Promise对象来执⾏下⼀次then函数,⽽下⼀次then函数的执⾏,必须等待其内部的resolve调⽤。这样我们在new Promise时,放⼊setTimeout来进⾏延时,保证1秒之后让状态变更,这样就能不编写回调嵌套来实现连续的执⾏异步流程了。

Promise的演进

在介绍了这么多Promise对象后,我们发现他的能⼒⼗分强⼤,使⽤模式⾮常的⾃由,并且将JavaScript⼀个时代的弊病从此“解套”。

这个解套虽然⽐较成功,但是如果直接使⽤then()函数进⾏链式调⽤,我们的代码量仍然是⾮常沉重的,想要开发⼀个⾮常复杂的异步流程,依然需要⼤量的链式调⽤进⾏⽀撑,开发者还是会变得⾮常的难受。

按照⼈类的线性思维,虽然JavaScript分同步和异步,但是单线程模式下,如果能完全按照同步代码的编写⽅式来处理异步流程,这才是最奈斯的结果,那么有没有办法让Promise对象能更进⼀步的接近同步代码呢?

Generator函数的介绍

在JavaScript中存在这样⼀种函数,我们先看⼀下这个函数的样⼦

function * generator(){
    yield ***
    yield ***
}

ES6 新引⼊了 Generator 函数,可以通过 yield 关键字,把函数的执⾏流挂起,为改变执⾏流程提供了可能,从⽽为异步编程提供解决⽅案。 所以他的存在提供了让函数可以进⾏分步执⾏的能⼒。

举例:

//该函数和普通函数不同,在执⾏的时候函数并不会运⾏并且会返回⼀个分步执⾏对象
//该对象存在next⽅法⽤来让程序继续执⾏,当程序遇到yield关键字的时候会停顿
//next返回的对象中包含value和done两个属性,value代表上⼀个yield返回的结果
//done代表程序是否执⾏完毕
function * test(){
    const a = yield 1
    console.log(a, '==a==')
    const b = yield 2
    console.log(b, '==b==')
    const c = a+b
    console.log(c, '==c==')
}

//获取分步执⾏对象
const generator = test()

//输出
console.log(generator, '==generator==')

//步骤1 该程序从起点执⾏到第⼀个yield关键字后,step1的value是yield右侧的结果1
const step1 = generator.next()
console.log(step1, '==step1==')

//步骤2 该程序从var a开始执⾏到第2个yield后,step2的value是yield右侧的结果2
const step2 = generator.next()
console.log(step2, '==step2==')

//由于没有yield该程序从var b开始执⾏到结束
const step3 = generator.next()
console.log(step3, '==step3==')

我们查看程序的注释并且运⾏该程序看控制台的结果:

> test {<suspended>}        '==generator=='
    [[GeneratorLocation]]: VM171:1
    [[Prototype]]: Generator
    [[GeneratorState]]: "closed"
    [[GeneratorFunction]]: ƒ * test()
    [[GeneratorReceiver]]: Window
> {value: 1, done: false} '==step1=='
undefined '==a=='
> {value: 2, done: false} '==step2=='
undefined '==b=='
NaN '==c=='
> {value: undefined, done: true} '==step3=='

查看结果我们发现a和b的值不⻅了,c也是NaN虽然程序实现了分步执⾏,但是流程却出现了问题。
这是因为在分步执⾏过程中,我们是可以在程序中对运⾏的结果进⾏⼈为⼲预的,也就是说yield返回的结果和他左侧变量的值都是我们可以⼲预的。

接下来我们改造代码如下:

function * test(){
    const a = yield 1
    console.log(a, '==a==')
    const b = yield 2
    console.log(b, '==b==')
    const c = a+b
    console.log(c, '==c==')
}

const generator = test()
console.log(generator, '==generator==')

const step1 = generator.next()
console.log(step1, '==step1==')

const step2 = generator.next(step1.value)
console.log(step2, '==step2==')

const step3 = generator.next(step2.value)
console.log(step3, '==step3==')

当我们将代码改造成上⾯的结构之后我们发现控制台中的数据就正确了:

> test {<suspended>}  ==generator==
> {value: 1, done: false}  ==step1==
1  ==a==
> {value: 2, done: false}  ==step2==
2  ==b==
3  ==c==
> {value: undefined, done: true}  ==step3==

也就是说next函数执⾏的过程中我们是需要传递参数的,当下⼀次next执⾏的时候我们如果不传递参数,那么本次yield左侧变量的值就变成了undefined,所以我们如果想让yield左侧的变量有值就必须在next中传⼊指定的结果。

Generator能控制什么样的流程?

⾸先查看下列代码

function * test(){
    const a = yield 1
    console.log(a, '==a==')
    
    const res = yield setTimeout(function(){
        return 123
    },1000)
    console.log(res, '==res==')
    
    const res1 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve(456)
        },1000)
    })
    console.log(res1, '==res1==')
}

const step = test()
console.log(step, '==step==')

const step1 = step.next()
console.log(step1, '==ste1p==')

const step2 = step.next()
console.log(step2, '==step2==')

const step3 = step.next()
console.log(step3, '==step3==')

const step4 = step.next()
console.log(step4, '==step4==')

然后查看他的输出结果:

> test {<suspended>} '==step=='
> {value: 1, done: false} '==ste1p=='
undefined '==a=='
> {value: 867, done: false} '==step2=='
undefined '==res=='
> {value: Promise, done: false} '==step3=='
undefined '==res1=='
> {value: undefined, done: true} '==step4=='

根据调⽤情况我们可以⾃⾏测试,会发现输出结果时并没有任何的延迟,并且我们观察打印输出会发现普通变量可以直接在value中拿到,setTimeout位置我们拿到的值和回调函数内部的值完全不⼀样,⽽Promise对象我们可以拿到它本身。接下来我们展开查看Promise对象

↓ {value: Promise, done: false}  '==step3=='
      done: falsevalue: Promise
      > [[Prototype]]: Promise
        [[PromiseState]]: "fulfilled"
        [[PromiseResult]]: 456
    > [[Prototype]]: Object

我们发现Promise对象中是可以获取到内部的结果的,那么我们在Generator函数中能确保的就是,在分步过程中,能中使⽤Promise和普通对象都能拿到运⾏流程的结果,但是JavaScript中的setTimeout我们还是⽆法直接控制它的流程。

实现⽤Generator将Promise的异步流程同步化

通过上⾯的观察,我们可以通过递归调⽤的⽅式,来动态的去执⾏⼀个Generator函数,以done属性作为是否结束的依据,通过next来推动函数执⾏,如果过程中遇到了Promise对象我们就等待Promise对象执⾏完毕再进⼊下⼀步,我们这⾥排除异常和对象reject的情况,封装⼀个动态执⾏的函数如下:

//fn:Generator函数对象
function generatorFunctionRunner(fn){
    //定义分步对象
    let generator = fn()

    //执⾏到第⼀个yield
    let step = generator.next()

    //定义递归函数
    function loop(stepArg,generator){
        //获取本次的yield右侧的结果
        let value = stepArg.value

        //判断结果是不是Promise对象
        if(value instanceof Promise){
            //如果是Promise对象就在then函数的回调中获取本次程序结果
            //并且等待回调执⾏的时候进⼊下⼀次递归
            value.then(function(promiseValue){
                if(stepArg.done == false){
                    loop(generator.next(promiseValue),generator)
                }
            })
            
            return
        }
        
        //判断程序没有执⾏完就将本次的结果传⼊下⼀步进⼊下⼀次递归
        if(stepArg.done == false){
            loop(generator.next(stepArg.value),generator)
        }
        return
    }
    
    //执⾏动态调⽤
    loop(step,generator)
}

有了这个函数之后我们就可以将最初的三个setTimeout转换成如下结构进⾏开发

function * test(){
    const res1 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve('第⼀秒运⾏')
        },1000)
    })
    console.log(res1, '==res1==')
    
    const res2 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve('第⼆秒运⾏')
        },1000)
    })
    console.log(res2, '==res2==')
    
    const res3 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve('第三秒运⾏')
        },1000)
    })
    console.log(res3, '==res3==')
}
generatorFunctionRunner(test)

当我们通过上⾯的运⾏⼯具函数之后我们就可以在控制台看⻅每间隔1秒钟就输出⼀次

第⼀秒运⾏  ==res1==
第⼆秒运⾏  ==res2==
第三秒运⾏  ==res3==

经过这个yield修饰符之后我们惊喜的发现,抛去generatorFunctionRunner函数外,我们在Generator函数中已经可以将Promise的.then回调成功的规避了,yield修饰的Promise对象在运⾏到当前⾏时,程序就会进⼊挂起状态直到Promise对象变成完成状态,程序才会向下⼀⾏执⾏。这样我们就通过Generator函数对象成功的将Promise对象同步化了。这也是JavaScript异步编程的⼀个过渡期,通过这个解决⽅案,只需要提前准备好⼯具函数那么编写异步流程可以很轻松的使⽤yield关键字实现同步化。

关于Async和Await

经过了Generator的过渡之后异步代码同步化的需求逐渐成为了主流需求,这个过程在ES7版本中得到了提案,并在ES8版本中进⾏了实现,提案中定义了全新的异步控制流程。

//提案中定义的函数使⽤成对的修饰符
async function test(){
    await ...
    await ...
}
test()

查看代码结构之后我们发现他的编写⽅式与Generator函数结构很相似,提案中规定了我们可以使⽤async修饰⼀个函数,这样就能在该函数的直接⼦作⽤域中,使⽤await来⾃动的控制函数的流程,await 右侧可以编写任何变量或对象,当右侧是普通对象的时候函数会⾃动返回右侧的结果并向下执⾏,⽽当await右侧为Promise对象时,如果Promise对象状态没有变成完成,函数就会挂起等待,直到Promise对象变成fulfilled,程序再向下执⾏,并且Promise的值会⾃动返回给await左侧的变量中。async和await需要成对出现,async可以单独修饰函数,但是await只能在被async修饰的函数中使⽤。

有了await和async就相当于使⽤了⾃带执⾏函数的Generator函数,这样我们就不再需要单独针对Generator函数进⾏开发了,所以async和await逐渐成为主流异步流程控制的终极解决⽅案。⽽Generator慢慢淡出了业务开发者的舞台,不过Generator函数成为了向下兼容过渡期版本浏览器的候补实现⽅式,虽然在现今的⼤部分项⽬业务中使⽤Generator函数的场景⾮常的少,但是如果查看脚⼿架项⽬中通过babel构建的JavaScript⽣产代码,我们还是能⼤量的发现Generator的应⽤的,他的作⽤就是为了兼容不⽀持async和await的浏览器。

认识async函数

创建如下函数,执⾏并查看控制台输出:

async function test(){
    return 1
}
let res = test()
console.log(res)

输出控制台如下:

Promise {<fulfilled>: 1}
    [[Prototype]]: Promise
    [[PromiseState]]: "fulfilled"
    [[PromiseResult]]: 1

根据控制台结果我们发现其实async修饰的函数,本身就是⼀个Promise对象,虽然我们在函数中return的值是1,是使⽤了async修饰之后,这个函数运⾏时并没有直接返回1,⽽是返回了⼀个值为1的Promise对象。

接下来我们测试如下流程,先分析运⾏结果,猜测输出的顺序:

async function test(){
    console.log(3)
    return 1
}
console.log(1)
test()
console.log(2)

执⾏该流程之后发现输出的结果是1,3,2。很惊喜是不是!按照Promise对象的执⾏流程function被async修饰之后它本身应该变成异步函数,那么他应该在1和2输出完毕之后在输出3,但是结果却出⼈意料,这⼜⼀次打破了单线程异步模型的概念。

别急,冷静⼀下,先回想⼀下Promise对象的结构:

new Promise(function(){

}).then(function(){

})

我们在介绍Promise对象时,特别介绍了⼀下回调函数,并且强调他是⼀个极少数的既使⽤同步回调流程⼜使⽤了异步的回调流程的对象,所以在new Promise时的function是同步流程。现在介绍这个和刚才的输出有关系吗?当然有,接下来查看下⾯的逻辑,还是先猜测⼀下输出顺序:

async function test(){
    console.log(3)
    const a = await 4
    console.log(a)
    return 1
}

console.log(1)
test()
console.log(2)

我们发现奇怪的事情⼜发⽣了,控制台输出的顺序是1,3,2,4

按照我们⼀开始以为的流程,test函数应该是同步逻辑,那么3和4应该是连着输出的,他不应该会出现3在2之前,4在2之后输出的情况,这个同步逻辑和异步逻辑都说不过去,那么我们将当前的函数翻译⼀下,由于async修饰的函数会被解释成Promise对象,所以我们可以将其翻译成如下结构:

console.log(1)

new Promise(function(resolve){
    console.log(3)
    resolve(4)
}).then(function(a){
    console.log(a)
})

console.log(2)

看到这个Promise对象我们就豁然开朗,由于初始化的回调是同步的所以1,3,2都是同步代码⽽4是在resolve中传⼊的,then代表异步回调所以4应该最后输出。

综上所述,async函数中有⼀个最⼤的特点,就是第⼀个await会作为分⽔岭⼀般的存在,在第⼀个await的右侧和上⾯的代码,全部是同步代码区域相当于new Promise的回调,第⼀个await的左侧和下⾯的代码,就变成了异步代码区域相当于then的回调,所以就出现上⾯我们发现的灵异问题。

最终的setTimeout解决代码

经过了两个时代的变⾰,现在我们可以使⽤如下的⽅式来进⾏流程控制,不再需要依赖⾃⼰定义的流程控制器函数来进⾏分步执⾏,这⼀切的核⼼起源都是Promise对象的规则定义开始的,所以最终我们的解决⽅案如下。

async function test(){
    const res1 = await new Promise(function(resolve){
        setTimeout(function(){
            resolve('第⼀秒运⾏')
        },1000)
    })
    console.log(res1)

    const res2 = await new Promise(function(resolve){
        setTimeout(function(){
            resolve('第⼆秒运⾏')
        },1000)
    })
    console.log(res2)

    const res3 = await new Promise(function(resolve){
        setTimeout(function(){
            resolve('第三秒运⾏')
        },1000)
    })
    console.log(res3)
}

test()

总结

从回调地狱到Promise的链式调⽤到Generator函数的分步执⾏再到async和await的⾃动异步代码同步化机制,经历了很多个年头,所以⾯试中为什么经常问到Promise,并且重点沿着Promise对象深⼊的挖掘去问你各种问题,主要是考察程序员对Promise对象本身以及他的发展历程是否有深⼊的了解,同时也是在考察⾯试者对JavaScript的事件循环系统和异步编程的基本功是否⾜够的扎实。

Promise和事件循环系统并不是JavaScript中的⾼级知识,⽽是真正的基础知识,所以所有⼈想要在⾏业中更好的发展下去,这些知识都是必备基础,必须扎实掌握。我们未来对⾃⼰的定位是软件开发/研发⼯程师,并不是码农~

原文链接:https://juejin.cn/post/7346763303430389769 作者:网界轩_前端匠人坊

(0)
上一篇 2024年3月16日 下午4:05
下一篇 2024年3月16日 下午4:15

相关推荐

发表回复

登录后才能评论