JavaScript异步学习——一文搞懂async、Promise、事件循环

JS中的同步和异步

JavaScript是解释执行代码。

对于同步代码,它会一句一句执行,等到上一句执行完毕才会执行下一句。

对于异步代码,它会后台执行,不等待异步代码执行完毕就执行下一句。执行完毕时,会额外执行一些其他代码,告诉我们这个异步代码执行完毕了。这个“其他代码”就是回调函数中的代码。

通常,JS实现异步的方式是将回调函数的名字(或者匿名函数,包括箭头函数)作为参数传递给一个函数,当这个函数执行完毕后,会执行这个回调函数。这样就造成了如下现象:某函数执行完之后会执行另一段代码。

但是这样做显然难以维护,并且在多个异步函数嵌套时,需要在每个函数中进行异常处理。

Promise的概念

JS为了解决之前提到的问题,提出了新规定:每个异步函数的返回值都是一个Promise对象。

Promise是一种JavaScript内置对象类型,它记录了产生它的那个异步函数的完成状态:fulfilled已完成、rejected被拒绝、pending等待结果。当状态处于fulfilledthen()方法的第一个参数被执行(它的参数是一个回调函数,fulfilledValue被传入给回调函数作为参数);当状态处于rejectedcatch()方法的参数或者then()方法的第二个参数被执行,rejectReason被当作参数传入,通常是一个异常。同时,then()catch()方法的返回值也是Promise对象。

  • fulfilledValue指的是异步函数里的代码正常执行的返回值(它本应该的返回值,而不是对应的Promise对象)。
  • rejectReason指的是异步函数里的代码不能正常执行的原因。
  • then()方法可以接收两个参数,第一个参数是异步函数兑现时执行的回调函数,第二个参数是异步函数被拒绝时执行的回调函数,可以为空。

链式调用

由于Promise对象的方法的返回值都是Promise对象,因此可以在Promise对象上实现链式调用:

const p = someAsyncFunction();
p.then(somethingB).catch(somethingC);

除了someAsyncFunction()自己执行过程中产生的异常以外,somethingB执行过程中产生的异常也会在somethingC中解决,因为p.then()返回一个Promise对象。

易混淆的概念fulfilled、settled、resolved

除了上面提到的三个状态,和Promise相关的还有两个热门词:

  • settled表示fulfilled或者rejected,意思是Promise或者说异步函数有了一个结果,即使是被拒绝的结果。
  • resolved表示fulfilled或者被绑定到另一个Promise,意思是Promise要么得到结果,要么知道找谁要结果,即使那个“谁”的状态可能是pending!处于resolved状态的Promise如果不是fulfilled状态,则不能主动settle,只能听另一个Promise的。

async和await

我们知道JS现在是如何实现异步的了,但是我们不知道如何创建一个异步函数。

在一个普通函数定义前加async关键字,使得这个函数变成异步的。它会自动返回一个Promise对象,不用你显示地写:你的return关键字后跟着的变量变成fulfilledValue,在函数代码执行正常且结束时传递给then()方法的参数;你的函数代码执行过程中的异常将传递给catch()方法的参数。

异步函数还有一个特点:在异步函数的里面可以用await关键字等待你代码中用到的其他异步函数的返回,使得那个异步调用表现得像同步一样。

async function BigAsyncFunction() {
	let fulfilledValue = await smallAsyncFunction();
	console.log(fulfilledValue);
}

await关键字会等待它后面的函数的Promise变成settled状态后再返回给前台,然后运行后面的代码。得到的返回值不再是Promise了,而是fulfilledValue或者rejectReason

对于这样的情况,如何处理smallAsyncFunction()执行过程中的异常呢?这就可以用原始的try { } catch { }方法了,或者也可以使BigAsyncFunction()直接返回fulfilledValue,这会在非await调用时变成一个Promise

注意,await关键字只能出现在异步函数或者JavaScript的module当中!

Promise对象的构造

可以单独构造Promise对象,而不等着JS自动把异步函数的返回值封装成一个Promise
Promise的构造函数接收的参数是一个有两个参数的函数,这个函数中的内容会立即执行(它是同步代码)。

这个函数接收两个参数,都是回调函数,都只接受一个参数。第一个是resolve,即被解决时应该执行的函数,第二个是reject,即被主动拒绝时执行的函数。并且,调用这两个回调函数的代码实际上都是执行了这样的操作:把Promise的状态修改成相应的状态,并用将对应的回调函数注册到微任务队列中。你会发现,调用回调函数时,这个回调函数实际上并不会立即执行,它只是被注册了。实际上,它起到一个通知的作用,通知事件循环,有一个异步操作结束了,可以准备执行它的回调函数了。

我们知道resolved状态包括已兑现或者需要看另一个Promise的状态两个状态。已兑现很好理解,那么如果是看另一个Promise的状态,而这个Promise在稍后被拒绝了,也要执行resolve参数中的代码吗?是的!

