事件循环 | 青训营笔记
这是我参与「第五届青训营 」伴学笔记创作活动的第 13 天
一、任务队列
JS是单线程的
JS这门脚本语言的使命就是为了处理页面中被触发的事件、与用户进行交互、操作DOM。
试想一下:如果js被设计成了多线程,那么会造成很复杂的线程同步问题,在多个线程中操作DOM元素势必会造成极大的复杂性和不确定性,所以JS从诞生之初就是单线程,单线程也可以完全胜任JS所需要处理的任务
什么是单线程
在js执行环境中负责执行的代码的线程只有一个 单线程的优点:更安全,更简单 单线程的缺点:遇到耗时的任务(具体来说就是某一行代码),后面的任务都要去排队等待,导致整个程序的执行会被拖延,出现假死的情况。这种情况叫阻塞,对于用户而言页面会有卡顿。
如何解决好事任务阻塞执行的问题,js将任务执行模式划分为两种
- 同步模式
- 异步模式 : 解决阻塞问题,避免卡死,比如浏览器端的ajax操作,node.js中的文件读写
同步模式
指的是代码任务依次执行,程序的执行顺序和我们的代码编写顺序完全一致,在单线程情况下,大多数任务都会以同步模式去执行,同步不是同时执行,是排队执行
以下代码执行过程:
console.log('global begin')
function bar(){
console.log('bar task')
}
function foo(){
console.log('foo task')
bar()
}
foo()
console.log('global end')
js内部调用栈Call stack压入一个匿名调用anonymous,匿名调用可以理解为,把全部代码放到匿名函数去执行, 然后开始逐行执行每行代码 从第一行开始,把console.log压入调用栈去执行 ,执行过程中控制台打印,执行完毕弹出调用栈,代码继续往下执行, 函数的声明和变量的声明都不会产生任何调用,执行会继续往下 foo函数的调用,压入调用栈,开始执行foo函数,再执行最后一句 整体代码全部结束,调用栈清空掉 调用栈通俗解释是js在执行引擎中维护了一个正在工作执行的工作表,记录当前在工作的事情,当工作表中所有的任务都被清空过后,这轮的工作就算结束
异步模式
- 异步模式的api 不会等这个任务结束才开始下个任务,对于耗时操作都是开启过后就立即往后执行下个任务,耗时任务的后续逻辑会通过回调函数的方式定义,内部耗时任务执行过后会自动执行传入的回调函数
- 没有异步模式单线程的javaScript语言就无法同时处理大量耗时任务
- 异步调用涉及到的内容:
- Web APIs : 内部api的环境
- Event loop: 事件循环,调用栈Call stack没有工作发挥作用,只做一件事情,负责监听调用栈Call stack和消息队列Queue
- Queue: 消息队列,也有人称为 回调队列
- Call stack: 调用栈
执行代码:
console.log('global begin')
setTimeout(function timer1(){
console.log('timer1 invoke')
},1800)
setTimeout(function timer2(){
console.log('timer2 invoke')
setTimeout(function inner(){
console.log('inner invoke')
},1000)
},1000)
console.log('global end')
- 加载整体代码
- 调用栈Call stack 压入匿名的全局调用anonymous,以此执行每行代码
- console.log同步api压入栈 执行打印弹出栈
setTimeout压入栈,函数内部是异步调用,内部timer函数,倒计时器timer1,倒计时器单独工作,不受js线程影响
开启计时器后,调用完成,函数出栈,在遇到一个setTimeout压入栈,开启另外一个倒计时器
调用完成弹栈,console.log调用,执行打印,弹出,整体的匿名调用完成,调用栈被清空掉
event loop 监听到调用栈所有任务结束,事件循环从消息队列
当中取出第一个函数压入调用栈,此时消息队列为空,执行暂停下来
倒计时timer2先结束,timer2被放入消息队列第一位,timer1结束后,timer2函数放入消息队列第二位
事件循环检测到消息队列发生了变化,把消息队列的第一个timer2函数取出来压到调用栈去执行,打印console.log,遇到setTime把inner放到api环境去执行,执行完毕,弹栈,
压入timer2,打印,调用完毕,弹栈
inner计时器结束,把inner函数放到消息队列,事件循环监听到变化,把它压入调用栈,执行打印,弹栈
消息队列和调用栈都没有需要执行的任务,整体的代码结束
异步调用过程总结:
- 调用栈相当于正在执行的工作表,消息队列相当于代办的工作表
- js执行引擎先去执行调用栈中所有的任务,再通过事件循环,从消息队列中,再取任务到调用栈执行,整个过程随时都可以从消息队列中放入任务,这些任务会排队等事件循环
- JavaScript是单线程,浏览器不是单线程的,通过js调用的某些内部api不是单线程的,就像倒计时器,内部有单独的异步调用线程去负责倒数,时间到了,把回调函数放入消息队列,我们所说的单线程是执行我们的代码的线程,内部的api会用单独的线程去执行这些等待的操作
- 同步和异步,都不是写代码的方式,而是我们运行环境提供的API是以同步或异步模式的方式工作
- 异步操作会在将来的某个时间点触发一个函数调用
二、Promise
Promise 的含义
Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了 Promise 对象。
所谓 Promise ,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise 对象有以下两个特点。
-
对象的状态不受外界影响。 Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是 Promise 这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
-
一旦状态改变,就不会再变,任何时候都可以得到这个结果。 Promise 对象的状态改变,只有两种可能:从 pending 变为 fulfilled 和从 pending 变为 rejected 。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对 Promise 对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
注意,为了行文方便,本章后面的 resolved 统一只指 fulfilled 状态,不包含 rejected 状态。
有了 Promise 对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外, Promise 对象提供统一的接口,使得控制异步操作更加容易。
Promise 也有一些缺点。首先,无法取消 Promise ,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数, Promise 内部抛出的错误,不会反应到外部。第三,当处于 pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
基本用法
ES6 规定,Promise 对象是一个构造函数,用来生成Promise 实例。 下面代码创造了一个 Promise 实例。
const promise = new Promise(function(resolve, reject) {
// ... some code
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是resolve 和reject 。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。
resolve 函数的作用是,将 Promise对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject函数的作用是,将 Promise对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
Promise 实例生成以后,可以用 then方法分别指定resolved状态和 rejected状态的回调函数。
then 方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为 resolved时调用,第二个回调函数是 Promise对象的状态变为rejected时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受 Promise 对象传出的值作为参数。
下面是一个 Promise 对象的简单例子。
function asyncArea(length) {
return new Promise((resolve, reject) => {
// 模拟异步请求
setTimeout(() => {
if (length > 0) resolve(length * length)
else reject(new Error(`invalid length: ${length}`))
}, 500)
})
}
在这里我们使用promise实现异步请求面积的方法
上面这个方法接受一个数字类型的参数 length,通过 setTimeout 来模拟异步请求,500ms后,如果 length大于等于0,则 resolve 返回 length 的平方,否则 reject 返回错误信息,通过下面的例子我们来看一下具体的调用方法。
asyncArea(1).then(result => {
console.log(result)
}).catch(error => {
console.trace(error)
})
// 触发异常
asyncArea(-1).then(result => {
console.log(result)
}).catch(error => {
console.trace(error)
})
// 链式调用
let start = Date.now()
asyncArea(1)
// 返回了一个新的Promise,可以在下一个then中获取,result === 1
.then(result => asyncArea(result + 1))
// result === 4
.then(result => asyncArea(result + 1))
.then(result => {
// 前一个then返回的新Promise,result === 25
console.log(result)
// 耗时是三个请求的累加
console.log(`cost ${Date.now() - start}ms`)
})
通过上面的例子我们可以看到,Promise可以很好的解决回调地狱的问题
上面的链式调用适用于需要串行计算的场景,下一步的请求需要依赖上一步的结果,总的耗时是每个请求的累加。有时候我们的多个异步请求是没有相互依赖的,此时如果串行计算的话会增加无谓的耗时,Promise 有一个 all 方法,可以批量并行执行异步请求,等所有的请求都结束后再统一返回
let start = Date.now()
Promise.all([
asyncArea(1),
asyncArea(2),
asyncArea(3),
asyncArea(4),
asyncArea(5)
]).then(result => {
console.log(result)
console.log(`cost ${Date.now() - start}ms`)
})
实现原理:
Promise.all = function (promises) {
let arr = [],
//计数器
count = 0
//返回一个promise
return new Promise((resolve, reject) => {
//需要执行的promises数组循环
promises.forEach((item, i) => {
//Promise.resolve 是降item转为promise(就是用来兼容玩意传入的不是promise)。
//这里其实就是执行了传入数组中的promise
Promise.resolve(item).then(res => {
//存储结果
arr[i] = res
//记录promise 完成的数量
count += 1
判断完成的数量和 实际的promise数组的长度。相等就返回arr结果
if (count === promises.length) resolve(arr)
}).catch(err){
//只要有一个promise失败就返回
reject(err)
}
})
})
}
三、async/await
Promise 通过 then 来进行异步请求虽然改善了回调的问题,但还是不够优雅,好在现在我们可以通过 async/await 语法,使用串行的语法进行异步调用,下面我们来改写一下上面的例子:
async function test_1() {
let start = Date.now()
let result_1 = await asyncArea(1)
let result_2 = await asyncArea(result_1 + 1)
let result_3 = await asyncArea(result_2 + 1)
console.log('test_1', result_3)
console.log(`cost ${Date.now() - start}ms`)
}
test_1()
async function test_2() {
let start = Date.now()
let result = await Promise.all([
asyncArea(1),
asyncArea(2),
asyncArea(3),
asyncArea(4),
asyncArea(5)
])
console.log('test_2', result)
console.log(`cost ${Date.now() - start}ms`)
}
test_2()
使用 async/await 改写之后,我们的异步请求更加优雅,变得更接近符合我们习惯的串行代码,它有以下特点需要注意。
await只能出现在async修饰的函数中,普通函数中无效async函数隐式返回一个Promise对象,最后return的返回值,相当于Promise中resolve的值,所以可以认为async函数是Promise的语法糖await后面的函数请求需要返回Promise,因为async返回的也是Promise,所有也可以await一个async函数await需要等待后面的Promise返回结果(resolve)之后,才会继续执行后面的代码