JavaScript 中的事件循环

JavaScript 有一个基于事件循环的并发模型,它负责执行代码、收集和处理事件以及执行队列中子任务,它被称为事件循环(Event Loop)。了解事件循环的工作方式和原理对于进行优化非常重要。

运行时概念

Stack,Heap,Queue

函数调用形成了一个有若干帧组成的栈。

栈是一种仅限在表为进行添加和删除的线性数据结构。

对象被分配在堆中。堆是一个用来表示一块非结构化内存区域的计算机术语。

堆是一种利用完全二叉树维护的线性数据结构。

队列

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用来处理这个消息的回调函数。

在事件循环的合适时刻,JavaScript 运行时会从最早进入消息队列的消息开始处理消息。被处理的消息会被移出消息队列,并以自身为参数调用与之关联的函数。

函数的处理会一直到执行栈再次为空为止,然后事件循环会处理下一个消息。

事件循环

事件循环的概念非常简单,一个无限的循环:JavaScript 引擎等待任务、执行任务,然后进入休眠状态,等待更多任务。

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

queue.waitForMessage() 会同步地等待消息到达。

消息队列中的消息只有在当前消息完全的执行完成后,接下来的消息才会被执行。这个特性会带来一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数,其等待的时间取决于队列里待处理的消息数量。延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。

运行时通信

一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。

浏览器下的事件循环实现

浏览器中所有任务都会被放到调用栈中等待主线程执行。

调用栈

调用栈是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。

宏任务和微任务

在每一个宏任务执行完成后,浏览器会立即执行所有来自微任务队列的微任务,这是比执行任何其它宏任务或者渲染更具有优先级的操作。

只有当微任务队列中的所有微任务都执行完之后,才会执行其它宏任务,这保证了在微任务执行时应用程序的环境不会改变。

如果想要在渲染或处理新的事件之前运行一个异步函数,可以通过 queueMicrotsk 方法来达成。

  • 宏任务 - scripts/setTimeOut/setInterval/I/O/UI Rendering
image-20201201110148857
  • 微任务 - Promise.then catch finally/MutationObserver
image-20201201110210529

同步任务和异步任务

Javascript 单线程任务被分为同步任务异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

任务执行逻辑

  • -> 选择当前要执行的任务队列,选择任务队列中最先进入的任务,如果任务队列为空即null,则执行跳转到微任务(MicroTask)的执行步骤。

    -> 将事件循环中的任务设置为已选择任务。

    -> 执行任务。

    -> 将事件循环中当前运行任务设置为 null

    -> 将已经运行完成的任务从任务队列中删除。

    -> microtasks 步骤:进入microtask检查点。

    -> 更新界面渲染。

    -> 返回第一步。

  • -> 设置 microtask 检查点标志为 true

    -> 当事件循环 microtask 执行不为空时:选择一个最先进入的 microtask 队列的 microtask,将事件循环的 microtask 设置为已选择的 microtask,运行 microtask,将已经执行完成的 microtasknull,移出 microtask 中的 microtask

    -> 清理 IndexDB 事务

    -> 设置进入 microtask 检查点的标志为 false

img img

事件循环的一些应用

分割占用 CPU 较多的任务

假设我们有一个需要占用非常多 CPU 的任务,当浏览器在处理这个任务时,它就不能做其它 DOM 相关的操作,处理用户事件等,这将导致浏览器变的卡顿甚至崩溃,显然这是不可接受的。但我们可以通过将大任务分割成较小任务的方法来避免这个问题。

比如下面的代码,在执行时会导致浏览器挂起一段不短的时间,这期间用户事件都不会得到处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let i = 0;

let start = Date.now();

function count() {
// 大任务
for (let j = 0; j < 1e9; j++) {
j++;
}

console.log("Done in " + (Date.now() - start) + "ms");
}

count();

如果用 setTimeout 将其分割成较小的任务,如果在小任务执行时有其它任务要执行,会被放入任务队列,当目前的分割小任务完成后立即执行这个任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let i = 0;

let start = Date.now();

function count() {
do {
i++;
} while(i % 1e6 !== 0)

if (i === 1e9) {
console.log("Done in " + (Date.now() - start) + "ms");
} else {
setTimeout(count);
}
}

count();

永不阻塞

JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。

Node 中的事件循环、定时器和 process.nextTick()

JavaScript 调用栈和执行上下文

执行上下文和执行栈

执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。变量或函数的执行上下文决定了它们能够访问哪些数据以及它们的行为。

执行上下文的类型

  • 全局执行上下文

    默认或者说基础的上下文,所有不在函数内部的代码都在全局上下文中执行。它会创建一个全局对象 window 并将 this 指向它(浏览器环境)。所有通过 var 定义的全局变量和方法都会成为 window 对象的属性和方法。使用 letconst 定义的顶级声明不会定义在全局上下文中,但在作用域链的解析效果上是一样的。

    一个程序中只会有一个全局执行上下文。

  • 函数执行上下文

    每当一个函数被调用时,都会为该函数创建一个新的上下文。

    每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。

  • eval 函数上下文

    执行在 eval 函数内部的代码也会有它属于自己的执行上下文。