Promise使用技巧

Promise的合并

多个Promise一一进行结果处理很累,可以在条件满足的情况下把一个列表的Promise合并。

Promise.all()

这个函数接收一个Promise数组最为参数,它收集并监听这些Promise对象的状态,并且返回一个Promise

  • 当参数中的所有Promise都是fulfilled时,它的状态才是fulfilled,返回值是fulfilledValue的数组;
  • 当参数中的Promise中有一个时rejected,它就是rejected,返回值是rejectReason。

Promise.any()

接收一个Promise数组作为参数,并返回一个Promise

  • 当参数中的所有Promise都被拒绝时它才被拒绝;
  • 任何一个Promise被实现,它立即被实现。

Promise.allSettled()

这些Promise都有结果了才兑现。

Promise.race()

这些Promise竞争,任何一个有结果了,返回的Promise就是这个结果。

Promise的解决和拒绝

Promise.reject()

传入一个理由,返回的Promise以这个理由被拒绝。

Promise的解决

三种方法:

  1. 调用构造Promise对象时传入的resolve参数(是一个回调函数)。
  2. Promise.resolve():返回一个Promise,这个Promise需要听参数Promise的话(如果传的参数是一个Thenable对象),或者以参数的值兑现(传入的参数是一个非Thenable对象)。当不知道一个东西是不是Promise对象的时候,把他作为参数传递给Promise.resolve(),可以把它转化成Promise对象。
  3. 定义的异步函数直接返回一个 Promise对象(通常是其他异步函数的非await调用),例如
async function FuncA() {
	return AsyncFuncB();
}

FuncA()返回的Promise必须听AsyncFuncB()的了。

注意,在Promise的构造函数中调用它的参数resolve()方法后,后面的代码仍会继续执行(后续的resolve()reject()除外),直到产生异常。
并且,Promise.resolve()Promise构造函数的参数中的resolve()不同!后者是回调函数的调用,前者是让新建一个会听从otherPromisePromise对象(或者以某个值被解决的Promise对象)!

实现一个基于Promise的异步API

构造一个Promise对象,并在一个普通函数中直接返回。
例子:

function alarm(person, delay) {
  return new Promise((resolve, reject) => {
    if (delay < 0) {
      // 拒绝
      throw new Error("Alarm delay must not be negative");
    }
    // 听另一个Promise的,这个Promise是异步函数setTimeout()函数的返回值
    window.setTimeout(() => {
      resolve(`Wake up, ${person}!`);
    }, delay);
  });
}

使用这个自定义的Promise的API:

alarm(name.value, delay.value)
    .then((message) => (output.textContent = message))
    .catch((error) => (output.textContent = `Couldn't set alarm: ${error}`));

浏览器的事件循环

这里深入探讨浏览器怎么规划异步代码、回调函数和同步代码。

浏览器的主线程负责执行调用栈中的代码,其他线程执行异步任务,如I/O操作、定时器等等。

现在有一段代码,主线程去一行一行解释执行。对于同步操作,会压入调用栈,阻塞主线程,执行完后弹栈;对于异步操作,交给其他线程进行,然后去执行下一行代码。

而执行异步操作的其他线程,会在执行完毕(成功或失败)后,通知主线程,并将该操作对应的回调函数加入任务队列。

研究上述流程,我们发现,我们不知道那些加入任务队列的回调函数啥时候执行。实际上,浏览器采用“事件循环”机制,这个机制规定了啥时候执行同步代码,啥时候执行回调函数。

主线程依旧是执行调用栈中的代码,当全部执行完后,调用栈为空,这时候就去查看任务队列中是否有回调函数,如果有,将他压入调用栈,然后执行;否则就等着,或许用户会执行一个鼠标操作,这时就会有事件处理函数(也属于回调函数)被加入任务队列,然后被主线程发现,又或许某个正在执行的异步操作结束,其回调函数被加入任务队列,以此类推。

以上只是宏观地看“事件循环”。实际上,任务队列分为两类:微任务和宏任务队列。对于两类任务,又有不同的处理时机。
每当调用栈为空时,取出宏任务队列队头的任务,然后压入调用栈执行。这个过程可能产生其他宏任务和微任务,按照后进后出的原则加入对应队列的队尾。然后,这个宏任务自身执行完毕,弹栈。随后,微任务队列中的所有任务都会被执行,包括在执行微任务过程中添加的新微任务——微任务队列会被清空。这个时候,就和我们最开始的状态符合了。然后主线程会进行页面渲染,之后进入下一个循环。这一整个过程是事件循环的一次循环。

对于我们自己写的看输出顺序的代码来说:整段代码是一个脚本,属于宏任务。它添加的微任务属于本次循环,所以在这段代码中的同步代码执行完之后,会执行这些微任务,然后渲染,然后到了下一个循环,拿出一个宏任务,也是我们自己写的代码中添加的,通常是setTimeout。

