从一个题目浅浅理解JS运行机制 | 青训营笔记

632 阅读7分钟

ChMkJlZJPpOIR610AATGeL_FC6sAAE9EwJRoVwABMaQ566.jpg 这是我参与「第四届青训营 」笔记创作活动的第2天

小白一枚,欢迎指正

话不多说,直接上题

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    console.log('promise1');
}).then(function() {
    console.log('promise2');
});

console.log('script end');

答案如下

script start
script end
promise1
promise2
setTimeout

要想理解答案,需要具备以下小知识,没明白的不妨往下挖挖 ^.^

一、V8 引擎内的 栈

也被称为“调用栈、执行栈”。栈是一种LIFO(last input first output)后进先出的数据结构。

当函数被推入执行栈,JS引擎就开始解析函数体,在堆里创建变量、把新的函数调用推入栈顶、或者把函数自身分发给Web API调用的专属容器

当函数有了返回值,或者被分发到Web API容器,它就会被弹出栈,同时下一个函数调用会被推入栈顶。

如果JS引擎执行完一个函数,并且该函数没有明确的指明返回值,JS引擎会默认的返回undefined然后再将之弹出栈。人们通常说的JS同步运行指的就是JS引擎解析函数然后弹出栈(再运行下一个函数)的运行流程。简言之,在单线程下同一时间只做一件事。

二、Web API

从栈发送到Web API容器内的Web API调用(比如事件监听函数、HTTP/AJAX请求、或者是定时器函数)会一直在Web API容器内,直到触发操作为止。这些触发操作可能是一个点击事件被触发、或者是HTTP请求完成从数据源获取数据、或者是定时器达到触发的时间点,一旦达到触发条件,一个回调函数就会被推入第四个也是最后一个容器:回调队列

回调队列将按添加的顺序存储所有回调函数。当调用栈函数任务为空时,它会将队列开头的回调函数发送到调用栈。当调用栈函数任务再次清空时,它将发送下一个队列首位的回调函数。

回调队列是一种FIFO(first input first output)先进先出的数据结构。

三、渲染进程

WebAPI处理由栈中弹出后发送来的任务 所使用的线程和JS引擎所使用的的线程是不同的,浏览器有多个进程,其中渲染进程是我们主要关心的,里面又包含了多个线程。

页面的渲染,JS的执行,事件的循环,都在这个渲染进程内进行。

浏览器的渲染进程是多线程的, 主要包含的线程有:

  1. GUI渲染线程
    • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
    • 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
  2. JS引擎线程
    • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
    • JS引擎线程负责解析Javascript脚本,运行代码。
    • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
    • 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
  3. 事件触发线程
    • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
    • 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
    • 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
  4. 定时触发器线程
    • setIntervalsetTimeout所在线程
    • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
    • 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
  5. 异步http请求线程
    • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

至此,我们可以得到整个任务执行流程,这个流程也叫事件循环

2059751-20220628174526663-1156357853.png

四、最后一步,宏任务和微任务

JS中分为两种任务类型:macrotaskmicrotask,在ECMAScript中,microtask称为jobs,macrotask可称为task

  • macrotask(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)

    • 每一个task会从头到尾将这个任务执行完毕,不会执行其它
    • 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染
    • task->渲染->task->...
  • microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务

    • 也就是说,在当前task任务后,下一个task之前,在渲染之前
    • 所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染
    • 也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)

什么样的场景会形成macrotask和microtask呢?

  • macrotask:主代码块,setTimeout,setInterval等(可以看到,事件队列中的每一个事件都是一个macrotask)
  • microtask:Promise,process.nextTick等

macrotask和microtask是由什么控制维护的呢?

  • macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护
  • microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护

五、代入解释答案

  • 执行一个宏任务(栈中没有就从事件队列中获取)

    这个时候打印出执行栈中的同步代码,也就是script start 和script end

  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中

    这个时候,确实在过程中发现了微任务,也就是promise,将这个任务放到微任务队列,此时执行栈中只剩下一个定时器任务,这个任务不会马上执行,他会被放入宏任务队列,执行栈空闲

  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)

    此时短暂空闲的执行栈又插入promise,输出promise1和promise2,然后继续空闲

  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染

  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

    因为执行栈空闲,从宏任务队列中取出已经满足条件的定时器任务,这个时候输出了最后一行setTimeout

over

更多细节 推荐原文1原文2