JavaScript 中的事件轮询机制

JavaScript 中的事件轮询机制

最后更新于 2019-12-28

最近打算重新复习一下 JavaScript 中的事件轮询机制,而且刚好在之前也介绍过 Node.js 中的事件轮询机制,它与 JavaScript 中的事件轮询机制是有所区别的,所以在这里打算重新的梳理一下,也算是将之前博客当中关于事件轮询的相关内容做一个汇总,方便以后更为方便的复习和查看,下面我们就先从一些前置知识点开始看起

JavaScript 中的栈和堆

我们都知道,JavaScript 的一大特点就是单线程,这意味着在任何时候只能有一段代码执行,JavaScript 主线程在运行时,会建立一个执行同步代码的『栈』和执行异步代码的『队列』,在深入展开之前,我们先来简单的了解一下 JavaScript 中堆和栈的相关概念,关于 栈 和 堆 我们需要区分数据结构和内存中各自『堆栈』的含义,数据结构中的堆和栈是两种不同的数据项按序排列的数据结构,但是在这里我们主要介绍的是内存中的堆区与栈区

内存中的堆区与栈区

在 C 语言中,各个区别如下

  • 栈区是分配局部变量的空间
  • 堆区是地址向上增长的用于分配我们申请的内存空间
  • 另外还有静态区是分配静态变量、全局变量空间的
  • 只读区是分配常量和程序代码空间的

一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
int a = 0;                  // 全局初始化区
char *p1; // 全局未初始化区

main() {
int b; // 栈
char s[] = "abc"; // 栈
char *p2; // 栈
char *p3 = "123"; // 在常量区,p3 在栈上
static int c = 0; // 全局(静态)初始化区
p1 = (char *)malloc(10); // 堆
p2 = (char *)malloc(20); // 堆
}

而 JavaScript 是高级语言,底层依靠 C/C++ 来编译实现,其变量划分为基本数据类型和引用数据类型

  • 基本数据类型(undefined/null/boolean/string/number/BigInt/Symbol)在内存中分别占有固定大小的空间,他们的值保存在栈空间,通过按值访问、拷贝和比较
  • 引用数据类型(Object/Array/Function/Error/Date)大小不固定,栈内存中存放地址指向堆内存中的对象,是按引用访问的(和 C 语言的指针类似)

对于引用类型的变量,栈内存中存放的只是该对象的访问地址,在堆内存中为该值分配空间,由于这种值的大小不固定,因此不能把他们保存在栈内存中,但是内存地址大小是固定的,因此可以将堆内存地址保存到栈内存中

这样一来,当查询引用类型的变量的时候,就会先从栈中读取堆内存地址,然后在根据地址取出对应的值,显而易见的是,JavaScript 中所有引用类型创建实例的时候,都是显式或者隐式的 new 出对应类型的实例,实际上就是对应 C 语言的 malloc() 分配内存函数

栈和队列的区别

  • 栈的插入和删除操作都是在一端进行的,而队列的操作却是在两端进行的
  • 队列先进先出,栈先进后出

栈只允许在表尾一端进行插入和删除,而队列只允许在表尾一端进行插入,在表头一端进行删除,有一个比较好的记忆方式

  • 队列相当我们去银行柜台排队,大家依次鱼贯而行,先进去排队的最先出来
  • 栈比较像我们在家中洗碗,最后洗好的碗叠在最上面的,而下次拿的时候是最先拿到最后叠上去的碗

栈和堆的区别

  • 栈区(Stack),由编译器自动分配释放,存放函数的参数值,局部变量的值等
  • 堆区(Heap),一般由程序员分配释放,若程序员不释放,程序结束时可能由 OS 回收
  • 堆(数据结构),堆可以被看成是一棵树,如堆排序
  • 栈(数据结构),一种先进后出的数据结构

进程和线程

我们都知道 JavaScript 属于单线程,程序按顺序执行,本质上执行的是基于浏览器的一个事件队列,要执行的函数和触发事件的回调函数都被放在这个队列中最终交由浏览器来执行,那么浏览器又是如何来执行的呢?在此之前,我们需要先来了解一下什么是进程和线程

进程

学术上说,进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体,我们这里将进程比喻为工厂的车间,它代表 CPU 所能处理的单个任务,任一时刻,CPU 总是运行一个进程,其他进程处于非运行状态

线程

在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位,任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离

