08 浏览器中的页面循环系统

702 阅读26分钟

1. 消息队列和事件循环

每个渲染进程都有一个主线程,主线程非常繁忙,既要处理DOM、计算样式,还要处理布局,同时还要注意JavaScript任务和各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,需要一个系统来统筹调度。这也是消息队列和事件循环系统出现的原因。

接下来让我们从最简单的场景开始分析,一步一步了解浏览器页面主线程是如何运作的。

1.1 使用单线程处理安排好的任务

假如有一系列下列这些任务:

任务1:1+2
任务2:21 / 7
任务3:7*8
任务4:打印前3个任务的结果

要在一个线程中执行这些任务,我们通常会这样编写代码:

function MainThread() {
    let num1 = 1+2
    let num2 = 21/7
    let num3 = 7*8
    console.log(num1,num2,num3)
}

它的执行过程参考下图: 8-1.png

1.2 在线程执行过程中执行新任务

但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算“21+6”,那么该怎么做呢?

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

我们可以用一个循环来监听是否有新的任务。

// 等待用户从键盘输入一个数字,并返回
function getInput() {
    return prompt("请输入一个数字:")
}

// 主线程 (Main Thread)
function MainThread() {
    ... // 之前的任务
    while(true) {
        let firstNum = getInput()
        let secondNum = getInput()
        let sum = firstNum + secondNum
        console.log("最终的计算结果为:"+sum)
    }
}

相较于之前,这一版引入了循环机制,线程会一直循环执行。还引入了事件,可以运行在线程之中。等待过程线程处于暂停状态,一旦接收到新的任务,线程就会被激活,执行相应的任务。 8-2.png

1.3 处理其他线程发过来的任务

上述我们考虑的新加的任务都是来自线程内部的,但如果遇到了外部线程发来的任务应该怎么处理呢?接下来进一步对上述模型进行改造。 8-3.png 渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行DOM 解析;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。

如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?

一个通用模式是使用消息队列8-4.png 我们的改造可以分为下面三个步骤:

  1. 添加一个消息队列;

  2. IO 线程中产生的新任务添加进消息队列尾部;

  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

创建一个队列:

function getElement(arr, items) {
  Array.from(arr).forEach((item) => {
    if (item instanceof Array) {
      getElement(item.reverse(), items)
    } else {
      items[items.length] = item
    }
  })
}

function TaskQueue() {
  // 基于数组实现
  this.items = []

  // 创建的时候传入可迭代结构怎么办?不能直接初始化嘛

  //1.添加新的项
  TaskQueue.prototype.enqueue = function() {
    getElement(arguments, this.items.reverse())
    this.items.reverse()
  }

  //2.移除操作,返回被移除的项
  TaskQueue.prototype.dequeue = function() {
    let temp = this.items[this.items.length - 1]
    this.items.length--
      return temp
  }

  // 3.返回队列的第一个元素
  TaskQueue.prototype.front = function() {
    return this.items[this.items.length - 1]
  }

  // 4.判断队列是否为空
  TaskQueue.prototype.isEmpty = function() {
    return this.items.length == 0
  }

  // 5.返回队列大小
  TaskQueue.prototype.size = function() {
    return this.items.length
  }

  // 6.将队列中的内容转换为字符串
  TaskQueue.prototype.toString = function(punctuation) {
    return this.items.join(punctuation)
  }

}

改造一下主线程:

// 主线程 (Main Thread)
function MainThread() {
    ... // 之前的任务
    while(true) {
        let task = TaskQueue.dequeue()
        ProcessTask(task)
    }
}

如果有其他线程想要发送任务让主线程执行,只需将任务添加到该消息队列中:

let clickTask;
TaskQueue.enqueue(clickTask)

由于是多个线程操作同一个消息队列,所以在添加任务和取出任务时还会加上一个同步锁。

1.4 处理其他进程发过来的任务

在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?你可以参考下图: 8-5.png 渲染进程专门有一个IO线程来接收其他进程传进来的信息,接收到信息之后,会把这些信息封装成任务发送给渲染主进程。后续的步骤就和之前一样了。

1.5 消息队列中的任务类型

这里面包含了很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析DOM、样式计算、布局计算、CSS 动画等。

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

当页面主线程执行完成之后,是如何退出的呢?

Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如果设置了,那么就直接中断当前的所有任务,退出线程。

