今天的一道面试题(20) - 详细讲述一下浏览器的循环系统

先综述一下:
浏览器在执行js时有一个渲染主线程, 但是其他IO线程, 网络进程等等也可以向主线程发送一些任务, 必须输入输出, 下载等等, 就引入了消息队列

消息队列中存放的是其他线程和进程发送过来的任务, 每一个任务都是一个宏任务, 浏览器在执行时, 每次从队列首部取出最老的一个任务开始执行

setTimeout异步任务 : 存放在延迟消息队列中, 在浏览器执行完消息队列中的一个最老的任务后, 会取出延迟消息队列中到期的延迟任务, 开始执行. (所以setTimeout可能会有延迟, 不一定到期之后就会立刻被执行)

微任务就是在消息队列中的每个宏任务被执行时, 浏览器都会为其创建该宏任务对应的微任务队列, 宏任务执行完毕会执行其对应的微任务. 所以微任务的执行时间也会影响到宏任务的总体执行时长

下面详细讲述( 浏览器工作原理与实践 的笔记)

文章目录

消息队列和事件机制

渲染进程下有一个渲染主线程主要来执行任务, 还可以IO线程来添加任务

一个比较好的接收其他线程发送的消息的线程模型就是 - 消息队列

消息队列

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LnqqbxQF-1636881722554)(http://blog.poetries.top/img-repo/2019/11/29.png)]

流程如下:

  1. 添加一个消息队列;
  2. IO 线程中产生的新任务添加进消息队列尾部;
  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

IO线程也用来接收其他进程发送过来的网络消息, 比如网络资源下载完成等
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4GnhAKJd-1636881722556)(http://blog.poetries.top/img-repo/2019/11/30.png)]

消息队列中的任务类型

消息队列中的任务类型主要有: 输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。

除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,你还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。

微任务的引入

在消息队列中的所有任务都是单线程的, 必须要等前一个任务完成之后才能去执行下一个任务.

这个时候引入了微任务, 消息队列中的每个任务都是一个宏任务, 每个宏任务都包含一个微任务队列. 如果有高优先级的任务可以放在某个宏任务对应的微任务队列中

setTimeout的实现

setTimeout是一个定时器, 指定多少毫秒之后执行. 如果我们想要执行一段异步任务, 不能立刻将其加入任务队列, 而是要在指定时间之后将其加入到任务队列

延迟消息队列

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务

所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中

void ProcessTimerTask(){
  // 从 delayed_incoming_queue 中取出已经到期的定时器任务
  // 依次执行这些任务
}
 
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
    // 执行消息队列中的任务
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    // 执行延迟队列中的任务
    ProcessDelayTask()
 
    if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
        break; 
  }
}

在上段代码中,处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

setTimeout的问题

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

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

即使setTimeout的延迟时间是0, 但是由于消息队列中的任务for循环执行时间太久会阻碍延迟消息任务的执行, for循环执行大约花费500ms, 所以setTimeout会在500ms后执行

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