后来随着计算机的发展,对 CPU 的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了,于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,这里把线程比喻一个车间的工人,即一个车间可以允许由多个工人协同完成一个任务

进程和线程的区别

  • 进程是操作系统分配资源的最小单位,线程是程序执行的最小单位
  • 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
  • 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号)
  • 调度和切换,线程上下文切换比进程上下文切换要快得多

多进程与多线程

多进程指的是在同一个时间里,同一个计算机系统中如果允许两个或两个以上的进程处于运行状态,多进程带来的好处是明显的,比如你可以听歌的同时,打开编辑器敲代码,编辑器和听歌软件的进程之间丝毫不会相互干扰

而多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务

这里我们只是简单的了解一下栈,堆,进程和线程的基本知识,更多相关内容可以参考我们之前整理过的 浏览器的渲染机制 这篇文章来了解更多,所以这里我们也就不过多提及了,下面我们就来正式的看看 EventLoop 的相关内容

EventLoop

我们从一张图开始看起,如下

JavaScript 执行引擎的主线程运行的时候,产生堆(Heap)和栈(Stack),程序中代码依次进入栈中等待执行,若执行时遇到异步方法,该异步方法会被添加到用于回调的队列(Queue)中,即 JavaScript 执行引擎的主线程拥有一个执行栈(或者堆)和一个任务队列,其中

  • 栈(Stack),函数调用会形成了一个堆栈帧
  • 堆(Heap),对象被分配在一个堆中,一个用以表示一个内存中大的未被组织的区域
  • 队列(Queue),一个 JavaScript 运行时包含了一个待处理的消息队列
    • 每一个消息都与一个函数相关联
    • 当栈为空时,则从队列中取出一个消息进行处理
    • 这个处理过程包含了调用与这个消息相关联的函数(以及因而创建了一个初始堆栈帧)
    • 当栈再次为空的时候,也就意味着该消息处理结束

所以我们梳理出整个执行流程大致模样,如下

  1. 所有同步任务都在主线程上执行,形成一个执行栈(并发模型的 Stack),同步的执行流程可以参考我们之前整理过的 JavaScript 的同步执行过程
  2. 主线程之外,还存在一个任务队列(并发模型的 Queue),只要异步任务有了运行结果,就在任务队列中放置一个事件
  3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件和那些对应的异步任务,于是等待结束状态,进入执行栈开始执行
  4. 主线程不断重复上面的第三步,也就是只要主线程空了,就会去读取任务队列

因为主线程从任务队列中读取事件的过程是循环不断的,因此这种运行机制又称为 EventLoop(事件循环),简单总结就是,主线程运行的时候,产生堆(Heap)和栈(Stack),栈中的代码调用各种外部 API,它们在任务队列中加入各种事件(click,load,done),只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数

任务队列

我们在之前提到过,事件循环是通过 任务队列 的机制来进行协调的,在一个 EventLoop 中可以有一个或者多个任务队列(Task Queue),一个任务队列便是一系列有序任务(Task)的集合,每个任务都有一个任务源(Task Source),源自同一个任务源的 Task 必须放到同一个任务队列,从不同源来的则被添加到不同队列

在事件循环中,每进行一次循环操作称为 Tick,每一次 Tick 的 任务处理模型 是比较复杂的,但关键步骤如下

  • 在此次 Tick 中选择最先进入队列的任务(Oldest Task),如果有则执行(一次)
  • 检查是否存在 Microtasks,如果存在则不停地执行,直至清空 Microtasks Queue
  • 更新 render
  • 主线程重复执行上述步骤

我们仔细查阅规范可知,异步任务可分为 Task 和 Microtask 两类,不同的 API 注册的异步任务会依次进入自身对应的队列中,然后等待 EventLoop 将它们依次压入执行栈中执行(也就是如上面章节中的图片所示那样)

在网上比较常见的说法是分为是分为宏任务(Macrotask)和微任务(Microtask)两个概念,但规范中并没有提到 Macrotask,因而一个比较合理的解释是 Task 即为其它文章中的 Macrotask,另外在 ES2015 规范中将 Microtask 又称为 Job

