你所不知道的JavaScript④--异步任务与事件
关键词
事件机制、Promise、Generator、async/await、事件循环
事件机制
事件的触发过程?事件代理?
简介
事件流是一个事件沿着特定数据结构传播的过程。冒泡和捕获是事件流在DOM中的两种不同的传播方式。
事件流的三个阶段
- 事件捕获阶段
- 目标阶段
- 事件冒泡阶段
事件捕获
事件捕获通俗的理解就是鼠标点击或者触发dom事件的时候,浏览器从根节点开始由外到内的进行事件传播,即点击了子元素。如果父元素通过捕获方式注册了对应的事件的话,会先触发父元素绑定的事件
事件冒泡
事件冒泡和事件捕获相反,顺序是由内到外直到根节点
无论是事件捕获还是冒泡,都有一个共同的特征就是事件传播
事件流阻止
在一些情况下要阻止事件流的传播,阻止默认动作的发生
event.preventDefault()
取消事件对象的默认动作以及继续传播event.stopPropagation()/event.cancelBubble = true
阻止事件冒泡在不同浏览器的处理
- 在IE下使用
event.returnValue= false
, - 在非IE下则使用
event.preventDefault()
进行阻止preventDefault
与stopPropagation
的区别 preventDefault
告诉浏览器不用执行与事件相关联的默认动作(如表单提交)stopPropagation
是停止事件继续冒泡,但是对IE9以下的浏览器无效事件注册
- 通常我们使用
addEventListener
注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为false
。useCapture 决定了注册的事件是捕获事件还是冒泡事件 - 一般来说,我们只希望事件只触发在目标上,这时候可以使用
stopPropagation
来阻止事件的进一步传播。通常我们认为stopPropagation
是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation
同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件注意
addEventListener
需要销毁,onClick
则不需要因为每次都替换事件委托
- 优化性能,当子节点过多的时候给父元素绑定事件通过冒泡只执行一次事件节省内存并且不需要给子节点注销事件。
事件的兼容写法
ie的event和非ie的event不太一样,也不能一起用
1 | function gete(e){ |
Promise
谈到promise的时候,除了将他解决的痛点以及常用的api之外,最好进行拓展吧eventloop带进来好好讲一下。微任务,任务的指向顺序,如果看过promise源码,最好可以谈一谈原生promise是如何实现的,还有就是promise的链式调用
- promise是es6新增的语法,解决了回调地狱的问题
- promise可以被看成是一个状态机,初始是
pending
可以通过函数resolve
和reject
将状态转变为resolved
orrejected
状态。状态一旦发生改变就不能再次变化。 - then函数会返回一个promise实例,并且该返回值是一个新的实例而不是之前的实例。因为promise规范规定除了pending状态,其他状态是不能改变的。如果返回的是一个相同实例的话,多个then调用就失去了意义。对于then来说,本质上可以看成是flatMap
promise的基本情况
简单来说他是一个容器,里面保存着某个未来才会结束的事件,通常是异步操作的结果。从语法上面来说,promise是一个对象,从他可以获取异步操作的消息。
一般 Promise 在执行过程中,必然会处于以下几种状态之一。
- 待定(pending):初始状态,既没有被完成,也没有被拒绝。
- 已完成(fulfilled):操作成功完成。
- 已拒绝(rejected):操作失败。关于 Promise 的状态流转情况,有一点值得注意的是,内部状态改变之后不可逆,你需要在编程过程中加以注意。文字描述比较晦涩,我们直接通过一张图就能很清晰地看出 Promise 内部状态流转的情况
待定状态的promise对象指向的话,最后要么会通过一个值完成,要么会通过一个原因被拒绝。当其中一种情况发生的时候,我们用promise的then方法排列起来的相关处理程序就会被调用。因为最后
Promise.prototype.then
和Promise.prototype.catch
方法返回的是一个Promise
所以它们可以继续被链式调用。then catch
- thencatch都会返回一个新的promise
- catch不管放在哪里都能捕获上层未捕获的错误
- 不写默认返回
return Promise.resolve(undefined)
也是成功回调 - 直接return 一个error对象不会抛出错误 所以不会被catch捕获
- 返回的值不能是promise本身 否则死循环
- then可以接收两个参数的,在某些时候你可以认为catch是then第二个参数的简便写法。
finally
- 不管最后的状态如何都会执行
- 不接受任何参数 所以在finally中是没办法知道最终的状态的
- 默认返回上一次的promise对象值,如果抛出的是一个异常则返回异常的promise对象
new Promise
(大坑)
先看一段代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14const promise = new Promise((resolve, reject) => {
console.log(1);
resolve();
reject()
console.log(2);
})
promise.then(() => {
console.log(3);
},() => {
console.log("失败的状态")
})
console.log(4);
正确答案是1243
这是为什么呢?因为我们会忽略一个问题,就是在new Promise
的时候,构造器的方法就已经开始执行了,虽然我们一般提倡用函数封装之后return
出去这个promise对象。所以就是先执行new Promise
里面的代码 输出12然后状态到resolved
然后执行同步的4 最后因为成功的回调到3
promise的静态方法
all方法
- 语法:
Promise.all(iterable)
- 参数:一个可迭代对象,例如Array(括号里面放数组)
- 描述:此方法对于汇总多个promise的结果很有用,在es6中可以将多个
Promise.all
异步请求并发操作,返回的结果一般有下面两种情况。- 当所有结果成功返回按照请求顺序返回成功结果
- 当其中一个方法失败就进入失败方法。
- 业务场景
1 | // 在一个页面中需要加载获取轮播列表、获取店铺列表、获取分类列表这三个操作,页面需要同时发出请求进行页面渲染,这样用 `Promise.all` 来实现,看起来更清晰、一目了然。 |
all如果有一个抛出了异常会如何处理。
all和race传入的数组中如果会有抛出异常的异步任务,那么只有最先抛出的错误会被捕获。并且是被then的第二个参数或者后面的catch捕获,但并不影响数组中其他的异步任务的执行。
all是并发的还是串行的
并发的。不过promise.all().then()
结果中数组的顺序和Promise.all()
接收到的数组顺序一致
all的并发限制
Promise.all可以保证,promises数组中所有promise对象都达到resolve状态,才执行then回调。
这时候考虑一个场景:如果你的promises数组中每个对象都是http请求,或者说每个对象包含了复杂的调用处理。而这样的对象有几十万个。
那么会出现的情况是,你在瞬间发出几十万http请求(tcp连接数不足可能造成等待),或者堆积了无数调用栈导致内存溢出。
这时候,我们就需要考虑对Promise.all做并发限制。
Promise.all并发限制指的是,每个时刻并发执行的promise数量是固定的,最终的执行结果还是保持与原来的Promise.all一致。
1 | function asyncPool(poolLimit, array, iteratorFn) { |
allSettled
方法
Promise.allSettled
的语法及参数跟Promise.all
类似.不同在于它执行完之后不会失败,会按顺序返回每个promise的状态- 案例
1 | const resolved = Promise.resolve(2); |
any方法(还在草案不能使用)
- 语法:
Promise.any(iterable)
- 参数: iterable 可迭代的对象,例如 Array。
- 描述:any返回一个promise 只要参数promise实例中有一个变成fulfilled状态,最后any返回的实例就返回fulfilled状态,如果全部都是rejected,就返回rejected状态
1
2
3
4
5
6
7
8const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const anyPromise = Promise.any([resolved, rejected]);
anyPromise.then(function (results) {
console.log(results);
});
// 返回结果:
// 2
race方法
- 语法:
Promise.race(iterable)
- 参数: iterable 可迭代的对象,例如 Array。
- 描述: race方法返回一个promise,只要参数的promise之中有一个实例率先改变状态,那么race方法的返回状态就跟着改变。那个率先改变的promise实例的返回值就传递给race方法的回调函数。
- 业务场景:图片加载 超时判断
1 | //请求某个图片资源 |
Generator
generator是es6新增的语法,和promise一样可以用来异步编程,最大的特点是它可以利用yield和next分段执行。
function *foo(x)
使用号来声明该函数是一个生成器函数,``的位置比较随意。yield
关键字用来实现分段执行,它的意思是产出,当生成器函数遇到yield的时候会暂停并把他后面的表达式抛出去。(注意yield可以不写在生成器中)next
表示将代码的控制权还给生成器函数
1 | function *foo(x) { |
过程:
- 首先执行第一个next的时候传参会被忽略 因为赋值的时候已经传了参 所以参数就是5 且到第一个yield停止。返回后面的结果 就是6 此时x为5
- 来到第二个next 传入的参数覆盖上一次yield的结果 所以此时y为
12*2=24
那么返回的结果就是8 - 第三个next 传入的参数覆盖上一次yield的结果 所以此时z为13 那么总体就是
5+24+13=42
简单实现generator
1 | // cb 也就是编译过的 test 函数 |
async/await
Generator的语法糖,有更好的语义性和适用性,返回的是promise
- await和promise一样更多是笔试题
- await相比直接使用promise来说 优势在于处理then的调用链,能够更清晰的写出代码。缺点在于await可能导致性能问题。因为await会阻塞代码。也许之后的异步代码并不依赖于前者,但仍然需要等待前者完成,导致代码失去了并发性,此时更应该使用
Promise.all
- 一个函数如果加上了async 那么就会返回一个promise案例
async => *
await => yield
1
2
3
4
5
6
7
8
9
10var a = 0
var b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
a = (await 10) + a
console.log('3', a) // -> '3' 20
}
b()
a++
console.log('1', a) // -> '1' 1 - 首先函数b执行 但是遇到了
await
暂时返还了代码的控制权,所以到外面去执行了a++并输出a为1。 - 又因为await内部实现了generators,且它会保留堆栈中的东西,所以在返还之前的a=0被保留了下来。输出10 然后是20
优缺点
async/await的优势在于处理 then 的调用链,能够更清晰准确的写出代码,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。
事件循环
首先js的任务分为同步任务和异步任务,异步中又分为宏任务和微任务,我们常见的settimeout,setinterval系列就是宏任务,promise,muationobserver系列就是微任务,微任务插队宏任务。
- 默认代码从上到下执行,执行环境通过
script
来执行 - 代码执行过程中,先执行同步任务,再执行异步任务。
- 给异步任务划分队列,分别存在微任务(立即存放)和宏任务(时间到了或者事情发生了在存放)到队列中
- script执行后清空所有微任务。
- 微任务执行完毕后渲染页面(不是每次都调用—)
- 再去宏任务队列中看看也没有到达时间的,拿出来其中一个执行。
- 执行完毕后按上述的步骤不停循环。
例子(UI渲染是宏任务)自动执行的情况 会输出 listener1 listener2 task1 task2
如果手动点击click 会一个宏任务取出来一个个执行,先执行click的宏任务,取出微任务去执行。会输出 listener1 task1 listener2 task2
案例11
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28console.log(1)
async function asyncFunc(){
console.log(2)
// await xx ==> promise.resolve(()=>{console.log(3)}).then()
// console.log(3) 放到promise.resolve或立即执行
await console.log(3)
// 相当于把console.log(4)放到了then promise.resolve(()=>{console.log(3)}).then(()=>{
// console.log(4)
// })
// 微任务谁先注册谁先执行
console.log(4)
}
setTimeout(()=>{console.log(5)})
const promise = new Promise((resolve,reject)=>{
console.log(6)
resolve(7)
})
promise.then(d=>{console.log(d)})
asyncFunc()
console.log(8)
// 输出 1 6 2 3 8 7 4 5
案例2
JS为什么是单线程?
js的单线程和它的用途有关,作为浏览器脚本语言,JavaScript主要用途是与用户互动以及操作DOM。这决定了他只能是单线程,否则会带来很复杂的同步问题。比如假定js同时有两个线程,一个线程在某dom节点上添加内容,另外一个线程删除了节点。这个时候浏览器应该以哪个线程为准?所以为了避免复杂性,从一诞生,js就是单线程,这已经成为了这门语言的核心特征以后也不会改变。
浏览器事件循环
涉及面试题:异步代码的执行顺序?解释一下什么是Event Loop
- 首先js是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来确保代码的有序进行。
- 在执行同步代码的时候,如果遇到了异步事件,js引擎并不会一致等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。
- 当同步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行
- 任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行
- 当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。
process.nextTick
process.nextTick
指定的异步任务总是发生于所有异步任务之前。
1 | setTimeout(function() { |