图解 Google V8 学习笔记
指消息队列中的等待被主线程执行的事件。
每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。
微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做一个有效的权衡。另外一个好处就是可以使用同步形式的代码来编写异步调用。
微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的。基于微任务,又可以延伸出协程
、Promise
、Generator
、await/async
等现代前端经常使用的一些技术。
示意图:
调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。
例子:
function bar() {
}
foo(fun){
fun()
}
foo(bar)
1、当 V8 准备执行这段代码时,会先将全局执行上下文压入到调用栈中:
2、V8 便开始在主线程上执行 foo 函数,首先它会创建 foo 函数的执行上下文,并将其压入栈中:
3、V8 执行 bar 函数时,同样要创建 bar 函数的执行上下文,并将其压入栈中:
4、bar 函数执行结束,V8 就会从栈中弹出 bar 函数的执行上下文:
5、最后,foo 函数执行结束,V8 会将 foo 函数的执行上下文从栈中弹出:
例子:
function foo(){
foo()
}
foo()
由于栈空间在内存中是连续的,调用栈的大小有限制,上面代码嵌套层数过深时,会导致栈一直向上增长,而过多的执行上下文堆积在栈中便会导致栈溢出。
示意图:
setTimeout 的本质是将同步函数调用改成异步函数调用。
可以将上面的代码改成:将 foo 封装成事件,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个任务。
function foo() {
setTimeout(foo, 0)
}
foo()
从调用栈、主线程、消息队列分析执行流程:
1、主线程会从消息队列中取出需要执行的宏任务:
2、V8 执行 foo 函数时,会创建 foo 函数的执行上下文,并将其压入栈中:
3、V8 执行 setTimeout 函数时,setTimeout 会将 foo 函数封装成一个新的宏任务,并将其添加到消息队列中:
4、foo 函数执行结束,V8 就会结束当前的宏任务,调用栈也会被清空:
5、刚才通过 setTimeout 封装的回调宏任务,会在在某一时刻被主线取出并执行:
上面就是 foo 函数的执行过程,它并不是在当前的父函数内部被执行的,而是封装成了宏任务,并被添加到了消息队列中,然后等待主线程从消息队列中取出该任务,再执行该回调函数 foo,这样就解决了栈溢出的问题。
注意:像 setTimeout 、XMLHttpRequest 这种 web APIs 是浏览器内核提供的,相当于宿主对 V8 的扩展。
V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。
微任务的执行时机:
例子:
function bar(){
console.log('bar')
Promise.resolve().then(
(str) =>console.log('micro-bar')
)
setTimeout((str) =>console.log('macro-bar'), 0)
}
function foo() {
console.log('foo')
Promise.resolve().then(
(str) =>console.log('micro-foo')
)
setTimeout((str) =>console.log('macro-foo'), 0)
bar()
}
foo()
console.log('global')
Promise.resolve().then(
(str) =>console.log('micro-global')
)
setTimeout((str) =>console.log('macro-global'), 0)
输出结果:可以看到微任务是处于宏任务之前执行的。
foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global
上面代码执行流程:
1、当 V8 执行这段代码时,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列:
2、执行 foo 函数的调用时:
Promise.resolve
,会触发一个 micro-foo
微任务,V8 会将该微任务添加进微任务队列。setTimeout
方法,会触发了一个 macro-foo
宏任务,V8 会将该宏任务添加进消息队列。3、foo 函数调用了 bar 函数时:
Promise.resolve
,会触发一个 micro-bar
微任务,V8 会将该微任务添加进微任务队列。setTimeout
方法,会触发了一个 macro-bar
宏任务,V8 会将该宏任务添加进消息队列。4、bar 函数执行结束并退出,bar 函数的执行上下文也会从栈中弹出,紧接着 foo 函数执行结束并退出,foo 函数的执行上下文也随之从栈中被弹出。
5、主线程执行完了 foo 函数之后:
Promise.resolve
,会触发一个 micro-global
微任务,V8 会将该微任务添加进微任务队列。setTimeout
方法,会触发了一个 macro-global
宏任务,V8 会将该宏任务添加进消息队列。6、等到这段代码即将执行完成时,V8 便要销毁这段代码的环境对象,此时环境对象的析构函数被调用,这是 V8 执行微任务的一个检查点,V8 会检查是否存在微任务队列,如果有,会依次取出微任务,并按照顺行执行。
**析构函数(destructor) **:与构造函数相反,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。析构函数往往用来做“清理善后” 的工作(例如在建立对象时用 new 开辟了一片内存空间,delete 会自动调用析构函数后释放内存)。
7、最后微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续重复执行取出任务、执行任务的过程。
在图解 Google V8 # 11:堆和栈:函数调用是如何影响到内存布局的?文章里,我们有过三个例子的对比:
function kaimo() {
kaimo()
}
kaimo()
1、在同一个任务中重复调用嵌套的 kaimo 函数。V8 会报栈溢出的错误:
2、使用 setTimeout 让 kaimo 函数在不同的任务中执行。V8 能够正确执行。
3、使用 Promise.resolve()
在同一个任务中执行 kaimo 函数,但是却不是嵌套执行。
重点在看一下第三种:由于 V8 每次执行微任务时,都会退出当前 kaimo 函数的调用栈,所以这段代码是不会造成栈溢出的。而这个微任务就是调用 kaimo 函数本身,所以在执行微任务的过程中,需要继续调用 kaimo 函数,在执行 kaimo 函数的过程中,又会触发了同样的微任务。那么这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中,页面无法响应这些事件,具体的体现就是页面的卡死。
MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。
// 选择需要观察变动的节点
const targetNode = document.getElementById('some-id');
// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };
// 当观察到变动时执行的回调函数
const callback = function(mutationsList, observer) {
// Use traditional 'for loops' for IE 11
for(let mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added or removed.');
}
else if (mutation.type === 'attributes') {
console.log('The ' + mutation.attributeName + ' attribute was modified.');
}
}
};
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);
// 以上述配置开始观察目标节点
observer.observe(targetNode, config);
// 之后,可停止观察
observer.disconnect();
MutationObserver 是一个微任务,通过浏览器的 requestIdleCallback,在浏览器每一帧的空闲时间执行 MutationObserver 监听的回调,该监听是不影响主线程的,但是回调会阻塞主线程。当然有一个限制,如果100ms 内主线程一直处于未空闲状态,那会强制触发 MutationObserver。