1.6 页面使用单线程的缺点

1.6.1 如何处理高优先级的任务

一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。因此DOM的变化应当首先被执行。

一个通用的设计的是利用 JavaScript设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式

这个模式存在一些问题,因为DOM通常都变化的十分频繁。每次变化都调用JavaScript接口会导致该次任务执行的时间拉长,导致执行效率的降低。如果将这种改变做成异步的消息事件添加到消息队列的尾部,又会影响到监控的实时性,因为此刻可能已经由很多任务已经在排队了。

为了权衡效率和实时性,出现了微任务。

通常我们把消息队列中的任务称为宏任务,每个宏任务中又包含一个微任务队列。在执行宏任务的过程中,如果有DOM变化,那么就会将变化添加到微任务列表中,等宏任务中的主要功能直接完成之后,渲染引擎不着急去执行下一个宏任务,而是执行当前宏任务中的微任务队列,这样就解决了实时性问题。

1.6.2 如何解决单个任务执行时间过长的问题

8-6.png 如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。针对这种情况,JavaScript 可以通过回调来规避这种问题,也就是让要执行的JavaScript 任务滞后执行

1.7 浏览器页面是如何运行的

你可以打开开发者工具,点击“Performance”标签,选择左上角的“start porfiling and load page”来记录整个页面加载过程中的事件执行情况,如下图所示: 8-7.png 我们点击展开了 Main 这个项目,其记录了主线程执行过程中的所有任务。图中灰色的就是一个个任务,每个任务下面还有子任务,其中的 Parse HTML 任务,是把 HTML 解析为 DOM 的任务。值得注意的是,在执行 Parse HTML 的时候,如果遇到JavaScript 脚本,那么会暂停当前的 HTML 解析而去执行 JavaScript 脚本。

2. 事件循环的应用

经过上述介绍我们已经明白,浏览器页面是由消息队列和事件循环系统来驱动的

那么接下来我们通过setTimeoutXMLHttpRequest这两个 WebAPI 来介绍事件循环的应用。

2.1 setTimeout是如何实现的

2.1.1 浏览器怎么实现setTimeout

渲染进程所有运行在主线程上的任务都要首先添加到消息队列,然后事件循环系统按照顺序执行消息队列中的任务。那么有哪些典型的事件呢?

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

回调函数是在指定的时间间隔内被调用,但是消息队列中的任务是按照顺序被执行的,所以定时器中的任务不能直接添加到消息队列中。那么怎么在消息循环系统的基础上添加定时器的功能呢?

其实在Chrome中,除了正常使用的消息队列外,还有一个HashMap,其中维护了需要延迟执行的任务,包括定时器和Chromium内部需要延迟执行的任务。当通过JavaScript创建一个定时器的时候,渲染进程会将该定时器中的回调任务添加到该HashMap中。

通过JavaScript调用setTimeout设置回调函数的时候,渲染进程会创建一个回调任务,包含了回调函数本身、当前发起时间、延迟执行时间。

let delayedIncomingTask = new HashMap() // 创建延迟执行任务的HashMap
function DelayTask(callback,delayTime) {
    this.id = xxx // 指定一个id
    this.startTime = Date.now() // 当前发起时间
    this.delayTime = delayTime // 延迟执行时间
    this.cbf = callback // 回调函数
}

let timerTask = new DelayTask(function(){
    console.log('timerTask')
},1000) // 创建回调任务

delayedIncomingTask.push(timerTask) // 将该任务添加到延迟执行HashMap中

参考一下上述事件循环的代码:

// 主线程 (Main Thread)
function MainThread() {
    ... // 之前的任务
    
    while(true) {
        let task = TaskQueue.dequeue()
        // 执行消息循环中的任务
        ProcessTask(task)
        // 执行延迟HashMap中的任务
        delayedIncomingTask()
        
        // 如果设置了退出标志,那么直接退出线程循环
        if(!keep_running) 
		   break;
    }
}

从上述代码可以看出,渲染进程主线程处理完消息队列中的一个宏任务之后,就开始执行delayedIncomingTask函数,delayedIncomingTask根据startTime,和delayTime计算出到期的任务,然后依次执行该任务。执行完毕就进入下次循环过程。

浏览器内部实现取消定时器是通过 ID 查找到对应的任务,然后再将其从HashMap中删除。