但是在这里我们为了统一,干脆就称为宏任务(Macrotask)和微任务(Microtask),知道它们各自具体代表的是什么即可,下面我们来看看它们两者之间的区别

  • 宏任务(Macrotask)主要包含 setTimeout、setInterval、I/O、UI 交互事件、postMessage、MessageChannel、setImmediate(Node.js)
  • 微任务(Microtask)主要包含 Promise.then、MutaionObserver、process.nextTick(Node.js)、Object.observe(已废弃)

这里有几个需要注意的地方

  • 在有些地方会列出来 UI Rendering,说这个也是宏任务,可是在 规范文档 当中可以发现这很显然是和微任务平行的一个操作步骤
  • 在 Node.js 中,会优先清空 next tick queue,即通过 process.nextTick 注册的函数,再清空 other queue,常见的如 Promise,此外,timers(setTimeout/setInterval) 会优先于 setImmediate 执行,因为前者在 timer 阶段执行,后者在 check 阶段执行(下面我们会进行介绍)
  • 还有一个比较特殊的 requestAnimationFrame,有的将其归纳到宏任务当中,但是这里是存在一定争议的,关于这个我们会在后面来详细进行介绍

下面我们就来稍微深入的了解一下宏任务和微任务

宏任务

我们可以将每次执行栈执行的代码当做是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行),每一个宏任务会从头到尾执行完毕,不会执行其他

我们在之前提到过,JavaScript 引擎线程和 GUI 渲染线程是互斥的关系,浏览器为了能够使宏任务和 DOM 任务有序的进行,会在一个宏任务执行结果后,在下一个宏任务执行前,GUI 渲染线程开始工作,对页面进行渲染,有些类似于宏任务与渲染交替执行的一个过程

我们可以通过一个示例来进行了解,如下

1
2
3
4
document.body.style = 'background:black'
document.body.style = 'background:red'
document.body.style = 'background:blue'
document.body.style = 'background:grey'

我们可以将上面这段代码放到浏览器的控制台下进行执行,运行以后我们会看到的结果是,页面背景会在瞬间变成灰色,因为以上代码属于同一次宏任务,所以全部执行完才触发页面渲染,渲染时 GUI 线程会将所有 UI 改动优化合并,所以视觉效果上,只会看到页面变成灰色,我们将上面的示例稍微调整一下

1
2
3
4
5
document.body.style = 'background:blue'

setTimeout(function () {
document.body.style = 'background:black'
}, 0)

再次执行一下,这次可以发现,页面先显示成蓝色背景,然后瞬间变成了黑色背景,这是因为以上代码属于两次宏任务,第一次宏任务执行的代码是将背景变成蓝色,然后触发渲染,将页面变成蓝色,再触发第二次宏任务将背景变成黑色

微任务

我们已经知道宏任务结束后,会执行渲染,然后执行下一个宏任务,而微任务可以理解成在当前宏任务执行后立即执行的任务,也就是说,当宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完,老规矩,来看示例

1
2
3
4
5
6
7
8
9
document.body.style = 'background:blue'
console.log(1)

Promise.resolve().then(() => {
console.log(2)
document.body.style = 'background:black'
})

console.log(3)

控制台会输出 1 3 2,这是因为 Promise 对象的 then 方法的回调函数是异步执行,所以 2 最后输出,而页面的背景色直接变成黑色,没有经过蓝色的阶段,是因为我们在宏任务中将背景设置为蓝色,但在进行渲染前执行了微任务,在微任务中将背景变成了黑色,然后才执行的渲染,我们将上面的示例稍微调整一下

1
2
3
4
5
6
7
8
setTimeout(() => {
console.log(1)
Promise.resolve(3).then(data => console.log(data))
}, 0)

setTimeout(() => {
console.log(2)
}, 0)

上面代码共包含两个 setTimeout,也就是说除主代码块外,共有两个宏任务,其中第一个宏任务执行中,输出 1,并且创建了微任务队列,所以在下一个宏任务队列执行前,先执行微任务,在微任务执行中,输出 3,微任务执行后,执行下一次宏任务,执行中输出 2

执行流程

在具体了解了什么是宏任务和微任务以后,我们就可以来使用它们简单的总结一下我们上面介绍到的 EventLoop 的执行流程,我们都知道,所有 JavaScript 代码一开始都只可能是同步代码,所以肯定是从同步开始进行的

  • 执行『同步』代码
    • 如果遇到到『异步任务』,『入队』到相应的队列中,不直接执行
    • 『继续执行』后续同步代码直到『全部执行完毕』
  • 检查『微任务』队列
    • 如果队列不为空,『出队』一个微任务准备执行,进入『同步任务』流程
    • 如果队列为空,进入『宏任务』流程
  • 检查『宏任务』队列
    • 如果队列不为空,『出队』一个宏任务准备执行,进入『同步任务』流程
    • 如果队列为空,结束并『等待』新的任务