执行上下文的内容

ES 5 中执行上下文包含了:

  • 词法环境
  • 变量环境
  • this

词法环境

JavaScript 中每个运行的函数,代码块以及整个脚本,都有一个被称为词法环境的隐藏的内部关联对象。而词法环境对象又由两部分组成:

  • 环境记录 - 存储所有局部变量
  • 一个对外部词法环境的引用

当代码要访问一个变量时,首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

执行栈(调用栈)

执行栈(或者调用栈),也就是在其它编程语言中所说的调用栈,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。

JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。

引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

执行上下文的创建

创建执行上下文有两个阶段:创建阶段和执行阶段

创建阶段

JavaScript 代码执行前,执行上下文将经历创建阶段

  1. this 绑定

    在全局执行上下文中,this 值是全局对象。

    在函数执行上下文中,this 的值取决于函数是如何被调用的。如果被一个引用对象调用,this 的值会被设置为这个对象,否则会被设置为 undefined 或全局对象。

  2. 词法环境组件的创建

    词法环境是一种持有 标识符 - 变量 映射的结构。词法环境的内部有两个组件 :环境记录器和一个外部环境的引用。环境记录器是存储变量和函数声明的实际位置,外部环境引用意味着你可以用它访问其父级词法环境。

    • 全局词法环境

      全局词法环境是没有外部环境引用的词法环境(在全局执行上下文中)。

    • 函数词法环境

      函数内部用户定义的变量存储在环境记录器中。它的外部环境引用可能是全局环境或者任何包含此函数的外部函数。

    环境记录器的两种类型 :

    • 声明式环境记录器存储变量、函数和参数
    • 对象环境记录器用来定义出现在全局上下文中的变量和函数的关系

    简而言之,在全局环境中,环境记录器就是对象环境记录器,在函数环境中,环境记录器就是声明式环境记录器。

  3. 变量环境组件的创建

    变量环境实际上也是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。

    ES 6 中,词法环境和变量环境的一个不同就是前者被用来存储函数声明和变量绑定(letconst),后者只用来存储 var 变量绑定。

执行阶段

在执行阶段,完成对所有变量的分配,最后执行代码。

执行上下文的销毁

执行上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。

闭包

闭包是指内部函数总是可以访问其所在的外部函数中声明的变量和参数,即使在外部函数被返回之后;也可以理解为闭包就是一个绑定了执行环境的函数。

JavaScript 中,函数是天生闭包的。

JavaScript Map 和 Object

Map

Map 是一个保存’键值对’的对象,会记忆键的插入顺序,而且不管是对象还是原始值都能作为其键或值。

如果看过 Map 的实例方法,就会发现它在某些方面起到的作用极为相似,但也有很多的不同:

Map Object
意外的键 默认情况下不包含任何键,只包含显式插入的键。 如果 Object 有原型,原型链上的键名则可能和你在对象上设置的键名冲突
键的类型 可以是任意值,包括函数、对象和原始值 只能是 String 或 Symbol
键的顺序 键是有序的,顺序为插入顺序 键是无序的
Size 通过 size 属性获取 手动计算
迭代 可以直接迭代
性能 在频繁增删键的情况下性能更好

JavaScript 装饰器和转发

装饰器模式

装饰器是一个特殊的函数,它接受一个函数作为参数,并改变它的行为。

它非常重要和普遍的一种应用就是缓存函数返回结果,节省重复计算的时间。

1
2
3
4
5
6
7
8
9
10
11
12
function cacheDecorator(fun) {
const cache = new Map();
return function(x) {
// 如果命中缓存,则直接返回缓存中的计算好的值
if (cache.has(x)) return cache.get(x);
// 如果没有命中缓存,则使用传入的函数重新计算结果
const result = fun(x);
// 将新的结果进行缓存
cache.set(x, result);
return result;
}
}

callapply 都允许显式地设置调用函数执行时的上下文(即 this 值),在作用上它们几乎相同。

不同的是 call 以参数列表的形式接收参数,而 apply 以类数组的形式接收参数。

Homebrew 命令

Mac 上的软件包管理器 - Homebrew

查看信息

1
2
3
4
5
6
7
8
9
10
11
# 查看帮助信息
brew help

# 查询可更新的包
brew outdated

# 查看包信息
brew info <packagename>

# 查看安装列表
brew list

安装和更新

1
2
3
4
5
# 安装 homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

# 更新 homebrew
brew update

软件包管理

homebrew 会将软件安装到自己所在文件夹内

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
# 安装软件
brew install <packagename>

# 更新软件
brew upgrade <packagename>

# 卸载/删除软件
brew uninstall <packagename>

# 安装图形化软件
brew cast <appname>

# 搜索可用的软件
brew search <name>

# 清理旧版本
brew cleanup

# 清理指定包的旧版本
brew cleanup <packagename>

# 查看可清理的旧版本包
brew cleanup -n

# 锁定某个包使其不能更新
brew pin <packagename>

# 解锁
brew unpin <packagename>