2.1.2 使用setTimeout的一些注意事项

a. 当前任务执行过久,会影响到定时器任务的执行
let hello = function() {
    console.log('hello world!')
}

function foo() {
    setTimeout(hello,0)
    for(let i = 0; i < 5000; i++) {
        let result = i*2
        console.log(result)
    }
}

打开Chrome的Performance查看一下执行情况 8-8.png 执行 foo 函数所消耗的时长是 500 毫秒,这也就意味着通过setTimeout 设置的任务会被推迟到 500 毫秒以后再去执行,而设置 setTimeout 的回调延迟时间是 0

b. setTimeout存在嵌套调用,系统会设置最短时间间隔为4ms
setTimeout(function() {
  console.log(Date.now());
  setTimeout(function() {
    console.log(Date.now());
    setTimeout(function() {
      console.log(Date.now());
      setTimeout(function() {
        console.log(Date.now());
        setTimeout(function() {
          console.log(Date.now());
          setTimeout(function() {
            console.log(Date.now());
              // ....
          }, 1)
        }, 1)
      }, 1)
    }, 1)
  }, 1)
}, 1)

通过 Performance 来记录下这段代码的执行过程,如下图所示: 8-9.png 嵌套调用超过五次以上,后面每次的调用最小时间间隔是 4 毫秒。这是因为在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了。如果嵌套定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为4 毫秒。

也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间

setTimeout(function() {
  console.log(Date.now());
  setTimeout(function() {
    console.log(Date.now());
  })
})

8-10.png 所以,一些实时性较高的需求就不太适合使用 setTimeout 了,比如你用 setTimeout 来实现 JavaScript 动画就不是一个很好的主意。

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

如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

d. 延时执行时间有最大值

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,超过这个值就会溢出立即执行。

setTimeout(function(param) {
  console.log('hello world');
}, 2147483650)

8-11.png

e. setTimeout中的this不符合直觉
let obj ={
    uname:'cuifan',
    sayHello() {
        conosle.log('hello')
    }
}
setTimeout(obj.sayHello)

输出为:

undefined // this指向的是window

解决办法:

let obj ={
    uname:'cuifan',
    sayHello() {
        conosle.log('hello')
    }
}
setTimeout(obj.sayHello.bind(obj))
let obj ={
    uname:'cuifan',
    sayHello() {
        conosle.log('hello')
    }
}
setTimeout(()=>{obj.sayHello})

2.1.3 与requestAnimationFrame的对比

requestAnimationFrame 之前,主要借助setTimeout和setInterval来编写动画,而动画的关键在于动画帧之间的时间间隔设置,必须足够准确。这个时间间隔设置很有讲究,一方面要足够小,这样动画之间帧才有连贯性。一方面要足够大,确保浏览器有足够的时间及时完成渲染。

大部分显示器的刷新率为60hz,即每秒钟重绘60次,大多数浏览器都会对重绘操作加以限制,使其不超过显示器的刷新频率。

setTimeout/setInterval的致命缺陷在于设定的时间并不准确,它们只是在设定时间到达后将相应的任务添加到待执行的任务队列中,而任务队列中前面如果还有任务尚未执行完毕,之后添加的任务就必须等待。这个等待的时间造成了原本设定的时间间隔不准。

基于上述缺陷,出现了requestAnimationFrame ,它采用的是系统时间间隔(约为16.7ms),保持最佳的绘制效果与效率。使各种网页动画有一个统一的刷新机制,从而节省系统资源,提升系统性能。

MDN关于requestAnimationFrame 的描述:

当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。

注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

回调函数会被传入DOMHighResTimeStamp参数,DOMHighResTimeStamp指示当前被 requestAnimationFrame() 排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位毫秒,最小精度为1ms(1000μs)。

<svg width="800" height="600">
    <circle cx="50" cy="50" r="10" fill="red" id="myCircle1"></circle>
    <circle cx="50" cy="100" r="10" fill="red" id="myCircle2"></circle>