简答来说就是,代码中的 setInterval(fn, time) 等语句,实际上就是对异步任务的声明,然后在适当的时机,运行环境会自行将事件加入任务队列中

实例

我们下面就通过几个例子来深入的理解一下,先来看一个 setTimeout() 相关示例,如下

1
2
3
4
5
6
7
console.log(1)

setTimeout(function () {
console.log(2)
}, 5000)

console.log(3)

这里需要注意的是,定时器只是将事件插入了任务队列,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数,我们结合之前提到过的 EventLoop 来看看单线程的 JavaScript 执行引擎是如何来执行该方法的

  1. JavaScript 执行引擎主线程运行,产生 Heap 和 Stack
  2. 从上往下执行同步代码,console.log(1) 被压入执行栈,因为 console.log() 方法是 webkit 内核支持的普通方法而非 WebAPIs 的方法,因此立即出栈被引擎执行,输出 1
  3. JavaScript 执行引擎继续往下,遇到 setTimeout() 异步方法(如上图,setTimeout() 属于 WebAPIs),将 setTimeout(callback, 5000) 添加到执行栈
  4. 因为 setTimeout() 属于 WebAPIs 中的方法,JavaScript 执行引擎在将 setTimeout() 出栈执行时,注册 setTimeout() 延时方法交由浏览器内核其他模块(以 webkit 为例,是 webcore 模块)处理
  5. 继续运行 setTimeout() 下面的 console.log(3) 代码,原理同步骤 2
  6. 当延时方法到达触发条件,即到达设置的延时时间时(5 秒后),该延时方法就会被添加至任务队列里,这一过程由浏览器内核其他模块处理,与执行引擎主线程独立
  7. JavaScript 执行引擎在主线程方法执行完毕,到达空闲状态时,会从任务队列中顺序获取任务来执行
  8. 将队列的第一个回调函数重新压入执行栈,执行回调函数中的代码 console.log(2),原理同步骤 2,回调函数的代码执行完毕,清空执行栈
  9. JavaScript 执行引擎继续轮循队列,直到队列为空
  10. 执行完毕

看完了 setTimeout(),下面再来看一个 Promise 的示例

1
2
3
4
5
6
7
8
9
10
11
12
new Promise(resolve => {
resolve(1)
Promise.resolve().then(() => {
// t2
console.log(2)
})
console.log(4)
}).then(t => {
// t1
console.log(t)
})
console.log(3)

同样的,我们也简单的梳理一下这段代码的流程

  1. 程序运行,首先遇到 Promise 实例,构造函数首先执行,所以首先输出了 4,此时 Microtask 的任务有 t2 和 t1
  2. 代码继续运行,输出 3,至此,第一个宏任务执行完成,
  3. 执行所有的微任务,先后取出 t2 和 t1,分别输出 2 和 1
  4. 代码执行完毕

所以综上所述,最后的输出结果是 4321,但是这里可能会有一些疑惑,那就是为什么 t2 会先执行呢?我们根据 Promises/A+ 规范可知

实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行

所以我们可以推断出,Promise.resolve 方法允许调用时不带参数,直接返回一个 resolved 状态的 Promise 对象,也就是立即 resolved 的 Promise 对象,是在本轮事件循环(EventLoop)的结束时,而不是在下一轮事件循环的开始时(详细可以参考 Promise-resolve),所以 t2 比 t1 会先进入 Microtask 的 Promise 队列

最后,我们再来看一个综合两者的示例,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('script start')

setTimeout(function () {
console.log('timeout1')
}, 10)

new Promise(resolve => {
console.log('promise1')
resolve()
setTimeout(() => console.log('timeout2'), 10)
}).then(function () {
console.log('then1')
})

