EventLoop
what is EventLoop
JavaScript 是单线程的。它有且只有一个调用栈,每次只能做一件事。
在其执行过程中会产生很多异步任务,我们不可能让 JavaScript 停下来去等待异步任务执行完成,而是把这些异步任务放入相应的队列中,先执行当前的代码,等到当前任务执行完成,时机成熟的时候再去检查其他队列,并执行其他任务。这就意味着不会有不同的 JavaScript 代码同时执行,去编辑同一个 DOM,这样就不会造成冲突。但是同时也意味着,如果主线程有代码执行了很长的时间,会导致其他的任务被阻塞,比如说渲染或者用户交互。
当执行 setTimeout 的时候,如果让它阻塞后面代码的运行,等到了设定的时间再执行 setTimeout 里面的任务,这肯定是行不通的。那如果让它从主线程中脱离出来,并行执行。可能又会导致多段 JS 代码同时编辑一个 DOM。于是把任务放到队列里面,等到时机成熟,再放回主线程执行。
当产生新的任务时,浏览器会把这个任务加入到相应的任务队列中,稍后去处理它。
how EventLoop works
在执行 JavaScript 时,会产生很多异步任务,像 setTimeout、AJAX 请求(XMLHttpRequest)、promise.then 等等。这些异步任务通常需要一定的条件才能触发,比如 setTimeout 需要经过指定的时间,AJAX 请求需要等响应回来,promise.then 需要在 promise 状态发送改变之后等等。在遇到这些异步任务的时候,首先将它们放入 Web APIs 中,等到达到触发条件,再将它们放入到相应的队列中,等待主线程的执行。
假设从当前脚本开始执行。首先执行脚本里面的同步代码,在执行过程中会产生一些异步任务,这些异步任务被暂时存放在 Web APIs 里面,当达到一定的条件,再放入到相应的队列中。当前任务执行完,调用栈为空。然后检查微任务队列,将所有微任务执行完,包括在执行微任务的时候新创建的微任务,直到微任务队列为空,再去执行下一个宏任务。
当然,过程中可能会产生 requestAnimationFrame 任务(就先叫它动画任务吧)。这时,浏览器会将这一轮的动画任务执行完,当然不包括在执行这些动画任务时候产生的新的动画任务。新产生的动画任务会延迟到下一帧渲染之前执行。因为 requestAnimationFrame 产生的回调是在当前这一帧渲染之前执行,并不是在每一帧渲染之前都执行。这就像 setTimeout一样,如果我们需要用它来做连续的动画,肯定要在 requestAnimationFrame 的回调函数里面再嵌套执行一次 requestAnimationFrame,即在动画任务里面再创建一个动画任务。如果这个时候新产生的动画任务在这一轮渲染之前执行,那么执行一个动画任务它就会创建一个新的动画任务。这样就会进入死循环,永远也不会渲染这一帧。我们也无法使用这个 API 来实现连续的动画了。
就像这样来实现连续的动画:
let left = 0;
function moveToRight() {
box.style.transform = `translateX(${left}px)`;
requestAnimationFrame(moveToRight);
left += 1;
}
moveToRight();
requestAnimationFrame
作为渲染的一部分被执行,它会跟显示器的刷新频率同步,与显示器性能匹配。通常显示器 1s 刷新 60 次。
how to transition
下面这个代码运行,box 是不会从 0 过渡到 1000px,再从 1000px 过渡到 500px 的。因为浏览器会优化这个过程,在执行完这个代码之后再去渲染。
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
box.style.transform = 'translateX(500px)';
});
那么我们要怎样才能让他按照我们的想法过渡呢?
利用 getComputedStyle(box).transform
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
getComputedStyle(box).transform;
box.style.transform = 'translateX(500px)';
});
在执行 getComputedStyle 时,浏览器会进行回流操作,计算出元素当前最准确的样式,并渲染出来。
利用 requestAnimationFrame
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.transform = 'translateX(500px)';
})
})
});
在渲染第一帧之前,调用 requestAnimationFrame,在这个 requestAnimationFrame 里面设置了另一个 requestAnimationFrame,在第二帧渲染之前执行里面的 requestAnimationFrame 的回调函数,将其移动到 500px 处,而第一帧之前我们将其移动到了 1000px 处。
三种不同的队列
宏任务队列、动画回调队列、微任务队列:
宏任务队列,一次只处理一个宏任务,如果有新的宏任务,则直接添加到队尾。动画回调队列会一次执行完当前的任务,如果在过程中又产生了新的动画回调,则放到下一帧之前执行。微任务队列会全部清空,包括中途添加进来的新的微任务,也会执行。
what's the result
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 1'));
console.log('Listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 2'));
console.log('Listener 2');
})
当用户点击按钮,会触发两个事件(按顺序触发),这两个事件被放入到宏任务队列。首先执行第一个事件,输出 Listener 1,这没毛病。然后输出什么呢?Listener 2?不是。因为 Listener 2 是第二个事件里面输出的,也就是第二个宏任务里面才会输出。而当前执行第一个宏任务的过程中产生了新的微任务,这个微任务被放入到微任务队列,此时微任务队列不为空,所以执行完第一个宏任务之后会执行微任务,也就是输出 Microtask 1。然后才去执行第二个宏任务,输出 Listener 2 和 Microtask 2。
所以输出顺序为:Listener 1、Microtask 1、Listener 2、Microtask 2。
那如果使用 JavaScript 调用点击函数呢?
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 1'));
console.log('Listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 2'));
console.log('Listener 2');
})
button.click();
结果还会和上面一样吗?不一样了。这个时候才会输出:Listener 1、Listener 2、Microtask 1、Microtask 2。因为 click 方法会同步运行(创建和派发)事件,这个事件不会被放入到事件循环队列当中。