浏览器的同源窗口更有可能共享一个事件循环,不同源的页面之间有互不干扰的事件循环。

宏任务

  • window上的事件的处理函数(鼠标、键盘、输入等事件),注意,通过EventTarget.dispatchEvent()方法触发的事件是同步的,不属于宏任务
  • 一段< script >标签包裹的JS脚本
  • 定时器(setTimeout、setInterval)

这使得定时器的回调函数不一定在时间到达后立即触发。

微任务

  • promise
  • async / await(其实就是promise+迭代器。async函数中,await之前的代码相当于Promise构造函数中的代码,是立即执行的;await表示等待promise状态变settled然后执行对应的回调,所以await之后的代码是微任务)

微任务仅来自代码执行,用户操作都是宏任务。

node中的事件循环

和浏览器的差别在于,它的一次循环的最开始是处理微任务的,之后再处理宏任务,然后还会清空微任务队列。
它多了一个process.nextTick()方法,这个方法作用于下一次循环的开头,属于微任务。这是一个特殊的微任务,它会加入下一次循环的微任务的队头,即使它之前队列中已经有微任务。
例子:

function asyncFunc1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            process.nextTick(() => {
                console.log('tick 2');
            });
            resolve();
            console.log('macro 1');
        }, 0)
    })
}

Promise.resolve().then(() => {
    console.log('micro 1')
    setTimeout(() => console.log('macro 2'), 0);
});

(async function() {
    console.log('sync 3');
    await asyncFunc1();
    console.log('micro 4');
})();

process.nextTick(() => {
    console.log('tick 1');
});

console.log('sync 5');

这段代码在node环境中的输出:

sync 3
sync 5
tick 1
micro 1
macro 1
tick 2
micro 4
macro 2

参考资料:https://juejin.cn/post/7209698674905382973

Vue中的事件循环

Vue维护一个虚拟DOM树,并在一次循环期间保存对DOM的修改,在虚拟DOM上进行合并,然后在事件循环的末尾、浏览器重新渲染之前,将虚拟DOM上的改动告诉浏览器,这样浏览器就一次性为多个DOM操作进行重排、重绘,提升了性能。
Vue中也有一个特殊的钩子函数Vue.nextTick(),它的回调函数在事件循环的下一个循环的开头执行,也属于特殊的微任务。也就是说,nextTick()中的代码将在最近一次DOM渲染操作之后立刻执行 。

总结

浏览器的事件循环的一开始是执行一个宏任务;而node和Vue的事件循环的开始是清空微任务队列,然后执行一个宏任务,nextTick会在这个宏任务执行完之后立刻执行。

手写代码部分

用setTiemout实现setInterval

运用了递归和闭包的概念。
用一个函数包装调用setTimeout的过程,并在setTimeout的回调函数中调用这个函数。这样,时间一到,真正的执行函数和下一次setTimeout操作都会准备被执行。
在消除定时器时用到了闭包,我的间隔定时器函数有一个局部变量,用于保存最新被设置的倒计时计时器,然后定义一个用于消除最新定时器的函数,并返回它。用户调用这个函数时,它的词法上下文还被保存着(因为这是一个闭包),所以它能访问到定时器变量,然后可以顺利消除它。

const myInterval = (func, ms) => {
	let to;
	function doTimeout() {
		to = setTimeout(() => {
			func();
			doTimeout();
		}, ms);
	}
	doTimeout();
	return function setStop() {
		clearTimeout(to);
	}
}

let stop = myInterval(() => console.log(1), 2000);

stop();

手写Promise.all()

运用了Promise.resolve(value or otherPromise)函数,创建一个新的promiseotherPromise的话,然后绑定自定义的回调函数:如果otherPromise解决,那么把它的结果加入我的数组,并判断这是不是最后一个成功的,如果是我就解决了;如果otherPromise拒绝,那么我就拒绝了。

function promiseAll(parr) {
    let result = [], len = parr.length;
    return new Promise((resolve, reject) => {
        for (let p of parr) {
        	//依次测试传入的参数(转化为promise)是否是成功的
            Promise.resolve(p).then(res => {
            	// res是单个promise的结果
                result.push(res);
                //加入到promise.all()的结果数组中
                if (result.length == len) {
                	//如果相等,说明都成功了,可以走成功resolve
                    resolve(res);
                }
            }, err => {
            	//只要有一个失败了,直接走失败reject
                reject(err);
            })
        }
    })
}

手写Promise.any()

我的解决和拒绝反过来就可以了。

手写finally()

运用了this执行时绑定的知识,并且把then()的两个参数都传入了,保证任何settled状态都会执行回调函数。
注意,finally()也返回一个Promise对象,所以不能简单执行回调函数。

Promise.prototype.myFinally = function(cb) {
    return this.then(//谁调用finally,this就是谁
        value => Promise.resolve(cb()),
        error => Promise.resolve(cb())
    );
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值