浏览器中的页面循环系统

790 阅读5分钟

消息队列和事件循环:页面是怎么“活”起来的?

在线程运行过程中处理新任务

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制

第一版线程模型:如果由一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务

image.png

第二版线程模型:要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统

image.png

第三版线程模型:如果要接收其他线程发送过来的任务,就要引入消息队列

image.png

如果其他进程想要发送任务给页面主线程,那么先通过IPC把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。

消息队列并不是太灵活,为了适应效率和实时性,引入了微任务

浏览器页面是如何运行的

我们可以打开一个网站,从开发者工具来看:

image.png

WebAPI:setTimeout 是如何实现的?

浏览器如何实现 setTimeout

我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。典型事件:

  1. 当接收到 HTML 文档数据,渲染引擎就会将“解析 DOM ”事件添加到消息队列中
  2. 当用户改变了 Web 页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中
  3. 当触发了 JavaScript 引擎回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中
  4. 如果要执行一段异步 JavaScript 代码,也需要将执行任务添加到消息队列中

不过,定时任务比较特别,他需要在指定的时间间隔内被调用。但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能够在指定时间内执行,渲染进程会将定时器的回调任务添加到延迟队列中。

延迟队列的执行时机:处理完消息队列中的一个任务之后,就去执行延迟队列中,根据发起时间和延迟时间计算出到期的任务,然后依次执行到期的任务,等到期的任务执行完成,再继续下一个循环过程。取消的话,JavaScript 引擎直接从延迟队列中通过定时器的 ID 查找到对应的任务,然后将其删除。

使用 setTimeout 的注意事项

如果当前任务执行时间过久,会影响延时到期定时器任务的执行

function bar() {
    console.log('test')
}
function foo() {
    setTimeout(bar, 0)
    for (let i = 0; i < 5000; i++) {
        let i = 5 + 5 + 8 + 8;
        console.log(i)
    }
}
foo()

这里设置了 0 延时的回调任务,通过 setTimeout 设置的回调任务被放入消息队列并在下一次执行,等待当前任务完成,当前执行时间比较长,影响到下个任务的执行。

image.png

如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 ms

var q = 1;
function callb() {
    console.log(q++)
    setTimeout(callb, 0)
}
setTimeout(callb, 0)

通过输出或者 Performance 中看出,前几次会比较快,后面就比较慢了。之所以出现这种,是因为在 Chrome 中,定时器被嵌套 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间小于 4 毫秒,那么浏览器会将时间设置为 4 ms。

image.png

未激活的页面,setTimeout 执行最小间隔是 1000 ms

除去 4 ms 延迟,未被激活得页面定时器最小值大于 1000 ms 。目的是为了优化后台页面的加载损耗以及降低耗电量。

延迟执行时间有最大值,Chrome 以32个 bit 来储存延时值,2^31 ms(约为24.8天),超过就会立即执行

 function bar() {
    console.log('test')
}
setTimeout(bar, 2147483650)

运行后可以发现是立即执行的。

使用 setTimeout 设置的回调函数中的 this 不符合直觉

var name = 'sunny'
var myObj = {
    name: '晴天',
    showName: function () {
        console.log(this.name)
    }
}
setTimeout(myObj.showName, 1000)

这里输出sunny,因为这段代码在编译的时候,执行上下文中的 this 会被设置为全局 window。

那么如何解决呢?

// 使用 bind
setTimeout(myObj.showName.bind(myObj), 1000)

// 箭头函数
setTimeout(() => {
    myObj.showName()
}, 1000)

宏任务和微任务:不是所有任务都是一个待遇

宏任务

页面上大部分任务都是在主线程上执行的,这些任务包括:

  1. 渲染事件(如解析 DOM、计算布局、绘制)
  2. 页面交互事件(如鼠标点击、滚动页面、放大缩小等)
  3. JavaScropt 脚本执行事件
  4. 网络请求完成、文件读写完成事件

为了协调任务有条不紊地在主线程执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

微任务

我们先分析下异步回调,它有两种方式:

  1. 把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数,如 setTimeout
  2. 以微任务的形式来体现,执行时机是在主函数执行结束之后,当前宏任务之前执行回调函数。

微任务,就是一个需要异步执行的函数,执行时机是在主函数执行结束后、当前宏任务结束之前。

微任务的产生方式:

  1. 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,会产生微任务
  2. 使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务

总结点:

  1. 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
  2. 微任务的执行时长会影响当前宏任务的时长
  3. 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行

异步编程:V8 是如何实现微任务的?

function bar() {
    console.log('bar')
    Promise.resolve().then(() => {
        console.log('micro-bar')
    })
    setTimeout(() => {
        console.log('setTimeout-bar')
    }, 0)
}

function apple() {
    console.log('apple')
    Promise.resolve().then(() => {
        console.log('micro-apple')
    })
    setTimeout(() => {
        console.log('setTimeout-apple')
    }, 0)

    bar()
}

apple()

console.log('global')

Promise.resolve().then(() => {
    console.log('micro-global')
})

setTimeout(() => {
    console.log('setTimeout-global')
}, 0)

执行过程:

image.png

image.png

执行结果:

image.png

Promise:使用 Promise,告别回调函数

产生回调地狱的原因:

  1. 多层嵌套
  2. 每种任务的处理结果才能在两种可能性,需要在任务结束后分别处理这两种可能性

Promise 通过回调函数延迟绑定、回调函数返回值穿透和错误“冒泡”技术解决了这两个问题。

Promise 之所以要使用微任务是因为 Promise 回调函数延迟技术

async-await:使用同步的方式去写异步代码