消息队列和事件循环
要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。
事件循环机制:相比于线性的执行一串任务,事件循环机制通过循环当线程一直循环执行,而通过事件来等待输入任务,此时处于暂停状态,一旦接收到用户输入的信息,那么线程就会被激活,然后执行相加运算并输出结果。
如何接收其他线程发送的消息呢?
一种通用的模式就是使用消息队列。
消息队列是一种数据结构,可以存放要执行的任务。它符合队列的先进先出的特点。
如何处理 IO 线程任务发送到主线程
- 添加一个消息队列
- IO 线程中产生的新任务添加进消息队列尾部。
- 渲染主线程会循环地从消息队列头部中读取任务,执行任务。
如何处理其他进程发送过来的任务
通过消息队列,我们实现了线程之间的消息通信。
而对于跨进程任务,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息。之后就是线程间的通信方式了,和上面一样使用了消息队列和事件循环机制。
消息队列中的任务类型
以下行为都是在主线程中执行的:
- 输入事件
- 微任务
- 文件读写
- WebSocket
- 定时器
- JavaScript 执行
- 解析 DOM
- 样式计算
- 布局计算
- CSS 动画
- ……
如何安全退出
确定要退出当前页面时,会设置一个退出标志变量,在每次执行完任务时(一个循环的结束时),判断是否有设置退出标志。如果设置了,就直接中断当前的所有任务,退出线程。
页面使用单线程的缺点
如何处理高优先级的任务
针对这种情况,微任务就应用而生了,下面我们来看看微任务是如何权衡效率和实时性的。通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。
消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务。
如何解决单个任务执行时长过久的问题
针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行 JavaScript 任务滞后执行。
tips
可以通过 chrome 的开发者工具中的 Performance 工具来查看页面的情况。
关于宏任务和微任务的比喻
宏任务是开会分配的工作内容,微任务是工作过程中被临时安排的内容。
WebAPI
setTimeout 是否如何实现的
在上面讲了消息队列之后,会对理解 setTimeout 会又所帮助。
在 Chrome 中除了正常的消息队列,还有一个消息队列用来维护延迟执行的任务列表。
在事件循环中,每次执行了事件循环后会执行以下延时执行任务列表中的函数(如果有)
clearTimeout 的实现
通过 ID 找到延时队列中对应的任务,并将其从队列中删除掉。
使用 setTimeout 的一些注意事项
- 如果当前任务执行事件过长,会影响延时定时器任务的执行。
- 如果 setTimeout 存在嵌套调用,那么系统会设置最短间隔时间为 4 毫秒。
- 未激活的页面(非当前标签页),setTimeout 执行最小间隔是 1000 毫秒。
- 延时执行时间有最大值,最大值为 2147483647 毫秒,如果超出就等于 0 毫秒。
- 使用 setTimeout 设置的回调函数中的 this 指向全局上下文。解决方法是把函数放在匿名函数中,或者使用 bind 来绑定函数 this 上下文。
评论区笔记
浏览器的页面是通过消息队列和事件循环系统来驱动的。settimeout的函数会被加入到延迟消息队列中,
等到执行完Task任务之后就会执行延迟队列中的任务。然后分析几种场景下面的setimeout的执行方式。
- 如果执行一个很耗时的任务,会影响延迟消息队列中任务的执行
- 存在嵌套带调用时候,系统会设置最短时间间隔为4s(超过5层)
- 未激活的页面,setTimeout最小时间间隔为1000ms
- 延时执行时间的最大值2147483647,溢出会导致定时器立即执行
- setTimeout设置回调函数this会是回调时候对应的this对象,可以使用箭头函数解决
回调函数
将一个函数作为参数传递给另外一个函数,就叫做回调函数。
同步回调:在函数中执行到回调函数,立即执行且执行完成后继续下面的逻辑。
异步回调:在函数中执行的回调函数,回调函数在主函数外部执行,而主函数内不管回调函数执行如何,继续向下执行逻辑。
消息队列和主线程循环机制保证了页面有条不紊地运行。
当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。它可以在执行一个任务的时候执行很多在系统调用栈中执行很多子任务。如解析 HTML 的时候遇到 JavaScript 脚本、样式表,都会用属于解析 HTML 任务的子任务。
同步回调是在当前主函数的上下文中执行回调函数。
异步回调是当前函数在主函数之外执行。一般有两种方式:
- 把异步任务做成一个任务,调价到信息队列尾部。
- 把异步函数添加安东微任务队列中,这样就可以在当前任务的末尾处执行微任务了。
XMLHttpRequest 运作机制
- 创建 xhr 对象
- 为 xhr 对象注册回调函数
- 配置基础的请求信息
- 发起请求
渲染进程会将请求发送给网络进程
网络进程负责资源的下载
等网络进程接收到数据之后,通过 PIC 来通知渲染进程
渲染进程接收到消息后,将 xhr 的回调函数封装成任务添加到消息队列中
等主线程循环系统执行到该任务时,根据相关状态调用对应回调函数。
请求出错,执行 xhr.onerror;请求超时,执行 xhr.ontimeout;请求正常,执行 onreadystatechange 来反馈相应状态。
XHRHttpRequest 的坑
- 跨域问题 —— 非同源域名请求资源会报跨域信息。
- https 混合内容 —— 在 HTTPS 页面里面使用 http 请求会报错。
评论区:如何高效学习安全理论
- why, 知道为什么有这个安全机制, 历史是什么样的
- how,知道why之后自己先想想怎么解决这个问题, 再去看看别人是怎么解决的, 分析利弊
- 学完之后自己上手试试
- 拉个你身边最蠢的小伙伴把这件事给他说明白
宏任务和微任务
微任务可以在实时性和效率之前做一个有效的权衡
宏任务
页面中的大部分任务都是在主线程上执行的,包括了渲染事件、用户交互事件、JavaScript 脚本执行事件、网络请求完成、文件读写完成事件等等。
页面进程中使用了消息队列和循环机制来协调任务有条不紊地进行。渲染进程里有多个消息队列,如延迟执行队列和普通的消息队列。主线程不断地从这些任务队列中取出任务并执行任务。
宏任务的缺点
宏任务呗添加到消息队列中都是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列的位置,也就无法掌控任务执行的时间。
宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的。
微任务
异步回调的两种方式:
- 作为宏任务添加到消息队列的尾部,当循环系统执行到该任务的时候执行回调函数。
- 在主函数执行结束后,当前后任务结束之前执行回调函数,这通常都是以微任务的形式体现的。
微任务是一个需要异步执行的函数,执行时机是在主函数执行结束之后,当前宏任务结束之前。
每个宏任务对应了一个微任务队列。
微任务产生的两种方式
- 使用 MutationObserver 监控某个 DOM 节点,然后通过 JavaScript 来操作 DOM 节点。当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
- 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
这些微任务被保存在了微任务列表中
微任务何时被执行
是在当前宏任务的 JavaScript 快执行完成时,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
循环执行一个一个执行微任务,直到微任务队列中没有微任务了,再进入下一个宏任务。
微任务结论
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
- 微任务的执行时长会影响到当前宏任务的时长。
- 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
监听 DOM 的方法
- 等多次 DOM 变化后,一次触发异步调用回调,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。
- 同时,在监听 DOM 时为了避免宏任务的实时性差的问题,引入了微任务。
- 每次 DOM 节点发生变化时,渲染引擎将变化记录封装成微任务,并将微任务添加到当前微任务列表中,这样当执行到检查点的时候,就会按照微任务列表顺序执行微任务了。
所以 MutationObserver 采用了异步 + 微任务的策略实现了 DOM 的实时监听。通过异步解决性能问题,通过微任务解决实时性问题。
Promise
DOM 和 BOM API 中新加入的 API 大多都是建立在 Promise 上的,而且新的前端框架也使用了大量的 Promise。
Promise 解决的是异步编码的风格问题!
异步编程问题
页面中的任务都是执行在主线程之上的,所以主线程就是页面的整个世界。
所以在执行一项好事任务时,往往会放到页面主线程之外的进程或者线程中去执行,避免耗时任务长期霸占页面主线程。
这就是页面的异步回调。
异步回调导致:代码的逻辑不连贯、不线性,反直觉。
像 xhr 的请求方式就非常麻烦,需要定义各种回调,然后再发送请求。
解决方法是将 xhr 繁琐的网络请求方式封装起来,只保留请求传入和传出的信息。类似于 ajax、axios 的写法。
回调地狱
当 ajax 请求嵌套多次后,就会出现回调地狱。代码看上去很乱。其中的原因如下:
- 嵌套调用。下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑。
- 任务的不确定性。执行每个任务都有两种可能的结果,所以体现在代码中的任务执行结果都要判断两次,增加混乱程度。
所以,我们需要:
- 消灭嵌套调用
- 合并多个任务的错误处理
Promise
好处~
- Promise 实现了回调函数的延时绑定。在
new Promise(executor)
中的 executor 来实现业务逻辑,使用promise.then(onResolve)
来实现回调函数的逻辑。有效将两者分离。 - 将回调函数 onResolve 的返回值穿透到最外层。这样就避免了嵌套的问题。
promise.then(onResolve)
执行返回的还是一个 promise 实例,可以继续使用 then 函数获取上一个 Promise 返回的值。 - Promise 提供了 catch 函数来捕获所有的异常,因为 Promise 对象的错误具有冒泡性质,会一直向后传递,知道被 onReject 函数处理或者 catch 语句捕获为止。
Promise 和微任务
当执行 new Promise 时,Promise 的构造函数会被执行,然后 Promise 的构造函数会调用 Promise 的参数 executor 函数,然后 executor 中执行 resolve。
这里注意 executor 函数中执行 resolve 引起的 onResolve 函数是延时绑定的。
而这个 Promise 的延时触发 onResolve 回调函数就是通过微任务在实现的。
采用微任务来推迟 onResolve 的执行,既可以让 onResolve_ 延时被调用,又提升了代码的执行效率。这就是 Promise 中使用微任务的原由了。
async/await
虽然 Promise 解决了回调代码风格问题。但是过多的 then 还是会让代码阅读带来一些困难。
基于这个原因,ES7 引入了 async/await,提供了在不阻塞主线程的情况下使用同步带吗实现异步访问资源的能力,并且使得代码逻辑更加清晰。
生成器 vs 协程
生成器函数是一个带星号的函数,而且是可以暂停执行和恢复执行的。(也称为 Generator 函数),它通过 yield 来暂停函数,通过函数执行后的 next() 来恢复执行函数。
或者说:
- 生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停函数的执行。
- 外部函数可以通过 next 方法恢复函数的执行。
协程:协程是一种比线程更加轻量级的存在。可以把协程看成是线程上的任务,一个线程上可以存在多个协程,但是线程上同时只能执行一个协程。
比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
所以,生成器函数的暂停和恢复是通过两个协程间的切换来实现的。
- 通过 next 函数切换到生成器协程并执行。
- 通过 yield 关键字来暂停生成器协程执行,并返回主协程。
- 通过 return 关键字来结束当前协程,并将 return 后面的内容返回给父协程。
注意:
- 子协程和父协程都是在主线程上交互执行的。
- 当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。
于是,我们可以通过生成器函数将函数写成同步的形式。我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器。
function* foo() {
let response1 = yield fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1)
let response2 = yield fetch('https://www.geekbang.org/test')
console.log('response2')
console.log(response2)
}
co(foo());
async/await
虽然生成器可以很好地满足需求,但是有了 async/await 就有了更加直观简洁的代码。
async
async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。
这里可以知道 async 函数是会返回一个 Promise对象的。
await
async 和 await 到底做了什么?
- async 定义的函数,执行该函数会进入子协程。
- 将 await 后面的内容封装到一个 Promise 对象的 resolve 函数中。
- 执行 resolve 函数,将任务交给微任务队列。
- 返回父协程,父协程调用 promise.then 来监控 promise 的状态改变。
- 继续执行父协程后续流程。
- 父协程所有流程走完,在结束之前,进入微任务的检查点,执行微任务列表。
- 执行微任务,触发 promise.then 的回调函数
- 回调函数激活后,主线程控制权交给子协程,获取 value 值。
- 将 value 值赋值给 await 前面的变量。
- 子协程继续执行后续语句。
- ……
有点乱,我捋一捋:
- 父协程:正常逻辑是在父协程的。
- 子协程:async 函数中的逻辑是在子协程中的。
- 子协程:遇到 await 创建 Promise 对象,并且执行 resolve 函数。
- 父协程:执行 Promise.then 并将回调放到微任务中。
- 父协程:执行后续逻辑。
- 父协程:执行微任务,返回 resolve 结果。
- 子协程:Promise 的 value 传给协程,进行赋值。
- 子协程:后续逻辑。
- 子协程:再遇到 await 重复行为 2
- 所以如果一个 async 函数中有多个 await 异步行为,那么应该是有多个宏任务的咯?
这里特别要注意父子协程的切换,那个时间点状态是暂停的。所以 3-4 的行为,将 await 的结果赋值给一个变量的行为就暂停了,等到 6-7 的时候继续进行赋值操作!
结尾
这块儿内容对我的收获很大,很多东西我之前了解的都是如何用,没有深入了解过进程、线程、协程这些。看了这几课程之后,对于 JavaScript 的执行顺序、同步、异步这些行为的理解加深了很多。
同时,这块内容也值得反复吸收学习!