function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yfx1e4AL-1636881722558)(http://blog.poetries.top/img-repo/2019/11/34.png)]

上图中的竖线就是定时器的函数回调过程,从图中可以看出,前面五次调用的时间间隔比较小,嵌套调用超过五次以上,后面每次的调用最小时间间隔是 4 毫秒。之所以出现这样的情况,是因为在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒

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

除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。这一点你在使用定时器的时候要注意。

延时执行时间有最大值

那就是 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行。

function showName(){
  console.log(" 极客时间 ")
}
var timerID = setTimeout(showName,2147483648);// 会被理解调用执行

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

如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。这点在前面介绍 this 的时候也提过,你可以看下面这段代码的执行结果:

var name= 1;
var MyObj = {
  name: 2,
  showName: function(){
    console.log(this.name);
  }
}
setTimeout(MyObj.showName,1000)

这里输出的是 1,因为这段代码在编译的时候,执行上下文中的 this 会被设置为全局 window,如果是严格模式,会被设置为 undefined。

那么该怎么解决这个问题呢?通常可以使用下面这两种方法。

第一种是将MyObj.showName放在匿名函数中执行,如下所示:

// 箭头函数
setTimeout(() => {
    MyObj.showName()
}, 1000);
// 或者 function 函数
setTimeout(function() {
  MyObj.showName();
}, 1000)

第二种是使用 bind 方法,将 showName 绑定在 MyObj 上面,代码如下所示:

setTimeout(MyObj.showName.bind(MyObj), 1000)

宏任务和微任务

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

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。

宏任务的执行过程

  1. 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  2. 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  3. 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
  4. 最后统计执行完成的时长等信息。

宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求

微任务

异步回调

  • 第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数
  • 第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。

微任务的执行过程

  1. 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  2. 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以-你在写代码的时候一定要注意控制微任务的执行时长。
  3. 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

Promise告别回调函数

异步编程模型
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5a5lW97z-1636881722561)(http://blog.poetries.top/img-repo/2019/11/43.png)]

使用promise封装xhr

第一步封装XHR

//makeRequest 用来构造 request 对象
function makeRequest(request_url) {
    let request = {
        method: 'Get',
        url: request_url,
        headers: '',
        body: '',
        credentials: false,
        sync: true,
        responseType: 'text',
        referrer: ''
    }
    return request
}
//[in] request,请求信息,请求头,延时值,返回类型等
//[out] resolve, 执行成功,回调该函数
//[out] reject  执行失败,回调该函数
function XFetch(request, resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.ontimeout = function (e) { reject(e) }
    xhr.onerror = function (e) { reject(e) }
    xhr.onreadystatechange = function () {
        if (xhr.status = 200)
            resolve(xhr.response)
    }
    xhr.open(request.method, URL, request.sync);
    xhr.timeout = request.timeout;
    xhr.responseType = request.responseType;
    // 补充其他请求信息
    //...
    xhr.send();
}
XFetch(makeRequest('https://time.geekbang.org'),
  function resolve(data) {
      console.log(data)
  }, function reject(e) {
      console.log(e)
  })

第二步解决回调地狱问题

function XFetch(request) {
  function executor(resolve, reject) {
      let xhr = new XMLHttpRequest()
      xhr.open('GET', request.url, true)
      xhr.ontimeout = function (e) { reject(e) }
      xhr.onerror = function (e) { reject(e) }
      xhr.onreadystatechange = function () {
          if (this.readyState === 4) {
              if (this.status === 200) {
                  resolve(this.responseText, this)
              } else {
                  let error = {
                      code: this.status,
                      response: this.response
                  }
                  reject(error, this)
              }
          }
      }
      xhr.send()
  }
  return new Promise(executor)
}
var x1 = XFetch(makeRequest('https://time.geekbang.org/?category'))
var x2 = x1.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://www.geekbang.org/column'))
})
var x3 = x2.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://time.geekbang.org'))
})
x3.catch(error => {
    console.log(error)
})
  • 首先我们引入了 Promise,在调用 XFetch 时,会返回一个 Promise 对象
  • 构建 Promise 对象时,需要传入一个executor 函数,XFetch 的主要业务流程都在 executor 函数中执行。
  • 如果运行在 excutor 函数中的业务执行成功了,会调用 resolve 函数;如果执行失败了,则调用 reject 函数。
  • 在 excutor 函数中调用 resolve 函数时,会触发 promise.then 设置的回调函数;而调用 reject 函数时,会触发 promise.catch 设置的回调函数

模拟实现promise的构造函数

由于Promise是由V8实现的, 所以手动模拟实现以下, 起名为Bromise

function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
     // 模拟实现 resolve 和 then,暂不支持 rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
          //setTimeout(()=>{
            onResolve_(value)
           // },0)
    }
    executor(resolve, null);
}

执行下面这段测试代码

function executor(resolve, reject) {
    resolve(100)
}
// 将 Promise 改成我们自己的 Bromsie
let demo = new Bromise(executor)
 
function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

会报错

Uncaught TypeError: onResolve_ is not a function
    at resolve (<anonymous>:10:13)
    at executor (<anonymous>:17:5)
    at new Bromise (<anonymous>:13:5)
    at <anonymous>:19:12

是由于 Bromise 的延迟绑定导致的,在调用到 onResolve_ 函数的时候,Bromise.then 还没有执行

所以在执行onResolve_的时候, 可以使用setTimeout延迟执行

function resolve(value) {
          setTimeout(()=>{
              onResolve_(value)
            },0)
    }
上一篇:【清北学堂周末刷题班】 Day5


下一篇:封装防抖与节流