Event Loop事件队列和宏任务微任务
Event Loop
我们知道JavaScript是一门单线程的语言,他只能一行一行的执行代码,于是我们的代码应该都是同步的,这里我们暂时忘掉所有的异步,看一下这么做会有什么问题。
用户进入了我们的网页,点击了一个按钮,触发加载更多功能,此时浏览器发起ajax请求,如果我们的代码都是同步的,那么页面会在这个请求完成之前是卡主状态的,因为需要等待该代码的完成,此时用户什么都做不了,既不能滚动页面,也无法点击其他内容,如果这个请求需要60s的时间,用户肯定会觉得,这是什么狗屎页面。
这显然是不行的,因为代码阻塞导致体验特别的差,解决这个问题的办法就是加入异步功能,我们将请求的回调作为异步处理,不需要去同步等待它的完成,而是先去执行其他的内容。
当请求完成后再运行这个回调,那么如何协调同步与异步的运行,使用的就是事件循环Event Loop。
上图是一个简单的代码可视化运行环境的图,其中运行栈就是我们代码实际运行的地方,而web api则是我们的运行环境通过的一系列api钩子,比如setTimeout
,这是浏览器为我们提供的,而任务队列我们待会再讲它是做什么的。
console.log(1);
setTimeout(() => {
console.log(2);
},1000);
console.log(3);
我们看这段代码,首先他会将console.log(1)
压入运行栈中,运行,运行完毕后出栈,然后运行setTimeout
,注意,此时setTimeout其实存在于Web Api中,而不是在运行栈中。
接着console.log(3)
被压入运行栈中运行,然后完毕出栈。
我们的setTimeout
会被浏览器处理,当1000ms后,回调函数会被压入任务队列中。
此时,如果我们的运行栈是空的,那么就会去查询任务队列中是否存在待运行的代码,然后它发现了一个回调需要运行,此时运行回调函数,完毕后出栈。
所以上述代码最终的log输出顺序是:
// 1
// 3
// 2
我们的事件循环名字都写着循环了,所以上述的运行过程是在不断循环处理的,不是说代码运行完毕就结束了,它会不断的重复这个过程。
而任务队列是存放异步回调的地方,它会在运行栈空的时候被查询,然后获取一个任务,运行后,如果运行栈没有需要运行的了,又会去任务队列查询是否有需要运行的代码,如果无了又返回自己,空了又来查询,如此往复,形成了一个无限的循环,这个过程就称为事件循环。
上述的例子还是比较简单的,对于入门理解是足够的,但是随着我们的代码深入,你会发现,我们的异步代码有很多啊,比如:promise、setTimeout、ajax、onclick
,这些的执行顺序都是怎么样的,这里就得讲讲宏任务和微任务了。
宏任务和微任务
由于不同的异步代码并不相同,有的是定时器,有的是请求,有的是协议promise,他们的执行被划分为两个区域:宏任务区域、微任务区域。
我们可以将上面的任务队列拆分成两个容器,一个是存放宏任务的,一个存放微任务的。
宏任务:setTimeout
、setInterval
、DOM事件
、ajax请求
、setImmediate(node独有的)
、requestAnimationFrame(浏览器独有)
、IO
、UI render(浏览器独有)
微任务:Promise
、Object.observe
、MutationObserver
、process.nextTick(node 独有)
虽然看上去分类很多,其实不用太考虑一个日常用不到的东西,比如setImmediate这些,我们精简一下之后:
宏任务:setTimeout
、setInterval
、DOM事件
、ajax请求
、requestAnimationFrame
微任务:Promise
注意:这两个任务中,微任务优先级是最高的。
看这段代码:
console.log(1);
setTimeout(() => {
console.log("定时器");
});
new Promise((resolve) => {
console.log(2);
resolve();
console.log(3);
}).then(() => {
console.log("Promise1");
});
console.log(4);
log(1)
先被压入运行栈运行,完毕后出栈!
接着是触发webapi的setTimeout
,没有等待时间,回调被推入宏任务队列中。
触发new Promise,注意new Promise的回调函数执行还是同步的,所以log(2)
被推入栈中,完毕后出栈。
此时触发了resolve()
,then接收的回调被推入微任务队列中。
log(3)
被推入栈中,完毕后出栈。
log(4)
被推入栈中,完毕后出栈。
此时运行栈中已经没有需要运行的代码了,于是先去微任务队列中查询是否有需要运行的内容,发现then的回调,于是log("Promise1")
推入栈中运行,完毕后出栈。
此时运行栈又空了,再去微任务队列查询,发现微任务也空了,于是去宏任务队列查询,发现有需要运行的,于是log("定时器")
被推入栈中,完事后出栈。
最终我们的打印结果是:
// 1
// 2
// 3
// 4
// Promise1
// 定时器
这里你会发现,当微任务运行完一个后还会去微任务队列里面查询,这就会产生一个现象,如果我在运行微任务的是又往微任务里面追加一个任务,那么就会导致线程死循环了。
举个例子:
console.log(1);
setTimeout(() => {
console.log("定时器");
});
new Promise((resolve) => {
console.log(2);
resolve();
console.log(3);
})
.then(() => {
console.log("Promise1");
})
.then(() => {
console.log("Promise2");
})
.then(() => {
console.log("Promise3");
})
.then(() => {
console.log("Promise4");
})
.then(() => {
console.log("Promise5");
});
console.log(4);
当一个then的回调运行完毕后,会将下一个then的回调传入微任务中去,于是我们会发现打印顺序是这样的:
// 1
// 2
// 3
// 4
// Promise1
// Promise2
// Promise3
// Promise4
// Promise5
// 定时器
如果我们的微任务无限添加下去,定时器的回调就永远无法运行,所以我们需要注意这点。
而且我们需要注意一点,不管是什么队列,每次拿取都只会拿取一个,运行完毕后再重复整个循环过程。
示例:
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
setTimeout(() => {
console.log(4);
});
});
Promise.resolve().then(() => {
console.log(5);
});
console.log(6);
当我们在宏任务中创建了一个微任务,一个新的宏任务时,在该宏任务运行结束后,如果运行栈空了,他还是会先从微任务队列中查询,所以上述代码的打印顺序是:
1
6
5
2
3
4
略有特别的DOM事件
对于dom事件,我们可能需要通过一个小例子来进行记忆。
在页面中我们存在一个按钮:
<button id="btn">点击我</button>
加入JavaScript代码:
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
console.log(1);
Promise.resolve().then(() => console.log(2));
});
btn.addEventListener("click", () => {
console.log(3);
Promise.resolve().then(() => console.log(4));
});
当用户点击按钮时,我们的执行顺序是怎么样的呢?利用我们刚刚学到的知识。
首先是两个dom事件被触发,他们的回调被传入宏任务队列中。
此时运行栈和微任务都是空的,于是从宏任务队列中拿取第一个回调函数触发。
log(1)
入栈出栈。
创建了一个微任务压入微任务队列中去。
此时运行栈为空,于是先去微任务队列中查询,拿到了刚刚压入的内容,运行回调,log(2)
打印。
此时运行栈又空了,微任务队列也空了,在宏任务队列中拿到第二个回调运行。
log(3)
入栈出栈。
创建了一个微任务压入微任务队列中去。
此时运行栈为空,于是先去微任务队列中查询,拿到了刚刚压入的内容,运行回调,log(4)
打印。
代码结束。
最后的打印顺序如我们所料:
// 1
// 2
// 3
// 4
但是,如果我们通过js去触发click,故事就会变得魔幻起来。
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
console.log(1);
Promise.resolve().then(() => console.log(2));
});
btn.addEventListener("click", () => {
console.log(3);
Promise.resolve().then(() => console.log(4));
});
btn.click();
我们的输出结果是:
// 1
// 3
// 2
// 4
震惊!!!为什么???
当我们手动执行click事件时,处理方式会不同,我们可以这么去理解:
() => {
click1(); //第一个click回调
click2(); //第二个click回调
}
他会将按钮的两个click事件按顺序执行,而不是像上述所说的过程,两个事件回调被压入微任务队列中。这就是他们不同的地方。
由于是两个任务,在log(1)
打印完成,微任务也压入了队列中的时候,并不是结束一个循环回合,因为下面还有click2的代码没有运行,运行栈没有清空,所以继续运行click2的内容,于是log(3)
打印完成,微任务压入新的内容。
此时btn.click()
运行结束了,运行栈清空了,于是查询微任务队列,运行刚刚压入的两个回调。
现在明白了吧!
我们再看一个例子:
<a href="https://www.mulingyuer.com" target="_blank" id="alink">点击跳转</a>
我们现在有一个a链接
const alink = document.getElementById("alink");
const promise1 = new Promise((resolve) => {
alink.addEventListener("click", resolve, { once: true });
});
promise1.then((event) => {
event.preventDefault();
console.log("阻止默认行为");
});
// alink.click();
当用户点击时,我们是可以正常阻止默认行为的,也就是链接跳转的行为被阻止了,但是当我们通过js去触发click事件时,链接会被正常跳转。
a链接被阻止跳转是因为他会判断evnet对象是否是canceled
状态,当我们调用了event.preventDefault()
时,就会将evnet对象标记为canceled,所以它无法跳转。
正常情况下,会在所有click事件结束后判断evnet对象,我们可以理解为这个判断是一个宏任务。
于是then的回调会先触发,evnet对象被标记canceled,跳转被阻止。
但是直接通过alink.click()
调用的事件,它在标准规范中的定义不同,他会同步执行完所有事件回调,也就是我们上面刚演示的那种:
() => {
click1(); //第一个click回调
click2(); //第二个click回调
}
执行完后会直接判断evnet对象,此时它不是一个宏任务了,是一个同步的代码,类似于:
() => {
click1(); //第一个click回调
click2(); //第二个click回调
if(event.canceled) return;
//链接跳转处理
***
}
此时我们的微任务根本没有运行,所以无法阻止跳转,当它运行时就已经来不及了。
其实本质上还是上面说的那种情况,事件回调被依次执行,不再是一个个宏任务了,只不过a链接有些特殊,它的跳转判定方式在不同的调用情况下会有不同。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据