前言
今天同事看我代码问我,你这个地方的 timeout(0)
是干啥用的? 我说为了在下一个事件循环再执行该任务,以避免其他异步操作导致的这里取的变量还没有声明。
她又问:什么是事件循环?
我答:EventLoop
她又问:什么是 EventLoop
?
我答:这讲起来就不是一句两句说的清楚了,抽个时间专门做个培训吧。
于是便有了这篇文章。
什么是 EventLoop?
JavaScript
的 EventLoop
是一种用于处理异步操作的机制。
事件循环的基本思想是,JavaScript
运行时包含一个主线程,负责执行同步任务,同时维护一个任务队列( Task Queue
)用于存放异步任务。异步任务完成后,会被放入任务队列中,等待主线程空闲时执行。
Node
也是单线程,但是由于Node
是服务端语言,要处理更多的事情。因此在事件循环上有很大差异,不能一概而论。
为什么要有 EventLoop?
JavaScript
是一门单线程的语言,单线程意味着它不能同一事件处理多个任务。举个例子,我们发起了一个网络请求,不可能等着请求返回成功再接着执行其他代码。
不只是网络请求,再比如用户交互、网络请求、定时器等。为了有效地处理这些异步任务,JavaScript
才引入了事件循环的概念。
为什么 JavaScript 是单线程语言?
JavaScript
采用单线程有很多原因,但其中最重要是还是为了避免线程间的冲突,比如两个线程操作同一个 DOM
应该怎么办?
同时,多线程还有很多缺陷:
- 竞态竞争、死锁
- 复杂性增加
- 共享状态问题
- 复杂的 API 设计
与之对应的,多线程也有很多优点,像是 提高性能、响应性、并行计算 等优点。
但总的来说,在 JavaScript
设计之初,单线程还是更优的选择。
但随着发展的需要,JavaScript
还是在持续的通过不同的方法来处理单线程带来的一些问题。
虽然大多数后端语言也可以写 UI,且也是多线程的,且也有 竞态条件(
Race Condition
)、 死锁(Deadlock
) 等问题。但与JavaScript
不同的是,JavaScript
本就诞生于浏览器,且初衷是做一个浏览器的脚本语言,单线程才是优先的解决方案。
Web Worker
JavaScript 在 2011 年引入了 Web Worker
,本质上还是为了解决 JavaScript 单线程带来的问题。 Web Worker
可以在后台运行 JavaScript 代码,可以使用多核处理器中的其他核心的算力,进而实现并行计算的功能。可以让一些计算密集任务后台运行、不阻塞主线程。
但在大多数情况下,这类操作是不必要的,前端的性能瓶颈主要还是在 DOM
以及 NetWork
侧的问题。
但它提供了一些可能性,我们可以将一些大型的计算放在前端,进而节省服务器算力,且可以一定程度上节省网络带宽资源。
微任务和宏任务
JavaScript 的异步任务分为两种,分别是 宏任务( macrotask
) 、 微任务( microtask
) ,不同的任务会压到不同的任务队列中,然后等待 EventLoop
依次从中取出需要执行的任务逐个执行。
微任务:
- Promise callback(then、catch、all ...)
- process.nextTick(node)
- Object.observe 、 MutationObserver(是否已经废弃?)
宏任务:
- script tag
- setTimeout
- setInterval
- setImmediate(node)
- I/O(node)
- UI render
关于 UI render
,其实并不是传统 JavaScript 中的代码执行,而是浏览器的操作。
那我为什么把他放在宏任务中呢,具体可以查看以下代码:
JS
setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.querySelector( 'p' ).textContent = "modified";
const start = performance.now();
(function lockUI() {
if( performance.now() - start < 5000 ) {
return Promise.resolve().then( lockUI );
}
})();
});
});
}, 0);
<p>Initial (please wait 5s)</p>
该段代码执行后表示,我们的可以通过微任务加锁,阻塞 UI 渲染 5s 的时间。
因此这里把 UI render
放在宏任务中,以提示我们执行顺序。
EventLoop 的执行流程
EventLoop
主要的执行流程:
- 执行主线程任务,生成任务队列,直到结束
- 从任务队列(
task queue
)中取出所有需要执行的微任务,执行直到结束 - 从任务队列(
task queue
)中取出第一个需要执行的宏任务,执行 - 从任务队列(
task queue
)中该宏任务执行产生的微任务,执行直到结束 - 重复直到结束
EventLoop
遵循先进先出的策略(队列Queue
)
简单的执行流程可以查看下图:
实践
在我们的开发中,经常会遇到数据不更新的情况,通常情况下,我们可以根据实际的执行,来通过压入宏任务、微任务以更改执行顺序。
React
jsx
const [cnt, setCnt] = useState(0)
const add = () => {
setCnt(cnt + 1)
console.log(cnt) // 0
setTimeout(() => {console.log(cnt)}) // 1
console.log(cnt) // 0
}
众所周知,React
的 setState
,为了实现批量更新,转换成了异步的。由于这个机制,导致我们常常在获取属性值的时候,获取到的是修改之前的值。
因此我们可以通过压入新的宏任务打印,或者通过 useEffect
监听更新实现打印新值,这就是 EventLoop
的应用之一。
Vue
JS
this.message = 'Hello';
console.log(this.$el.textContent) // ''
this.$nextTick(() => {
console.log(this.$el.textContent) // 'Hello'
});
console.log(this.$el.textContent) // ''
众所周知,Vue 的响应式,也采用了批量更新的方式,但它和 React 有所不同,在更新后马上打印,也是新值。那是不是我们就没有这个问题了?
不是的,我们如果在更新数据后,想要马上拿到 DOM 的值,会发现 DOM 的值依旧没有更新,因为它会在下一个 tick 执行更新,因此我们需要通过 $nextTick 来实现获取新值,这实际上也是 EventLoop 的一种体现。
Svelte
JS
await import('aframe');
await onAframeImported();
await timeout(0);
isImported = true;
await onAframeRendered();
这里我们在导入 aframe 执行了一个 callback,这个 callback 中注册了一些 aframe component ,这时候我们如果立即 isImported = true , 就会出现一种情况,部分基元在使用 aframe component 时,报错该组件未注册。
这实际上就是注册时,aframe 内部的部分任务是压入宏任务执行的,进而导致未注册成功时,aframe component 被引用。
因此,我们通过再压入一个靠后的宏任务 await timeout(0)
,就可以实现在它内部代码注册完成之后,再执行我们的其他代码。
提问1:这里的 await timeout(0) 转换为非 await 的原生 setTimeout 代码,应该是什么样?
提问2:这里我如果要放弃写 async、await ,应该是什么样的?
解析
提问1转换:
JS
await import('aframe');
await onAframeImported();
setTimeout(async () => {
isImported = true;
await onAframeRendered();
}, 0);
提问2转换:
JS
import('aframe')
.then(() => {
return onAframeImported();
})
.then(() => {
return timeout(0);
})
.then(() => {
isImported = true;
return onAframeRendered();
})
.catch(error => {
console.error('Error:', error);
});
原生 JS
原生 JS 时间原因我就不举正常的例子了,这里简单讲讲,如果我们开发一个前端框架,我们希望框架在一定时间检测数据与 DOM 的区别,进而更新 DOM,同样我们需要通过一个 tick 来实现非阻塞更新,类似 react 的 fiber,实际上 react fiber 也考虑过 settimeout 实现,但由于可能会和浏览器渲染冲突,最终采用了 requestMessageCallback、requestAnimationFrame 等方式实现。
刻意练习
打印顺序题1:
JS
setTimeout(function () {
console.log(1);
});
new Promise(function (resolve, reject) {
console.log(2);
resolve(3);
}).then(function (val) {
console.log(val);
});
console.log(4);
解析
答案:2431
原因:
- 主线程执行,从上至下,分别打印: 2 4
- 查微任务队列,打印:3
- 查宏任务队列,打印:1
打印顺序题2:
JS
console.log('starting');
setTimeout(()=>{
console.log('settimeout');
setTimeout(()=>{
console.log('inner');
})
console.log('end');
},1000)
new Promise((resolve,reject)=>{
console.log('promise');
resolve()
})
.then(()=>{
console.log('then');
})
.then(()=>{
console.log('then2');
})
.then(()=>{
console.log('then3');
})
解析
答案:
- starting
- promise
- then
- then2
- then3
- settimeout
- end
- inner
原因:
- 执行主线程:starting、promise
- 执行主线程的微任务:then、then2、then3
- 取出宏任务执行,并压入新的宏任务:settimeout、end
- 取出宏任务执行:inner
打印顺序题3:
JS
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
Promise.resolve().then(() => console.log('4')).then(() => console.log('4.1'));
Promise.resolve().then(() => console.log('4.2'));
setTimeout(() => {
console.log('5');
Promise.resolve().then(() => console.log('6'));
});
});
Promise.resolve().then(() => console.log('7'));
}, 0);
setTimeout(() => {
console.log('8');
Promise.resolve().then(() => console.log('9'));
}, 0);
console.log('10');
解析
答案:
- 1
- 10
- 2
- 3
- 7
- 4
- 4.2
- 4.1
- 8
- 9
- 5
- 6
原因:
- 执行主线程:1、10
- 取出宏任务执行,并压入两个微任务:2
- 取出两个微任务执行,并压入后生成的两个微任务、一个宏任务:3、7
- 取出新压入的微任务执行,并压入一个微任务:4、4.2
- 取出最后一个微任务执行:4.1
- 没有微任务了,取下一个宏任务执行,并压入一个微任务:8
- 执行微任务:9
- 取出下一个宏任务执行,并压入一个微任务:5
- 取出微任务执行:6
- 没有新任务了,结束
打印顺序题4(课后作业):
javascript
console.log('Start');
setTimeout(() => {
console.log('1. First macro task');
Promise.resolve().then(() => {
console.log('2. First microtask in the first macro task');
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(`3. Subtask ${i} in the first macro task's first microtask`);
}, 0);
Promise.resolve().then(() => {
console.log(`4. Microtask in subtask ${i} in the first macro task's first microtask`);
});
}
Promise.resolve().then(() => {
for (let i = 4; i <= 6; i++) {
console.log(`5. Subtask ${i} in the first macro task's first microtask`);
}
});
});
setTimeout(() => {
console.log('6. Second macro task in the first macro task');
for (let i = 7; i <= 9; i++) {
console.log(`7. Subtask ${i} in the second macro task in the first macro task`);
}
Promise.resolve().then(() => {
for (let i = 10; i <= 12; i++) {
setTimeout(() => {
console.log(`8. Subtask ${i} in the second macro task's first microtask in the first macro task`);
}, 0);
Promise.resolve().then(() => {
console.log(`9. Microtask in subtask ${i} in the second macro task's first microtask in the first macro task`);
});
}
});
}, 0);
}, 0);
console.log('End');