Open lei4519 opened 7 months ago
2020-10-02
如果上面这个问题你都知道了,那你对事件循环的理解在日常工作中就够用了,但还是建议看一下文章,因为会讲一些原理性的知识。
想把事件循环讲明白,就绕不过浏览器的进程和线程。
异步代码是什么?从哪里来的?
浏览器的主进程(负责协调、主控)
负责渲染工作
渲染线程的工作流程
GUI 渲染线程与 JS 执行线程是互斥的,一个执行的时候另一个就会被挂起。
常说的 JS 脚本加载和执行会阻塞 DOM 树的解析,指的就是互斥现象。
document.body.style.color = "#000" document.body.style.color = "#001"
document.body.style.color = '#002'
``` - 如果JS线程的当前宏任务执行时间过长,就会导致页面渲染不连贯,给用户的感觉就是页面卡顿。 - `1000毫秒 / 60帧 = 16.6毫秒`
负责执行 Javascript 代码,V8 引擎指的就是这个。
JS 引擎在执行代码时,会将需要执行的代码块当成一个个任务,放入任务队列中执行,JS 引擎会不停的检查并运行任务队列中任务。
// html <script> console.log(1) console.log(2) console.log(3) </script> // 将需要执行的代码包装成一个任务 const task = () => { console.log(1) console.log(2) console.log(3) } // 放入任务队列 pushTask(task)
JS 引擎执行逻辑:伪代码(所有的伪代码都是为了理解写的,并不是浏览器的真实实现):
// 任务队列 const queueTask = [] // 将任务加入任务队列 export const pushTask = (task) => queueTask.push(task) while (true) { // 不停的去检查队列中是否有任务 if (queueTask.length) { // 队列:先进先出 const task = queueTask.shift() task() } }
事件监听触发
document.body.addEventListener('click', () => {})
伪代码:
// JS线程 -> 监听事件 function addEventListener(eventName, callback) { sendMessage("eventTriggerThread", this, eventName, callback) } // 事件触发线程 -> 监听元素对应事件 // 事件触发线程 -> 元素触发事件 function trigger(callback) { pushTask(callback) }
定时器 setInterval 与 setTimeout 所在线程
// JS线程 -> 开始计时 function setTimeout(callback, timeout) { sendMessage("timerThread", callback, timeout) } // 定时器线程 -> 设定定时器开始计时 // 定时器线程 -> 计时器结束 function trigger(callback) { pushTask(callback) }
Ajax、fetch 请求
// JS线程 -> 开始请求 XMLHttpRequest.send() sendMessage("netWorkThread", options, callback) // 网络线程 -> 开始请求 // 网络线程 -> 请求响应成功 function trigger(callback) { pushTask(callback) }
在地址栏输入 URL,请求 HTML,浏览器接受到响应结果,将 HTML 文本交给渲染线程,渲染线程开始解析 HTML 文本。
... </div> <script> document.body.style.color = '#f40' document.body.addEventListener('click', () => {}) setTimeout(() => {}, 100) ajax('/api/url', () => {}) </script> </body>
渲染线程解析过程中遇到 <script> 标签时,会把 <script> 中的代码包装成一个任务,放入 JS 引擎中的任务队列中,并挂起当前线程,开始运行 JS 线程。
<script>
pushTask(<script>)
JS 线程检查到任务队列中有任务,就开始执行任务。
第一个宏任务执行完成,执行写操作队列(渲染页面)
while (true) { if (queueTask.length) { const task = queueTask.shift() task() requestAnimationFrame() // 执行写操作队列后进行渲染 render() // 检查空闲时间是否还够 requestIdleCallback() } }
第一个任务就完全结束了,任务队列回到空的状态,第一个任务中注册了 3 个异步任务,但是这对 JS 引擎不会关心这些,它要做的就是接着不停的循环检查任务队列。
为了简化流程,假设三个异步任务同时完成了,此时任务队列中就有了 3 个任务
// 任务队列 const queueTask = [addEventListener, setTimeout, ajax]
但是不管有多少任务,都会按照上面的流程进行循环重复的执行,这整个流程被称为事件循环。
上面说的是 ES6 之前的事件循环,只有一个任务队列,很好理解。
在 ES6 标准中,ECMA 要求 JS 引擎在事件循环中加入了一个新的队列:微任务队列
实际功能:Vue 为了性能优化,对响应式数据的修改并不会立即触发视图渲染,而是会放到一个队列中统一异步执行。(JS 引擎对 GUI 线程写操作的思想)
那怎么实现这个功能呢?想要异步执行,就需要创建一个异步任务,setTimeout 是最合适的。
// 响应式数据修改 this.showModal = true // 记录需要重新渲染的视图 const queue = [] const flag = false // 触发setter function setter() { // 记录需要渲染的组件 queue.push(this.render) if (flag) return flag = true setTimeout(() => { queue.forEach((render) => render()) flag = false }) }
这样实现有什么问题呢?
用上面的例子,现在任务队列里有三个任务,在第一个任务 addEventListener 中进行了 Vue 响应式修改。
addEventListener
假设 setTimeout 立即就完成了,那么现在的任务队列如下:
// 任务队列 const queueTask = [addEventListener, setTimeout, ajax, vueRender]
这个结果符合任务队列的运行逻辑,但却不是我们想要的。
因为视图更新的代码太靠后了,要知道每次任务执行之后并不是立即执行下一个任务,而是会执行 requestAnimationFrame、渲染视图、检查剩余时间执行 requestIdleCallback 等等一系列的事情。
requestAnimationFrame
requestIdleCallback
按这个执行顺序,vueRender 的代码会在页面渲染两次之后才执行。
我们想要实现的效果是这个异步代码最好是在当前任务执行完就执行,理想的任务队列是下面这样。
// 任务队列 const queueTask = [addEventListener, vueRender, setTimeout, ajax]
相当于要给宏任务队列加入插入队列的功能,但是如果这么改,那就整个乱套了。之前的异步任务还有个先来后到的顺序,先加入先执行,这么一改,异步任务的顺序就完全无法控制了。
上面的问题总结来说
既然之前的任务队列逻辑不能动,那不如就加个新队列:微任务队列。
JS 引擎自己创建的异步任务,就往这个微任务队列里放。通过别的线程创建的异步任务,还是按老样子放入之前的队列中(宏任务队列)。
微任务队列,会在宏任务执行之后被清空执行。
加入了微任务队列之后,JS 引擎的代码实现:
// 宏任务队列 const macroTask = [] // 微任务队列 const microTask = [] while (true) { if (macroTask.length) { const task = macroTask.shift() task() // 宏任务执行之后,清空微任务队列 while (microTask.length) { const micro = microTask.shift() micro() } requestAnimationFrame() render() requestIdleCallback() } }
注意 while 循环的实现,只要微任务队列中有任务,就会一直执行直到队列为空。也就是说如果在微任务执行过程中又产生了微任务(向微任务队列中 push 了新值),这个新的微任务也会在这个 while 循环中被执行
// 微任务队列 = [] Promise.resolve().then(() => { console.log(1) Promise.resolve().then(() => { console.log(2) }) }) // 微任务队列 = [log1函数体] // log1函数体 = 微任务队列.shift() // 微任务队列 = [] // log1函数体() // 微任务队列 = [log2函数体] // log2函数体 = 微任务队列.shift() // 微任务队列 = [] // 渲染视图
以上就是为什么要有微任务队列,以及微任务队列的运行逻辑。
浏览器中可以产生微任务异步代码的 API:Promise.prototype.then、MutationObserver、setImmediate(IE、nodejs)、MessagePort.onmessage
Promise.prototype.then
MutationObserver
setImmediate(IE、nodejs)
MessagePort.onmessage
Vue 渲染视图的异步代码就是放在微任务队列中的。
Vue2 的 nextTick 实现为:Promise -> setImmediate -> MessagePort.onmessage -> setTimeout
API 介绍:使用 nextTick 注册的代码会在 DOM 更新之后被调用。
nextTick 的实现比我们想的要简单的多,尤其是我们已经了解了微任务的执行逻辑。
// 记录需要重新渲染的视图 const queue = [] const flag = false // 触发setter function setter() { // 记录需要渲染的组件 queue.push(this.render) if (flag) return flag = true // setTimeout 换成了 Promise, 将异步任务注册进微任务队列中 Promise.resolve().then(() => { queue.forEach((render) => render()) flag = false }) } // 微任务队列:[] this.showModal = true // 微任务队列:[vueRender] this.$nextTick(() => {}) // 微任务队列:[vueRender, nextTickCallback]
Vue3 的 nextTick(支持 Proxy 的浏览器不可能不支持 Promise)
const resolvedPromise = Promise.resolve() export function nextTick(fn) { return fn ? resolvedPromise.then(fn) : p }
前言
如果上面这个问题你都知道了,那你对事件循环的理解在日常工作中就够用了,但还是建议看一下文章,因为会讲一些原理性的知识。
概述
浏览器进程与线程
想把事件循环讲明白,就绕不过浏览器的进程和线程。
Chrome 的多进程架构
Browser 进程
浏览器的主进程(负责协调、主控)
插件进程
GPU 进程:用于 3D 绘制等
Renderer 进程(浏览器内核)
渲染进程(浏览器内核)中的线程
GUI 渲染线程
渲染线程的工作流程
GUI 渲染线程与 JS 执行线程是互斥的,一个执行的时候另一个就会被挂起。
常说的 JS 脚本加载和执行会阻塞 DOM 树的解析,指的就是互斥现象。
document.body.style.color = '#002'
JS 引擎线程
JS 引擎在执行代码时,会将需要执行的代码块当成一个个任务,放入任务队列中执行,JS 引擎会不停的检查并运行任务队列中任务。
JS 引擎执行逻辑:伪代码(所有的伪代码都是为了理解写的,并不是浏览器的真实实现):
事件触发线程
document.body.addEventListener('click', () => {})
伪代码:
定时触发器线程
伪代码:
异步 Http 请求线程
伪代码:
异步任务是什么?从哪来的?
示例:任务队列的运行过程
在地址栏输入 URL,请求 HTML,浏览器接受到响应结果,将 HTML 文本交给渲染线程,渲染线程开始解析 HTML 文本。
渲染线程解析过程中遇到
<script>
标签时,会把<script>
中的代码包装成一个任务,放入 JS 引擎中的任务队列中,并挂起当前线程,开始运行 JS 线程。JS 线程检查到任务队列中有任务,就开始执行任务。
第一个宏任务执行完成,执行写操作队列(渲染页面)
第一个任务就完全结束了,任务队列回到空的状态,第一个任务中注册了 3 个异步任务,但是这对 JS 引擎不会关心这些,它要做的就是接着不停的循环检查任务队列。
为了简化流程,假设三个异步任务同时完成了,此时任务队列中就有了 3 个任务
但是不管有多少任务,都会按照上面的流程进行循环重复的执行,这整个流程被称为事件循环。
微任务队列
上面说的是 ES6 之前的事件循环,只有一个任务队列,很好理解。
在 ES6 标准中,ECMA 要求 JS 引擎在事件循环中加入了一个新的队列:微任务队列
宏任务队列的问题
实际功能:Vue 为了性能优化,对响应式数据的修改并不会立即触发视图渲染,而是会放到一个队列中统一异步执行。(JS 引擎对 GUI 线程写操作的思想)
那怎么实现这个功能呢?想要异步执行,就需要创建一个异步任务,setTimeout 是最合适的。
这样实现有什么问题呢?
用上面的例子,现在任务队列里有三个任务,在第一个任务
addEventListener
中进行了 Vue 响应式修改。假设 setTimeout 立即就完成了,那么现在的任务队列如下:
这个结果符合任务队列的运行逻辑,但却不是我们想要的。
因为视图更新的代码太靠后了,要知道每次任务执行之后并不是立即执行下一个任务,而是会执行
requestAnimationFrame
、渲染视图、检查剩余时间执行requestIdleCallback
等等一系列的事情。按这个执行顺序,vueRender 的代码会在页面渲染两次之后才执行。
我们想要实现的效果是这个异步代码最好是在当前任务执行完就执行,理想的任务队列是下面这样。
相当于要给宏任务队列加入插入队列的功能,但是如果这么改,那就整个乱套了。之前的异步任务还有个先来后到的顺序,先加入先执行,这么一改,异步任务的顺序就完全无法控制了。
上面的问题总结来说
解决方案
既然之前的任务队列逻辑不能动,那不如就加个新队列:微任务队列。
JS 引擎自己创建的异步任务,就往这个微任务队列里放。通过别的线程创建的异步任务,还是按老样子放入之前的队列中(宏任务队列)。
微任务队列,会在宏任务执行之后被清空执行。
加入了微任务队列之后,JS 引擎的代码实现:
注意 while 循环的实现,只要微任务队列中有任务,就会一直执行直到队列为空。也就是说如果在微任务执行过程中又产生了微任务(向微任务队列中 push 了新值),这个新的微任务也会在这个 while 循环中被执行
以上就是为什么要有微任务队列,以及微任务队列的运行逻辑。
浏览器中可以产生微任务异步代码的 API:
Promise.prototype.then
、MutationObserver
、setImmediate(IE、nodejs)
、MessagePort.onmessage
Vue 渲染视图的异步代码就是放在微任务队列中的。
Vue.nextTick
nextTick 的实现比我们想的要简单的多,尤其是我们已经了解了微任务的执行逻辑。
Vue3 的 nextTick(支持 Proxy 的浏览器不可能不支持 Promise)
问题答案