JS的单线程特性
JS是一门单线的非阻塞的脚本语言
单线程
- 同一个时间只能做一件事。程序执行时,需要按顺序依次执行任务,前面的任务执行完,才能执行后面的任务。
- 特点:只要一个主线程来处理所有的任务
- 优点:保证了程序执行的一致性
- 缺点:效率低。由于单线程应用程序需要上一个任务完成后才能开始新的任务,其效率通常比多线程的低。
为什么JS不采用多线程呢?
这与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。若以多线程的方式操作这些 DOM,则可能出现操作的冲突。假设有两个线程同时操作一个 DOM 元素,线程 1 要求浏览器删除 DOM,而线程 2 却要求修改 DOM 样式,这时浏览器就无法决定采用哪个线程的操作。当然,我们可以为浏览器引入“锁”的机制来解决这些冲突,但这会大大提高复杂性,所以 JavaScript 从诞生开始就选择了单线程执行。
阻塞和非阻塞
- 阻塞和非阻塞指的是调用者(程序)在等待返回结果(或输入)时的状态
- 阻塞时,在调用结果返回前,当前线程会被挂起,并在得到结果之后返回
- 非阻塞时,如果不能立刻得到结果,则该调用者不会阻塞当前线程。因此对应非阻塞的情况,调用者需要定时轮询查看处理状态
JS引擎 V8
V8是由Google开发的JavaScript引擎,主要用于Google Chrome浏览器和Node.js服务器端运行环境。
引擎包括两个组件:
- 内存堆(Memory Heap):进行内存分配的区域
- 调用栈(Call Stack):代码运行时的栈
调用栈
JS采用了单线程的执行方式,所有的代码都是在主线程上执行,且同时只能执行一个任务。为了确保任务的执行顺序被正确调用,需要一个调用栈来跟踪函数的执行顺序。
- 调用栈是一种数据结构,使用后进先出(LIFO)原理临时存储和管理函数调用
- 调用函数时,会将它放到栈顶。从函数返回时,再把它从栈顶弹出。
栈溢出
调用栈的空间是有限的,当分配的调用栈空间被占满时,会引发“堆栈溢出”错误。在使用递归时容易导致栈溢出
function foo(){
return foo()
}
事件循环
什么是浏览器事件循环
在计算机中,Event Loop 是一个程序结构,用于等待和发送消息和事件。 —— 维基百科
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.
为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节中描述的事件循环。每个代理都有一个关联的事件循环,该事件循环对于该代理来说是唯一的。 —— HTML标准(html.spec.whatwg.org)
- 事件循环可以理解为一个消息分发器,通过接收和分发不同类型的消息,让执行程序的事件调度更加合理
- 浏览器需要事件循环来协调事件、用户操作、脚本执行、渲染、网络请求等。通过事件循环,浏览器可以利用任务队列来管理任务,让异步事件非阻塞地执行。
- 每个线程都有自己的事件循环。同一个源上的所有窗口共享一个事件循环,Web Worker也有自己的事件循环,独立执行。
- 浏览器事件循环是以浏览器为宿主环境实现的事件调度,负责同步任务和异步任务之间的调度
浏览器为什么需要事件循环
由于 JavaScript 是单线程的,且 JavaScript 主线程和渲染线程互斥,如果异步操作(如上图提到的 WebAPIs)阻塞 JavaScript 的执行,会造成浏览器假死。而事件循环为浏览器引入了任务队列(task queue),使得异步任务可以非阻塞地进行。
浏览器事件循环会将异步任务挂起,继续执行其他同步任务,直到当前所有同步任务执行完毕,主线程空闲时才会执行异步任务的回调。为了知道异步任务的回调何时才能执行,事件循环会等到时机合适时把异步任务的回调返回到主线程执行。
事件循环的执行流程
- 所有同步任务都在主线程上执行,形成调用栈
- 主线程之外还存在任务队列。只要异步任务运行有了结果就会在任务队列中放置一个回调。
- 一旦执行栈中所有同步任务执行完毕(调用栈为空),系统就会读取任务队列中的回调放到主线程执行
- 主线程不断重复上面的一步。
举个栗子?
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500); // 异步任务
const baz = () => console.log("Third");
bar();
foo();
baz();
- 调用bar,bar返回setTimeout函数
- 将传给setTimeout的回调 cb1 添加到Web API,setTimeout函数和bar从调用堆栈中弹出
- 计时器运行,同时foo被调用并打印 “First”。
- 500毫秒后,回调cb1会被添加到任务队列中
- 执行baz,打印”Third”。
- 此时,事件循环发现调用堆栈为空,从任务队列中取出第一个回调cb1添加到调用堆栈执行。
- 执行cb1,打印”Second”。此时调用堆栈和任务队列都为空,事件循环进入休眠状态等待任务
任务队列
一个事件循环循环有一个或多个任务队列。任务队列是任务的集合,而不是队列。因为事件循环处理模型会选取第一个可执行任务开始执行,而不是队首的任务。
同步任务和异步任务
任务被分成两种:同步任务(synchronous)和异步任务(asynchronous):
- 同步任务:指在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
- 异步任务:指一个任务不是连续完成的,先执行第一段,做好准备,再执行第二段(也叫回调),这个过程耗时长。这中间过程是由I/O去执行的,JS不需要参与,只需等待返回的结果再执行回调。
异步任务一般是调用浏览器提供的Web API创建的,常见的Web API有:
- DOM的事件
- 定时器:setTimeout和setInterval
- 网络请求(Ajax、fetch)
宏任务和微任务
在任务队列内部,任务大致分两类:微任务和宏任务。任务队列也分为两种:任务队列(也叫宏任务队列,macrotask queue)和微任务队列(microtask queue)
- 一个事件循环有一个或多个任务队列
- 每个事件循环都有一个微任务队列,它是一个微任务队列,最初是空的
- 任务可以被推入宏任务队列或微任务队列
- 微任务:process.nextTick、Promises、queueMicrotask、MutationObserver
- 宏任务:setTimeout、setInterval、setImmediate、requestAnimationFrame、I/O、UI 渲染
任务队列和微任务队列的区别
- 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
- 每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。
多个任务队列之间的任务调度
现在有两种任务队列,事件循环是如何调度任务的呢?
当调用堆栈(call stack)为空时,执行以下步骤:
- 选择任务队列中最旧的任务(任务A)
- 如果任务A为空(即任务队列为空),则跳转到步骤6
- 将 【当前运行的任务】设置为 任务A
- 运行任务A(表示运行回调函数)
- 将【当前运行的任务】设置为null,删除任务A
- 执行微任务队列
- 选择微任务队列中最旧的任务(任务X)
- 如果任务X为空(即微任务队列微空),跳转到步骤g
- 将【当前运行的任务】设置微任务X
- 执行任务X
- 将【当前运行的任务】设置为空,删除任务X
- 选择微任务队列中下一个最旧的任务,跳转到步骤b
- 完成微任务队列
- 跳到步骤1
简化版执行步骤:
- 运行任务队列(宏任务)中最旧的任务,并删除它
- 运行微任务队列中的所有可用任务,然后将其删除
- 下一轮:运行任务队列(宏任务)中的下一个任务(跳步骤2)
可见,微任务队列的优先级比任务队列高。
为什么需要微任务
已经有任务队列(宏任务队列),为什么还需要一个微任务队列?
举例说明微任务和宏任务在处理 DOM 更新方面的差异:
console.log('Start');
// 宏任务:setTimeout
setTimeout(() => {
console.log('Macro Task');
const paragraph = document.createElement('p');
paragraph.textContent = 'I am added by Macro Task';
document.body.appendChild(paragraph);
}, 0);
// 微任务:Promise 的 then
Promise.resolve().then(() => {
console.log('Micro Task');
const span = document.createElement('span');
span.textContent = 'I am added by Micro Task';
document.body.appendChild(span);
});
console.log('End');
上述代码中,我们在宏任务和微任务中都添加了一个 DOM 元素。宏任务的回调函数通过 setTimeout
在下一个事件循环中执行,而微任务的回调函数通过 Promise 的 then
方法在当前任务完成后立即执行。
当我们运行这段代码时,输出如下:
Start
End
Micro Task
Macro Task
可以看到,首先打印了 Start
和 End
,然后微任务的回调函数 Micro Task
执行,并且微任务中添加的元素 I am added by Micro Task
立刻出现在页面上。最后,在下一个事件循环中,宏任务的回调函数 Macro Task
执行,并且宏任务中添加的元素 I am added by Macro Task
出现在页面上。
这个例子展示了微任务和宏任务在处理 DOM 更新方面的差异。微任务中的 DOM 更新会在当前任务完成后立即执行,并在下一次页面渲染前生效。而宏任务中的 DOM 更新则会在下一个事件循环中执行,并稍有延迟。
这种差异对于实现页面的渲染优化和更新状态与界面的同步非常有用。通过将 DOM 更新放在微任务中,可以及时响应用户操作和状态变更,提升用户体验。而宏任务适用于相对独立的任务,可以用于处理较大的 DOM 更新、复杂的操作和页面重绘。
宏任务与setTimeout
setTimeout的回调不一定在指定时间后立刻执行,而是在指定时间后,将回调放入事件循环的任务队列中。
如果时间到了,JS还在执行调用堆栈中的同步任务,则任务队列中的任务需要等待,直到调用堆栈为空。如果当前事件循环的队列里还有其他回调,需要等其他回调执行完。
setTimeout的时间默认值是0,但不意味着立刻执行回调。它有一个默认最小时间4ms
微任务与Vue的更新
- Vue的更新使用微任务(Promise)来实现异步更新的。微任务会在当前任务完成后,浏览器渲染前执行,这样确保了DOM的更新发生在下一次页面渲染之前,更快速响应用户的交互。
- Vue的NextTick函数也使用了Promise创建一个微任务,将回调插入到微任务队列的后面,确保在所有更新任务(微任务)执行完后才被执行,这样就能拿到更新后的DOM值
requestAnimation与动画队列
有时,我们希望实现一些动画效果,可能会考虑使用setTimeout来实现,期望定时更新DOM的样式来实现动画效果。
举个栗子?:使用setTimeout实现渐变动画
function animateElement() {
const element = document.getElementById("myElement");
let opacity = 0;
function fadeIn() {
opacity += 0.01;
element.style.opacity = opacity;
if (opacity < 1) {
setTimeout(fadeIn, 10);
}
}
fadeIn();
}
animateElement()
递归调用setTimeout函数使得该函数每隔10毫秒执行一次,从而实现了渐变的效果。但使用setTimeout实现的动画效果可能不够平滑。这是因为setTimeout的回调执行时机不一定都刚好在每次页面渲染前被执行。
对于复杂的动画效果,建议使用requestAnimationFrame来获得更好的性能和流畅性。
使用requestAnimationFrame实现相同的渐变动画效果如下:
function animateElement() {
const element = document.getElementById("myElement");
let opacity = 0;
function fadeIn() {
opacity += 0.01;
element.style.opacity = opacity;
if (opacity < 1) {
requestAnimationFrame(fadeIn);
}
}
requestAnimationFrame(fadeIn);
}
requestAnimationFrame会与浏览器的渲染进程协调,确保回调函数在每一帧的开始时被调用。
在浏览器的任务队列中除了前面介绍的两种任务队列,还有动画队列(animationQueue)。requestAnimationFrame的回调添加到动画队列中,在页面渲染下一帧前会执行动画队列里的所有回调。
总结
事件循环机制的伪代码:
while(true){
// 从任务队列中取最旧的宏任务执行
queue = getNextQueue()
task = queue.pop()
execute(task)
// 检测微任务队列是否有微任务,如果有依次执行,直到微任务队列为空
while(microtaskQueue.hasTasks(){
doMicroTask()
}
// 是否到浏览器渲染时间
// 一般是每隔16毫米渲染一次页面,超过这个时间渲染画面会掉帧,显得页面卡顿
if(isRepaintTime()){
// 执行动画队列中的任务
animationTasks = animationQueue.copyTasks()
for(task in animationTasks){
doAnimationTask(task)
}
// 渲染页面
repaint()
}
}
参考
- 事件循环的进一步探索 – Erin Zimmer – JSConf EU 2018 – YouTube
- 可视化事件循环的过程
- JavaScript 运行机制详解:再谈Event Loop – 阮一峰的网络日志
- The JavaScript Event Loop: Explained | by Ayush Verma | Towards Dev — JavaScript 事件循环:解释 |通过阿尤什·维尔玛 |走向发展
- 事件循环:微任务和宏任务
- html.spec.whatwg.org/multipage/w…
- 深入:微任务与 Javascript 运行时环境 – Web API 接口参考 | MDN
- Call stack(调用栈) – MDN Web 文档术语表:Web 相关术语的定义 | MDN
- queueMicrotask() – Web API 接口参考 | MDN
- Window:requestAnimationFrame() 方法 – Web API 接口参考 | MDN — Window:requestAnimationFrame() 方法 – Web API 接口参考 | MDN
- 非同期処理 (1): Javascript の動作の流れ (JS エンジン / Call Stack / Event Queue)
- 浏览器事件循环 | HZFE – 剑指前端 Offer