JS中的同步和异步
JavaScript是解释执行代码。
对于同步代码,它会一句一句执行,等到上一句执行完毕才会执行下一句。
对于异步代码,它会后台执行,不等待异步代码执行完毕就执行下一句。执行完毕时,会额外执行一些其他代码,告诉我们这个异步代码执行完毕了。这个“其他代码”就是回调函数中的代码。
通常,JS实现异步的方式是将回调函数的名字(或者匿名函数,包括箭头函数)作为参数传递给一个函数,当这个函数执行完毕后,会执行这个回调函数。这样就造成了如下现象:某函数执行完之后会执行另一段代码。
但是这样做显然难以维护,并且在多个异步函数嵌套时,需要在每个函数中进行异常处理。
Promise的概念
JS为了解决之前提到的问题,提出了新规定:每个异步函数的返回值都是一个Promise对象。
Promise
是一种JavaScript内置对象类型,它记录了产生它的那个异步函数的完成状态:fulfilled
已完成、rejected
被拒绝、pending
等待结果。当状态处于fulfilled
,then()
方法的第一个参数被执行(它的参数是一个回调函数,fulfilledValue
被传入给回调函数作为参数);当状态处于rejected
,catch()
方法的参数或者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的解决
三种方法:
- 调用构造
Promise
对象时传入的resolve
参数(是一个回调函数)。 Promise.resolve()
:返回一个Promise
,这个Promise
需要听参数Promise
的话(如果传的参数是一个Thenable
对象),或者以参数的值兑现(传入的参数是一个非Thenable
对象)。当不知道一个东西是不是Promise
对象的时候,把他作为参数传递给Promise.resolve()
,可以把它转化成Promise
对象。- 定义的异步函数直接返回一个
Promise
对象(通常是其他异步函数的非await
调用),例如
async function FuncA() {
return AsyncFuncB();
}
FuncA()
返回的Promise
必须听AsyncFuncB()
的了。
注意,在Promise
的构造函数中调用它的参数resolve()
方法后,后面的代码仍会继续执行(后续的resolve()
和reject()
除外),直到产生异常。
并且,Promise.resolve()
和Promise
构造函数的参数中的resolve()
不同!后者是回调函数的调用,前者是让新建一个会听从otherPromise
的Promise
对象(或者以某个值被解决的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)
函数,创建一个新的promise
听otherPromise
的话,然后绑定自定义的回调函数:如果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())
);
};