</svg>
<script>
  const log=console.log;
  const circle1=document.getElementById('myCircle1');
  const circle2=document.getElementById('myCircle2');

  let start=null;
  let count=0;//统计动画共有多少帧
  function step(timestamp,circle) {
      if(!start){
          start=timestamp;
      }
      let progress=timestamp-start;
      let dx=Math.min(progress/10,600);
      circle.setAttribute('cx',dx);
      if(progress<6000){
          count++;
          // 在浏览器下次重绘之前继续更新下一帧动画
          window.requestAnimationFrame((timestamp)=>{step(timestamp,circle)});
      }
      else{
          log(progress);
          log(count);
      }
  }

  window.requestAnimationFrame((timestamp)=>step(timestamp,circle1));//不会阻塞后面语句执行
</script>   

以上代码执行效果就是一个svg绘制的圆形在 6000ms 内水平从左向右匀速移动,动画整体耗时 progress 为 6288.444ms,动画帧数 count 为 364,每帧之间的时间间隔为 progress/count 约为 16.7 ms

8-12.png 还有一个要注意的地方,就是 window.requestAnimationFrame(回调函数) 不会阻塞后面语句执行,所以下段代码中通过两个 window.requestAnimationFrame(回调函数) 语句可以创造两个同时进行的动画,如图2所示。(由于共有了变量 count,所以最终其值为两个动画的总帧数。)

 window.requestAnimationFrame((timestamp)=>step(timestamp,circle1));//不会阻塞后面语句执行
 window.requestAnimationFrame((timestamp)=>step(timestamp,circle2));

2.2 XMLHttpRequest是怎么实现的

2.2.1 回调函数和系统调用栈

同步回调:回调在函数内部执行

let sayHello = function() {
    console.log('hello world')
}

function foo(callback) {
    console.log('foo start')
    callback && callback()
    console.log('foo end')
}

异步回调:回调在函数外部执行

let sayHello = function() {
    console.log('hello world')
}

function foo(callback) {
    console.log('foo start')
    setTimeout(callback,1000)
    console.log('foo end')
}

经过前面的介绍,你已经知道了浏览器页面是通过事件循环机制来驱动的,消息队列和主线程循环机制保证了页面有条不紊地运行。

需要指出的是,当循环系统在执行一个任务的时候,需要为这个任务维护一个系统调用栈,类似于JavaScript的调用栈,只不过系统调用栈是由Chromium的C++来维护的。 8-13.png 这幅图记录了一个 Parse HTML 的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。

需要说明的是,整个Parse HTML是一个完整的任务,在执行过程中的脚本解析、样式表解析都是该任务的子过程,其下拉的长条就是执行过程中调用栈的信息。

每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数,这个没有太多可讲的。下面我们主要来看看异步回调过程,异步回调是指回调函数在主函数之外执行,一般有两种方式:

第一种是把异步函数做成一个任务,添加到消息队列的末尾

第二种是吧异步函数添加到微任务队列中,在当前任务的末尾执行微任务。

Promise的resolve和reject会创建微任务。还有MutationObserver,如果监听了某个节点,那么通过DOMAPI修改这些被监听的节点也会产生微任务

2.2.2 XMLHttpRequest的运作机制

具体工作过程你可以参考下图: 8-14.png 渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

下面是封装的一个XMLHttpRequest请求函数:

 function GetWebData(URL) {
      /**
       * 1: 新建 XMLHttpRequest 请求对象
       */
      let xhr = new XMLHttpRequest()
        /**
         * 2: 注册相关事件回调处理函数
         */
      xhr.onreadystatechange = function() {
        switch (xhr.readyState) {
          case 0: // 请求未初始化
            console.log(" 请求未初始化 ")
            break;
          case 1: //OPENED
            console.log("OPENED")
            break;
          case 2: //HEADERS_RECEIVED
            console.log("HEADERS_RECEIVED")
            break;
          case 3: //LOADING 
            console.log("LOADING")
            break;
          case 4: //DONE
            if (this.status == 200 || this.status == 304) {
              console.log(this.responseText);
            }
            console.log("DONE")
            break;
        }
      }
      xhr.ontimeout = function(e) {
        console.log('ontimeout')
      }
      xhr.onerror = function(e) {
          console.log('onerror')
        }
        /**
         * 3: 打开请求
         */
      xhr.open('Get', URL, true); // 创建一个 Get 请求, 采用异步
      /**
       * 4: 配置参数
       */
      xhr.timeout = 3000 // 设置 xhr 请求的超时时间
      xhr.responseType = "text" // 设置响应返回的数据格式
      xhr.setRequestHeader("X_TEST", "time.geekbang") // 添加自己专用的请求头属性

      /**
       * 5: 发送请求
       */
      xhr.send();
    }