console.log('script end')
  1. 首先,事件循环从宏任务(Macrotask)队列开始,当遇到任务源(task source)时,则会先分发任务到对应的任务队列中去,然后遇到了 console 语句,直接输出 script start,输出之后,任务继续往下执行,遇到 setTimeout,其作为一个宏任务源,则会先将其任务分发到对应的队列中
  2. 任务继续往下执行,遇到 Promise 实例,Promise 构造函数中的第一个参数,是在 new 的时候执行,构造函数执行时,里面的参数进入执行栈执行,而后续的 .then 则会被分发到 Microtask 的 Promise 队列中去,所以会先输出 promise1,然后执行 resolve,将 then1 分配到对应队列
  3. 构造函数继续往下执行,又碰到 setTimeout,然后将对应的任务分配到对应队列
  4. 任务继续往下执行,最后只有一句输出,所以输出 script end,至此,全局任务就执行完毕了

根据上述,每次执行完一个宏任务之后,会去检查是否存在 Microtasks,如果有,则执行 Microtasks 直至清空 Microtask Queue,因而在任务执行完毕之后,开始查找清空微任务队列,此时,微任务中只有 Promise 队列中的一个任务 then1,因此直接执行就行了,执行结果输出 then1,当所有的 microtast 执行完毕之后,表示第一轮的循环就结束了

  1. 这个时候就得开始第二轮的循环,第二轮循环仍然从宏任务 Macrotask 开始,此时有两个宏任务 timeout1 和 timeout2,取出 timeout1 执行,输出 timeout1,此时微任务队列中已经没有可执行的任务了,直接开始第三轮循环
  2. 第三轮循环依旧从宏任务队列开始,此时宏任务中只有一个 timeout2,取出直接输出即可

这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了,那么例子的输出结果就显而易见

1
2
3
4
5
6
script start
promise1
script end
then1
timeout1
timeout2

关于 requestAnimationFrame

我们先来看看 requestAnimationFrame 的基本用法,然后再来看看它与事件轮询的关系

基本用法

requestAnimationFrame 是浏览器用于定时循环操作的一个接口,类似于 setTimeout,主要用途是按帧对网页进行重绘,设置这个 API 的目的是为了让各种网页动画效果(DOM 动画、Canvas 动画、SVG 动画、WebGL 动画)能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果,代码中使用这个 API,就是告诉浏览器希望执行一个动画,让浏览器在下一个动画帧安排一次网页重绘,它主要有两个特点

  1. 按帧对网页进行重绘,该方法告诉浏览器希望执行动画并请求浏览器在下一次重绘之前调用回调函数来更新动画
  2. 由系统来决定回调函数的执行时机,在运行时浏览器会自动优化方法的调用

关于第二点,显示器有固定的刷新频率(60Hz 或 75Hz),也就是说每秒最多只能重绘 60 次或 75 次,requestAnimationFrame 的基本思想让页面重绘的频率与这个刷新频率保持同步,比如显示器屏幕刷新率为 60Hz,使用 requestAnimationFrame API,那么回调函数就每 1000ms / 60 ≈ 16.7ms 执行一次,如果显示器屏幕的刷新率为 75Hz,那么回调函数就每 1000ms / 75 ≈ 13.3ms 执行一次

另外,一旦页面不处于浏览器的当前标签,就会自动停止刷新

  • 当页面被最小化或者被切换成后台标签页时,页面为不可见,浏览器会触发一个 visibilitychange 事件,并设置 document.hidden 属性为 true
  • 当页面切换到显示状态,页面变为可见,同时触发一个 visibilitychange 事件,设置 document.hidden 属性为 false

不过有一点需要注意是 requestAnimationFrame 是在主线程上完成,这意味着如果主线程非常繁忙,requestAnimationFrame 的动画效果会大打折扣,requestAnimationFrame 使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用

1
id = window.requestAnimationFrame(callback)

它会返回一个 id 结果,主要是用于传递给 window.cancelAnimationFrame(id) 来取消重绘,在浏览器当中的执行过程大致如下

  • 首先判断 document.hidden 属性是否为 true(页面是否可见),页面处于可见状态才会执行后面步骤
  • 浏览器清空上一轮的动画函数
  • requestAnimationFrame 将回调函数追加到动画帧请求回调函数列表的末尾,当执行 requestAnimationFrame(callback) 的时候,不会立即调用 callback 函数,只是将其放入队列,每个回调函数都有一个布尔标识 cancelled,该标识初始值为 false,并且对外不可见
  • 当浏览器再执行列表中的回调函数的时候,判断每个元组的 callback 的 cancelled,如果为 false 则执行 callback,当页面可见并且动画帧请求回调函数列表不为空,浏览器会定期将这些回调函数加入到浏览器 UI 线程的队列中
  • 当调用 cancelAnimationFrame(handle) 时,浏览器会设置该 handle 指向的回调函数的 cancelled 为 true(无论该回调函数是否在动画帧请求回调函数列表中),如果该 handle 没有指向任何回调函数,则什么也不会发生

