闲言
其实我最开始写这篇博客,是想和大家聊聊关于 Js 定时器的精准度的,但是发现要把这事说得清清楚楚会涉及到很多基础概念,因此我系统地给大家介绍一下 Js 运行机制。
浏览器内核
浏览器内核其实是浏览器的一个渲染进程,它是多线程的。给大家介绍一些常驻线程:
GUI 渲染线程
负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。
当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
注意,GUI 渲染线程与 Js 引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 Js 引擎空闲时立即被执行。
Js 引擎线程
也称为 Js 内核,负责处理 Js 脚本程序(例如V8引擎)。
Js 引擎线程负责解析 Js 脚本,运行代码。
Js 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer进程)中无论什么时候都只有一个 Js 线程在运行 Js 程序
同样注意,GUI 渲染线程与 Js 引擎线程是互斥的,所以如果 Js 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,Js 引擎自己都忙不过来,需要浏览器另开线程协助)
当 Js 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等),会将对应任务添加到事件线程中
当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
注意,由于 Js 的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当 Js 引擎空闲时才会去执行)
定时触发器线程
传说中的 setInterval 与 setTimeout 所在线程
浏览器定时计数器并不是由 Js 引擎计数的,(因为 Js 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 Js 引擎空闲后执行)
注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。
异步 http 请求线程
在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求
将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 Js 引擎执行。
Js 运行机制
浏览器的 Event Loop(事件循环)
Js 语言的核心特性是单线程。虽然 HTML5 提出 Web Worker 标准,允许 Js 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 Js 单线程的本质。
由于单线程这个特性,为了充分利用 CPU,所有任务分成两种,一种是同步任务(主线程上执行),一种是异步任务(有运行结果后会向任务队列添加事件)。
主线程和任务队列的示意图:
主线程上有一个执行栈,一旦同步任务执行完毕,系统就会去读取任务队列,那些对应的异步任务会结束等待状态,进入执行栈并开始执行。这个过程会不断重复,整个的这种运行机制又称为 Event Loop(事件循环)
浏览器的 Event Loop 遵循的是 HTML5 标准,而 NodeJs 的 Event Loop 遵循的是 libuv
具体大家可以参考:再谈Event Loop
Event Loop 如何处理宏任务与微任务
Js 中分为两种任务类型:macrotask(宏任务) 和 microtask(微任务),在 ECMAScript 中,microtask 称为 jobs,macrotask 可称为 task。
我将 Event Loop 循环进行了拓展,如图:
Task
严格按照时间顺序压栈和执行的,所以浏览器能够使得 Js 内部任务与 DOM 任务能够有序的执行。当一个 task 执行结束后,在下一个 task 执行开始前,浏览器可以对页面进行重新渲染。每一个 task 都是需要分配的,例如:从用户的点击操作到一个点击事件、渲染 HTML 文档、setTimeout等。
Microtask
在 task 执行结束后立即执行的任务,例如:需要对一系列的任务做出回应,或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。microtask 任务队列是一个与 task 任务队列相互独立的队列,microtask 任务将会在每一个 task 任务执行结束之后执行。每一个 task 中产生的 microtask 都将会添加到 microtask 队列中,microtask 中产生的 microtask 将会添加至当前队列的尾部,并且 microtask 会按顺序处理完队列中的所有任务。microtask 类型的任务目前包括了 Promise 的回调函数等。
在何种场景会形成 macrotask 和 microtask ?
macrotask:主代码块,setTimeout,setInterval等(我们可以看到,事件队列中的每一个事件都是一个 macrotask)
microtask:Promise,process.nextTick(node.js相关)等
运行机制总结
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
说完概念,我们来看几个代码示例。
代码示例
示例一:
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');
分析:
由此可知,log 顺序:
script start
script end
promise1
promise2
setTimeout
更为详细的分析,大家可以参考这位大神的文章:Tasks, microtasks, queues and schedules
示例二:
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1();
new Promise (function (resolve) {
console.log('promise1')
resolve();
}).then(function () {
console.log('promise2')
});
console.log('script end')
/*
script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout
*/
其实本题大家只要了解 async await 的话,很快就能明白为什么是这个答案。
分析:
async
带 async 关键字的函数,它使得你的函数的返回值必定是 Promise 对象。若 async 关键字函数 return 的不是promise,会自动用 Promise.resolve() 包装。如果 async 关键字函数显式地返回 Promise,那就以其为准。
await
它等是右侧「表达式」的结果,await 会阻塞后面的代码,先执行 async 外面的同步代码,等同步代码执行完,再回到 async 内部。此时右侧「表达式」的结果:
1.是 Promise 对象,等着 Promise 对象 fulfilled,然后把 resolve 的参数作为 await 表达式的运算结果。
2.不是 Promise 对象,把这个非 Promise 的东西,作为 await表达式的结果
因此,async1 end 之所以在 setTimeout 前,是因为执行 await async2() 时,阻塞了 console.log(‘async1 end’),它作为下个宏任务添加到任务队列中,因此队列中,它是排在 setTimeout 的回调前的。
总结
相信大家看到这里,已经对于 Js 运行机制有一个大体的了解了。那么对于 setTimeout、setInterval 这两个计时器,大家可能发现了,它不一定按我们设置的时延精准地去执行回调。在下一篇文章中,我会详细给大家讲讲。
参考文章
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
8张图帮你一步步看清 async/await 和 promise 的执行顺序