responseType的几种格式: 8-15.png

2.2.3 XMLHttpRequest使用过程的问题

a. 跨域
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <script>
    function callOtherDomain(url) {
      // 1.创建xhr请求对象
      let xhr = new XMLHttpRequest()
        // 2.打开请求
      xhr.open('GET', url, true)
        // 3.回调函数
      xhr.onreadystatechange = function() {
          // 0 未初始化 1 opened 2 HEADERS_RECEIVED 3 Loading 4 Received 
          if (this.readyState === 4) {
            if (this.status >= 200 && this.status < 300) {
              console.log(this.responseText);
            }
          }
        }
        // 4.发送请求
      xhr.send()
    }
    callOtherDomain('http://www.baidu.com')
  </script>
</body>

</html>

8-16.png 狭义的同源就是指,域名、协议、端口均为相同。不同则为跨域

b. HTTPS 内容混合

HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。

通常,如果 HTTPS 请求页面中使用混合内容,浏览器会针对 HTTPS 混合内容显示警告,用来向用户表明此 HTTPS 页面包含不安全的资源。 8-17.png 通过 HTML 文件加载的混合资源,虽然给出警告,但大部分类型还是能加载的。

而使用 XMLHttpRequest 请求时,浏览器认为这种请求可能是攻击者发起的,会阻止此类危险的请求,比如下图:

8-18.png

3. 微任务和宏任务

随着浏览器应用领域的广泛,消息队列这种粗时间颗粒度的任务已经不能适应部分领域的需求。所以出现了一种新的技术——微任务。微任务可以在实时性和效率之间做一个权衡。

目前来看,基于微任务的技术有MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术。

3.1 宏任务

页面中大部分任务都是在主线程上进行的:

  • 渲染事件(如解析 DOM、计算布局、绘制);

  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);

  • JavaScript 脚本执行事件;

  • 网络请求完成、文件读写完成事件。

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

WHATWG 是这样定义消息循环机制的:

  1. 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;

  2. 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;

  3. 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个oldestTask;

  4. 最后统计执行完成的时长等信息。

下面我们来分析下为什么宏任务难以满足对时间精度要求较高的任务。

function foo() {
    console.log('foo')
}
setTimeout(function() {
    console.log('foo1')
    setTimeout(function() {
        foo()
    },1000)
},1000)

我的目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,如果插入,就可能会影响到第二个定时器的执行时间了。

前面我们说过,页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

但实际情况是我们不能控制的,比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。你可以打开 Performance 工具,来记录下这段任务的执行过程,也可参考文中我记录的图片: 8-19.png

3.2 微任务

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

下面站在 V8 引擎的层面来分析下微任务是怎么运转起来的。

V8引擎为JS创建全局执行上下文的时候会创建一个微任务队列,这个微任务队列是给V8引擎内部使用的,无法通过JavaScript引擎访问。

3.2.1 微任务产生的时机

  • 使用MutationObserver监控某个DOM节点,然后通过JavaScript来修改这个节点或者子节点,当DOM节点发生变化的时候,就会产生DOM变化记录的微任务
  • 使用Promise,当调用Promise.resolve()和Promise.reject()的时候也会产生微任务

3.2.2 微任务执行的时机

在当前宏任务中的JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行微任务。这个时间点叫做检查点,当然除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束,不会推迟到下个宏任务中。 8-20.png 8-21.png

该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。

3.2.3 监听DOM变化方法演变

接下来我们再来看看微任务是如何应用在MutationObserver 中的。MutationObserver 是用来监听 DOM 变化的一套方法。

比如许多 Web 应用都利用 HTML 与 JavaScript 构建其自定义控件,与一些内置控件不同,这些控件不是固有的。为了与内置控件一起良好地工作,这些控件必须能够适应内容更改、响应事件和用户交互。因此,Web 应用需要监视 DOM 变化并及时地做出响应

a. 轮询检测

早期页面并没有提供对监听的支持,那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:

  • 如果时间间隔设置过长,DOM 变化响应不够及时
  • 如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效
b. Mutation Event

2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调

解决了实时性的问题,但是会导致页面性能问题,事件执行事件过长会导致页面卡顿。

c. MutationObserver