更为底层的原理可以参考 深入理解 requestAnimationFrame,这里就不详细展开了,但是在这里我们多提及两点,第一个就是递归调用,要想实现一个完整的动画,应该在回调函数中递归调用回调函数

1
2
3
4
5
6
7
8
9
10
let count = 0
let rafId = null
function requestAnimation(time) { // requestAnimationFrame 调用该函数时,自动传入的一个时间
console.log(time)
if (count < 50) { // 动画没有执行完,则递归渲染
count++
rafId = requestAnimationFrame(requestAnimation) // 渲染下一帧
}
}
requestAnimationFrame(requestAnimation) // 渲染第一帧

另外如果如果在执行回调函数或者 Document 的动画帧请求回调函数列表被清空之前多次调用 requestAnimationFrame 调用同一个回调函数,那么列表中会有多个元组指向该回调函数(它们的 handle 不同,但 callback 都为该回调函数),采集所有动画任务操作会执行多次该回调函数(类比定时器 setTimeout)

1
2
3
4
5
6
7
8
9
10
11
12
function counter() {
let count = 0
function animate(time) {
if (count < 50) {
count++
console.log(count)
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
btn.addEventListener('click', counter, false)

例如上面这个示例,多次点击按钮,会发现打印出来多个序列数值

结论

在了解完它的基本用法以后,我们不难发现,requestAnimationFrame 其实不属于 Task,它只是浏览器渲染过程的一步,和 Task/Microtask 的执行是分离的,我们在这里简单的总结一下就是

  • setTimeout 的执行时间并不是确定的,在 JavaScript 中,setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,所以 setTimeout 的实际执行时机一般要比其设定的时间晚一些
  • 与 setTimeout 相比,requestAnimationFrame 最大的优势是『由系统来决定回调函数的执行时机』,系统每次绘制之前会主动调用 requestAnimationFrame 中的回调函数,如果系统绘制率是 60Hz,那么回调函数就每 16.7ms 被执行一次,如果绘制频率是 75Hz,那么这个间隔时间就变成了 1000 / 75 = 13.3ms,换句话说就是 requestAnimationFrame 的执行步伐跟着系统的绘制频率走,它能保证回调函数在屏幕每一次的绘制间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题

至于具体是什么样的流程,我们可以参考规范当中的 事件循环处理模型,简化后的流程是下面这样

  • 执行最早的(宏)任务
  • 微任务
  • 如果这是渲染的好时机
    • 准备工作
    • 运行 requestAnimationFrame 回调
    • 渲染

但是这里也会存在一个问题,就是如果你在不同的浏览器上进行测试,结果可能会有所不同,但是现在 Chrome/Firefox/iOS Safari/Legacy Edge 等都在向着规范靠拢,所以我们还是以规范为准吧

Node.js 中的表现

Node.js 也是单线程,但是在处理 EventLoop 上与浏览器稍微有些不同,就单从 API 层面上来理解,Node.js 新增了两个方法可以用来使用,微任务的 process.nextTick 以及宏任务的 setImmediate

Node.js 的运行机制也是基于事件轮询(EventLoop)的,详细见 Node.js 中的事件轮询机制

setImmediate 与 setTimeout 的区别

在官方文档中的定义,setImmediate 为一次 EventLoop 执行完毕后调用,setTimeout 则是通过计算一个延迟时间后进行执行,但是同时还提到了如果在主进程中直接执行这两个操作,很难保证哪个会先触发

因为如果主进程中先注册了两个任务,然后执行的代码耗时超过设定的期限,而这时定时器已经处于可执行回调的状态了,所以会先执行定时器,而执行完定时器以后才是结束了一次 EventLoop,这时才会执行 setImmediate

1
2
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

比如上面的代码,可以试验一下,执行多次的话会得到不同的结果,但是如果后续添加一些代码以后,就可以保证 setTimeout 一定会在 setImmediate 之前触发了

1
2
3
4
5
6
7
8
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 1e9

// 我们确保这个循环的执行速度会超过定时器的倒计时,导致这轮循环没有结束时,setTimeout 已经可以执行回调了
// 所以会先执行 setTimeout 然后再结束这一轮循环,也就是说开始执行 setImmediate
while (countdown--) { }

如果在另一个宏任务中,必然是 setImmediate 先执行

1
2
3
4
5
// 如果使用一个设置了延迟的 setTimeout 也可以实现相同的效果
require('fs').readFile(__dirname, _ => {
setTimeout(_ => console.log('timeout'))
setImmediate(_ => console.log('immediate'))
})

process.nextTick

就像上边说的,这个可以认为是一个类似于 Promise 和 MutationObserver 的微任务实现,在代码执行的过程中可以随时插入 nextTick,并且会保证在下一个宏任务开始之前所执行,在使用方面的一个最常见的例子就是一些事件绑定类的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Lib extends require('events').EventEmitter {
constructor() {
super()

this.emit('init')
}
}

const lib = new Lib()

lib.on('init', _ => {
// 这里将永远不会执行
console.log('init!')
})

因为上述的代码在实例化 Lib 对象时是同步执行的,在实例化完成以后就立马发送了 init 事件,而这时在外层的主程序还没有开始执行到 lib.on('init') 监听事件的这一步,所以会导致发送事件时没有回调,回调注册后事件不会再次发送,我们可以很轻松的使用 process.nextTick 来解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
class Lib extends require('events').EventEmitter {
constructor() {
super()

process.nextTick(_ => {
this.emit('init')
})

// 同理使用其他的微任务
// 比如 Promise.resolve().then(_ => this.emit('init'))
// 也可以实现相同的效果
}
}

这样会在主进程的代码执行完毕后,程序空闲时触发 EventLoop 流程查找有没有微任务,然后再发送 init 事件

但是这里也需要注意,循环调用 process.nextTick 会导致报警,后续的代码永远不会被执行

Async/Await

因为 Async/Await 本质上还是基于 Promise 的一些封装,而 Promise 是属于微任务的一种,所以在使用 await 关键字与 Promise.then 效果类似

1
2
3
4
5
6
7
8
9
10
11
setTimeout(_ => console.log(4))

async function main() {
console.log(1)
await Promise.resolve()
console.log(3)
}

main()

console.log(2)

async 函数在 await 之前的代码都是同步执行的,可以理解为 await 之前的代码属于 new Promise 时传入的代码,await 之后的所有代码都是在 Promise.then 中的回调

Web Workers

最后我们来简单的了解一下 Web Workers 的相关内容,不过需要注意的是,Worker 是浏览器(即宿主环境)的功能,实际上和 JavaScript 语言本身几乎没有什么关系,也就是说 JavaScript 当前并没有任何支持多线程执行的功能

浏览器可以提供多个 JavaScript 引擎实例,各自运行在自己的线程上,这样你可以在每个线程上运行不同的程序,程序中每一个这样的的独立的多线程部分被称为一个 Worker,这种类型的并行化被称为『任务并行』,因为其重点在于把程序划分为多个块来并发运行,下面是 Worker 的运作流图

下面是一个计算阶乘的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<body>
<fieldset>
<legend>计算阶乘</legend>
<input id="input" type="number" placeholder="请输入一个正整数" />
<button id="btn">计算</button>
<p>计算结果:<span id="result"></span></p>
</fieldset>
<legend></legend>

<script>
const input = document.getElementById('input');
const btn = document.getElementById('btn');
const result = document.getElementById('result');

btn.addEventListener('click', () => {
const worker = new Worker('./worker.js');

// 向 Worker 发送消息
worker.postMessage(input.value);

// 接收来自 Worker 的消息
worker.addEventListener('message', e => {
result.innerHTML = e.data;

// 使用完 Worker 后记得关闭
worker.terminate();
});
});
</script>
</body>

然后我们在同目录下新建一个 worker.js,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function memorize(f) {
const cache = {}
return function () {
const key = Array.prototype.join.call(arguments, ',')
if (key in cache) {
return cache[key]
} else {
return (cache[key] = f.apply(this, arguments))
}
}
}

const factorial = memorize(n => {
return n <= 1 ? 1 : n * factorial(n - 1)
})

// 监听主线程发过来的消息
self.addEventListener(
'message',
function (e) {
// 响应主线程
self.postMessage(factorial(e.data))
},
false,
)

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×