从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。那么MutationObserver 相较于 Mutation Event做了哪些改进呢?

MutationObserver 将响应函数改为异步调用的方式,**等多次DOM变化完成之后,一次触发调用。**还会使用一个数据结构来记录这期间所有的DOM变化。即使频繁地操作DOM,也不会对性能造成太大的影响。

MutationObserver 在DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。保证了实时性

3.4 Promise

3.4.1. 异步编程

Promise 解决的是异步编码风格的问题。

页面中任务都是执行在主线程之上的,所以主线程对于页面的性能至关重要。所以在执行一项耗时的任务时,比如下载网络文件任务、获取摄像头等设备信息任务,这些任务都会放到页面主线程之外的进程或者线程中去执行,这样就避免了耗时任务“霸占”页面主线程的情况。

下面是Web 应用的异步编程模型: 8-22.png Web 页面的单线程架构决定了异步回调,而异步回调影响到了我们的编码方式。

function onResolve(response){console.log(response) }
function onReject(error){console.log(error) }
let xhr = new XMLHttpRequest()
xhr.ontimeout = function(e) { onReject(e)}
xhr.onerror = function(e) { onReject(e) }
xhr.onreadystatechange = function () { onResolve(xhr.response) }
// 设置请求类型,请求 URL,是否同步信息
let URL = 'https://time.geekbang.com'
xhr.open('Get', URL, true);
// 设置参数
xhr.timeout = 3000 // 设置 xhr 请求的超时时间
xhr.responseType = "text" // 设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")

// 发出请求
xhr.send()

这短短的一段代码里面竟然出现了五次回调,这么多的回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉,有什么办法可以解决吗?

我们可以把中间的处理过程封装起来,专注于输入和输出: 8-23.png 下面初步对上述ajax代码做封装:

//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)
  })

这样又出现了新的问题

3.4.2 回调地狱

假如项目比较复杂:

XFetch(makeRequest('https://time.geekbang.org/?category'),
  function resolve(response) {
    console.log(response)
    XFetch(makeRequest('https://time.geekbang.org/column'),
      function resolve(response) {
        console.log(response)
        XFetch(makeRequest('https://time.geekbang.org')function resolve(response) {
          console.log(response)
        }, function reject(e) {
          console.log(e)
        })
      },
      function reject(e) {
        console.log(e)
      })
  },
  function reject(e) {
    console.log(e)
  })

这段代码之所以看上去很乱,归结其原因有两点:

  • 嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这嵌套层次多了之后,代码的可读性就变得非常差

  • 任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。

用promise重构:

 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 设置的回调函数。

3.4.3 Promise与微任务

先看一段代码:

function executor(resolve, reject) {
  resolve(100)
}
let demo = new Promise(executor)

function onResolve(value) {
  console.log(value)

}
demo.then(onResolve)

首先执行 new Promise 时,Promise 的构造函数会被执行,不过由于 Promise 是 V8 引擎提供的,所以暂时看不到 Promise 构造函数的细节。接下来,Promise 的构造函数会调用 Promise 的参数 executor 函数。然后在 executor中执行了 resolve,resolve 函数也是在 V8 内部实现的,那么 resolve 函数到底做了什么呢?我们知道,执行resolve 函数,会触发 demo.then 设置的回调函数 onResolve,所以可以推测,resolve 函数内部调用了通过 demo.then设置的onResolve函数。

这里需要注意一下,由于Promise采用了回调函数延迟绑定,所以在执行resolve函数的时候,回调函数还没有绑定,那么就推迟回调函数的执行。

上面说的可能有些抽象,下面我们简单实现一个Promise.

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

   function resolve(value) {
     onResolve_(value)
   }
   executor(resolve, null);
 }

执行这段代码,我们发现执行出错,输出的内容是:

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_ is not a function“的错误了。也正是因为此,我们要改造 Bromise 中的 resolve 方法,让 resolve 延迟调用onResolve_。

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

  function resolve(value) {
    setTimeout(function() {
      onResolve_(value)
    }, 0)
  }
  executor(resolve, null);
}

上面采用了定时器来推迟 onResolve 的执行,使用定时器的效率并不是太高,所以 Promise 又把这个定时器改造成了微任务了,这样既可以让onResolve_ 延时被调用,又提升了代码的执行效率。这就是 Promise 中使用微任务的原由。