jieye の 数字花园

Search

Search IconIcon to open search

browser

Last updated Aug 19, 2022

# JS 的单线程

很多人都知道的是,JavaScript 是一门动态的解释型的语言,具有跨平台性。在被问到 JavaScript 为什么是一门单线程的语言,有的人可能会这么回答:“语言特性决定了 JavaScript 是一个单线程语言,JavaScript 天生是一个单线程语言”,这只不过是一层糖衣罢了。

JavaScript 从诞生起就是单线程,原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。

准确的来说,我认为 JavaScript 的单线程是指 JavaScript 引擎是单线程的,JavaScript 的引擎并不是独立运行的,跨平台意味着 JavaScript 依赖其运行的宿主环境 — 浏览器 (大部分情况下是浏览器)。

浏览器需要渲染 DOM,JavaScript 可以修改 DOM 结构,JavaScript 执行时,浏览器 DOM 渲染停止。如果 JavaScript 引擎线程不是单线程的,那么可以同时执行多段 JavaScript,如果这多段 JavaScript 都操作 DOM,那么就会出现 DOM 冲突。

举个例子来说,在同一时刻执行两个 script 对同一个 DOM 元素进行操作,一个修改 DOM,一个删除 DOM,那这样话浏览器就会懵逼了,它就不知道到底该听谁的,会有资源竞争,这也是 JavaScript 单线程的原因之一。

# 浏览器的多线程

之前说过,JavaScript 运行的宿主环境浏览器是多线程的。

以 Chrome 来说,我们可以通过 Chrome 的任务管理器来看看。

img

当你打开一个 Tab 页面的时候,就创建了一个进程。如果从一个页面打开了另一个页面,打开的页面和当前的页面属于同一站点的话,那么这个页面会复用父页面的渲染进程。

# 浏览器主线程常驻线程

  1. GUI 渲染线程

    • 绘制页面,解析 HTML、CSS,构建 DOM 树,布局和绘制等
    • 页面重绘和回流
    • 与 JS 引擎线程互斥,也就是所谓的 JS 执行阻塞页面更新
  2. JS 引擎线程

    • 负责 JS 脚本代码的执行
    • 负责准执行准备好待执行的事件,即定时器计数结束,或异步请求成功并正确返回的事件
    • 与 GUI 渲染线程互斥,执行时间过长将阻塞页面的渲染
  3. 事件触发线程

    • 负责将准备好的事件交给 JS 引擎线程执行
    • 多个事件加入任务队列的时候需要排队等待 (JS 的单线程)
  4. 定时器触发线程

    • 负责执行异步的定时器类的事件,如 setTimeout、setInterval
    • 定时器到时间之后把注册的回调加到任务队列的队尾
  5. HTTP 请求线程

    • 负责执行异步请求
    • 主线程执行代码遇到异步请求的时候会把函数交给该线程处理,当监听到状态变更事件,如果有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行

# Chrome 多进程架构的好处

之前有提到,Chrome 用的是多进程的渲染方式,最容易想到的场景就是每个窗口(Tab)都有一个独立的渲染进程。假设你打开了三个浏览器窗口,当其中一个窗口因为某种原因崩掉的时候,你大可以直接关闭这个不再响应的窗口并继续你在其他窗口的工作。我们换一个浏览器,所有的窗口都共享同一个进程,当一个窗口挂掉的时候,所有的窗口都直接挂掉了(像不像理财的时候人们总是说:“不要把所有的钱放在同一个钱包里”?)。

img

将浏览器的工作拆分成不同的进程还有一个好处,就是安全。由于操作系统提供了一种限制进程“权限”的方法,因此浏览器可以将特定的功能和进程有效的隔离开。比如,Chrome 会限制用来处理用户输入的渲染进程去直接访问文件。

每个进程都有各自的内存空间,因此它们常会各自拥有一份基础功能的拷贝。正因为它们之间不像同一进程中的线程那样能够共享资源,所以就需要更多的内存占用。为了节省内存,Chrome 对其自身可调用的进程在数量上做了限制。具体的限制大小在不同性能的机器上各不相同,唯一确定的是,当达到了这个上限后,Chrome 会将同站点的多个窗口交给同一个进程来管理。

# Chrome 服务化 —节省更多的内存

浏览器进程也应用了相同的方案。Chrome 正在进行架构层面的整改,目的是将浏览器的各部分功能变成独立的服务,这样就能轻松的将其拆分为不同的进程,也能更加灵活的互相组合。

总的来说,当 Chrome 在较高性能的设备上运行时,它会将每个服务分配至不同的进程,以此来获得更强的运行时稳定性和健壮性;反之,如果 Chrome 运行在一台资源受限的设备上时,Chrome 会将服务整合在一个进程中,以此来节省内存的占用。像这种通过整合进程资源以此来节省内存的手段,已经被用于 Android 上了。

img

# Chrome 站点隔离(Site Isolation)

站点隔离是 Chrome 在其 67 桌面版上新增的特性,基本原则是不同的站点各自运行在自己的沙箱环境中,独享进程,并且不允许通信。我们已经讨论过每个窗口一个进程的模型,在这个模型中,浏览器允许跨站点的 iframe 独立进程共享不同站点之间的内存空间。早先在一个渲染进程中(窗口)同时运行 a.com 和 b.com 看起来没有什么问题,因为有同源策略,确保一个站点未经同意就无法访问其他站点的数据。绕过同源策略基本上成为了所有安全攻击的指导方针。而进程间的相互隔离是将站点分开的最佳途径(感兴趣的同学可以去了解一下 Meltdown 和 Spectre 攻击)。

img

经过多年的工程上的努力,如今的站点隔离已经默认为用户开启了。事实上,站点隔离并不仅仅是为站点分配不同的渲染进程这么简单,它从根本上改变了 iframe 之间的通信方式。打开运行有不同站点 iframe 的开发者工具,意味着浏览器必须做很多看不到的幕后工作,才能让这一切看起来和以前没有什么区别,即使是简简单单的 ctrl+F 在这个场景下也意味着在不同的渲染进程中查询字符串。网上有很多文章介绍浏览器的站点隔离策略,当你看完那些之后就会意识到,为什么站点隔离值得 Chrome 团队为其发布一个独立版本了。

# 输入 URL 后发生了什么

输入一个 url,浏览器会从服务端获取数据并将页面展示出来。本文会聚焦在用户通过浏览器向一个站点发起访问请求以及浏览器准备渲染这个页面的部分,这个过程我称之为导航。

img

我们在上一篇文章中提过,所有处于窗口之外的部分都由同一个浏览器进程进行掌管。浏览器的进程又同时拥有许多线程,掌管浏览器的不同部分:UI 线程用来绘制顶部的操作按钮和输入框、网络线程负责处理并接收来自互联网的数据、存储线程控制着访问本地文件的权限等。当你将一个网站的 url 输入到浏览器的地址栏时,此刻正是浏览器进程中的 UI 线程在起作用。

# Step 1:处理用户输入

当用户开始在地址栏输入时,UI 线程首先会问:“大兄弟,你输入的是个查询字符串还是网站地址?”。因为 Chrome 的地址栏同时还是个搜索框,所以 UI 线程需要解析用户的输入,才能决定该直接访问网址还是把用户的输入丢给搜索引擎处理。

img

# Step 2:开始导航

当用户按下回车键后,UI 线程要求网络线程去获取网站的内容。窗口的 Tab 上会开始转菊花,网络线程会采用一系列的协议和操作(比如 DNS)查询必要的信息并为请求建立连接。

img

此时,网络线程可能会收到来自服务器的一个标记着重定向指令的头部比如 HTTP 301,在这种情况下,网络线程会把这件事情告诉 UI 线程,之后则会发起一次指向重定向地址的新的网络请求。

# Step 3:读取响应

img

当响应的数据开始传送到浏览器时,网络线程会在必要的情况下检查一些来自响应的字段。响应数据的 Content-Type 字段会表示当前返回的是哪种类型的数据,但它也不完全靠谱,经常会出现丢失或者干脆不准确的情况,但也不用担心,MIME 嗅探[3] 会完成缺失的工作。正如源码[4] 的注释中写道,这是一个可以被解释为 hack 的方案,如果感兴趣的话,你也可以去阅读这些注释,这样就能了解不同的浏览器是如何将实际的数据与 Content-Type 匹配了。

如果响应数据是一个 HTML 文件,那么接下来的一步会是把数据传递给浏览器的渲染进程;但如果数据是 zip 压缩文件或其他类型的文件,意味着这将被定位成一次下载动作,于是浏览器会将数据转交给下载管理器去处理。

img

通常这一步也是安全检测[5] 发生的时候:如果域名或响应数据和已知的恶意网站匹配时,网络进程会抛出一个警告,并展现一个告警的页面。另外,CORB[6] 检测也会开始工作,确保那些来自敏感站点的跨站响应数据不会进入到浏览器的渲染进程中。

# Step 4:渲染进程

网络线程以获取了全部的数据,并完成了所有需要的检查,此刻它自信的告诉 UI 线程:“小兄弟,数据准备好了!”。接着,UI 线程会唤起一个渲染进程去渲染页面。

img

由于网络情况的不可控,一个请求可能会花上好几百毫秒才能把响应数据拿回来,所以这里浏览器默认开启了用来加速这一过程的优化。在 Step 2 中,当 UI 线程将需要请求的 url 告诉网络线程时,其实它本身已经知道要导航到哪个网站了,于是 UI 线程在把 url 传递给网络线程的同时,会尝试启动一个渲染进程。如果一切都按照预期正常进行的话,当网络线程拿到数据时,渲染进程就已经处于待命状态了。也会有例外的情况:比如导航重定向到一个另外的站点,那么预先启动好的渲染进程将不会被使用,这导致 UI 线程需要重新启动一个渲染进程。

# Step 5:触发导航

现在我们假设数据和渲染进程都准备好了,浏览器进程通过 IPC 告知渲染进程可以出发本次导航了。与此同时,数据流也将传递给渲染进程,这样后者就能继续接收 HTML 数据。一旦浏览器收到了来自渲染进程的导航启动信号,这次导航也就完成了,下一步进入文档的加载阶段。

到这会儿,浏览器的地址栏更新,安全指示符和站点的设置 UI 会将新页面的信息呈现出来。当前窗口的 session 将会更新,刚导航到的页面会被后退/前进按钮记录到窗口的页面历史中。为了便于在关闭窗口时恢复页面,历史的会话记录会保存在本地的磁盘上。

img

# Extra Step:初始加载完成

当导航触发后,渲染进程会持续接收资源并渲染页面。我们将在下一篇文章中讨论这一步的更多细节。当渲染进程“完成”渲染后,它会通过 IPC 告知浏览器进程(页面的 onload 事件均已执行完毕后),UI 线程也就不再在 tab 上转菊花了。

上面的“完成”两个字,之所以打了双引号,因为在实际场景中,它通常并不真正意味着完成,因为客户端的 JavaScript 可能在此时持续地加载资源并渲染新的视图。

img

# 导航到另一个网站

一次简单的导航截至目前已经完成了。假如这时用户输入了一个不同的 url 会发生什么呢?其实也没啥,浏览器进程会按照上面的步骤导航到这个网站。但在这一切开始之前,浏览器会检查当前已经渲染好了的网站是否需要在网页卸载之前搞一点事情,这就是 beforeunload 事件。

beforeunload 事件中,我们可以在用户即将跳转至其他页面或者关闭 Tab 的时候发起一个“确认离开当前页面?”的二次确认。Tab 中的所有东西都由渲染进程控制着,当然也包括开发者编写的 JavaScript,所以当一个新的导航请求即将到来时,浏览器进程会对当前的渲染进程做最后的检查。

img

我们应当尽量避免在 beforeunload 中添加总会执行的事件代码,这会造成更多的交互延时,毕竟它们总会在新的导航开始之前执行。只在需要的时候添加这些代码,比如提醒用户如果进入新的页面那么当前页面的数据会丢失。

如果导航是在渲染进程中被创建的(比如用户点击了页面上的某一链接或者在 JavaScript 运行了 window.location.href = 'https://kyrieliu.cn' ),则当前的渲染进程会首先检查是 beforeunload 中是否有东西需要执行。之后,它会经历与浏览器进程直接发起导航后一样的导航过程。

当新的导航将发往与当前页面不同的站点时,浏览器将会创建一个新的渲染进程去处理这些新工作,旧的渲染进程则则用来在剩余的时间里处理诸如 unload 的页面事件。如果你想了解更多的话,可以看看页面生命周期概览[7] 和页面生命周期 API[8] 这两篇文章。

img

# 如果有 Service Worker…

Service Worker[9] 的引入会对页面的导航流程带来一些改变。Service Worker 是一种可以在应用代码中编写网络代理的方法;增强了开发者对于本地缓存以及何时发起网络请求的控制。如果 Service Worker 提前设置了从本地缓存中读取某一页面的数据,那么也就不需要发起网络请求了。

需要明确的一点是,即使 Service Worker 提供了听起来很高端的功能,但它实质上也是运行在渲染进程中的 JavaScript 代码。那么问题来了:当用户发起一次导航时,浏览器进程是如何知道目标站点存在一个 Service Worker 的呢?

img

当一个 Service Worker 注册后,它的作用域会保存在一个引用中(你可以通过 Service Worker 的生命周期[10] 这篇文章了解我所说的“作用域”)。当导航发生时,网络线程会依据域名在已注册的 Service Worker 作用域集合中查询,如果找到某个对应的 Service Worker,UI 线程会发起一个渲染进程去执行 Service Worker 中的代码。Service Worker 可以从本地缓存中加载数据(无需发起网络请求),也可以选择通过网络请求获取最新的资源和数据。

img

# 导航预加载

相信你可以发现,如果 Service Worker 最终决定从网络中请求数据,那么之前在浏览器进程和渲染进程之间所发生的通信都将成为导致响应延时的罪魁祸首。导航预加载[11] 就是用来加速这一进程的机制:与 Service Worker 并行启动去加载资源。它将为这些请求设置一个 Header,由服务端来决定为这些请求发送不同的内容;比如,仅返回更新的数据而不是整个文档。

img

# 浏览器端的 Event Loop

img

上图是一张 JS 的运行机制图,Js 运行时大致会分为几个部分:

  1. Call Stack:调用栈 (执行栈),所有同步任务在主线程上执行,形成一个执行栈,因为 JS 单线程的原因,所以调用栈中每次只能执行一个任务,当遇到的同步任务执行完之后,由任务队列提供任务给调用栈执行。
  2. Task Queue:任务队列,存放着异步任务,当异步任务可以执行的时候,任务队列会通知主线程,然后该任务会进入主线程执行。任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中。

说到这里,Event Loop 也可以理解为:不断地从任务队列中取出任务执行的一个过程。

# 同步任务和异步任务

上文已经说过了 JavaScript 是一门单线程的语言,一次只能执行一个任务,如果所有的任务都是同步任务,那么程序可能因为等待会出现假死状态,这对于一个用户体验很强的语言来说是非常不友好的。

比如说向服务端请求资源,你不可能一直不停的循环判断有没有拿到数据,就好像你点了个外卖,点完之后就开始一直打电话问外卖有没有送到,外卖小哥都会抄着锅铲来打你 (狗头)。因此,在 JavaScript 中任务有了同步任务和异步任务,异步任务通过注册回调函数,等到数据来了就通知主程序。

  1. 同步任务:必须等到结果来了之后才能做其他的事情,举例来说就是你烧水的时候一直等在水壶旁边等水烧开,期间不做其他的任何事情。
  2. 异步任务:不需要等到结果来了才能继续往下走,等结果期间可以做其他的事情,结果来了会收到通知。举例来说就是你烧水的时候可以去做自己想做的事情,听到水烧开的声音之后再去处理。

从概念就可以看出来,异步任务从一定程度上来看比同步任务更高效一些,核心是提高了用户体验。

# Event Loop

Event Loop 很好的调度了任务的运行,宏任务和微任务也知道了,现在我们就来看看它的调度运行机制。

JavaScript 的代码执行时,主线程会从上到下一步步的执行代码,同步任务会被依次加入执行栈中先执行,异步任务会在拿到结果的时候将注册的回调函数放入任务队列,当执行栈中的没有任务在执行的时候,引擎会从任务队列中读取任务压入执行栈 (Call Stack) 中处理执行。

# 宏任务和微任务

现在就有一个问题了,任务队列是一个消息队列,先进先出,那就是说,后来的事件都是被加在队尾等到前面的事件执行完了才会被执行。如果在执行的过程中突然有重要的数据需要获取,或是说有事件突然需要处理一下,按照队列的先进先出顺序这些是无法得到及时处理的。这个时候就催生了宏任务和微任务,微任务使得一些异步任务得到及时的处理

曾经看到的一个例子很好,宏任务和微任务形象的来说就是:你去营业厅办一个业务会有一个排队号码,当叫到你的号码的时候你去窗口办充值业务 (宏任务执行),在你办理充值的时候你又想改个套餐 (微任务),这个时候工作人员会直接帮你办,不可能让你重新排队。

所以上文说过的异步任务又分为宏任务和微任务,JS 运行时任务队列会分为宏任务队列和微任务队列,分别对应宏任务和微任务。

先介绍一下 (浏览器环境的) 宏任务和微任务大致有哪些:

# 事件运行顺序

  1. 执行同步任务,同步任务不需要做特殊处理,直接执行 (下面的步骤中遇到同步任务都是一样处理) — 第一轮从 script 开始
  2. 从宏任务队列中取出队头任务执行
  3. 如果产生了宏任务,将宏任务放入宏任务队列,下次轮循的时候执行
  4. 如果产生了微任务,将微任务放入微任务队列
  5. 执行完当前宏任务之后,取出微任务队列中的所有任务依次执行
  6. 如果微任务执行过程中产生了新的微任务,则继续执行微任务,直到微任务的队列为空
  7. 轮循,循环以上 2 - 6

总的来说就是:同步任务/宏任务 -> 执行产生的所有微任务 (包括微任务产生的微任务) -> 同步任务/宏任务 -> 执行产生的所有微任务 (包括微任务产生的微任务) -> 循环……

注意:微任务队列

# 举个栗子

光说不练假把式,现在就来看一个例子:

img举个栗子

放图的原因是为了让大家在看解析之前可以先自己按照运行顺序走一遍,写好答案之后再来看解析。
解析:
(用绿色的表示同步任务和宏任务,红色表示微任务)

 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
+  console.log('script start')
+  setTimeout(function() {
+    console.log('setTimeout')
+  }, 0)
+  new Promise((resolve, reject)=>{
+    console.log("promise1")
+    resolve()
+  })
-  .then(()=>{
-    console.log("then11")
+    new Promise((resolve, reject)=>{
+      console.log("promise2")
+      resolve();
+    })
-    .then(() => {
-      console.log("then2-1")
-    })
-    .then(() => {
-      console.log("then2-2")
-    })
-  })
-  .then(()=>{
-    console.log("then12")
-  })
+  console.log('script end')
  1. 首先遇到 console.log(),输出 script start
  2. 遇到 setTimeout 产生宏任务,注册到宏任务队列 [setTimeout],下一轮 Event Loop 的时候在执行
  3. 然后遇到 new Promise 构造声明 (同步),log 输出 promise1,然后 resolve
  4. resolve 匹配到 promise1 的第一个 then,把这个 then 注册到微任务队列 [then11] 中,继续当前整体脚本的执行
  5. 遇到最后的一个 log,输出 script end当前执行栈清空
  6. 从微任务队列中取出队头任务 ’then11’ 进行执行,其中有一个 log,输出 then11
  7. 往下遇到 new Promise 构造声明 (同步),log 输出 promise2,然后 resolve
  8. resolve 匹配到 promise2 的第一个 then,把这个 then 注册到微任务队列 [then2-1],当前 then11 可执行部分结束,然后产生了 promise1 的第二个 then,把这个 then 注册到微任务队列 [then2-1, then12]
  9. 拿出微任务队头任务 ’then2-1’ 执行,log 输出 then2-1,触发 promise2 的第二个 then,注册到微任务队列 [then12, then2-2]
  10. 拿出微任务队头任务 ’then12’,log 输出 then12
  11. 拿出微任务队头任务 ’then2-2’,log 输出 then2-2
  12. 微任务队列执行完毕,别忘了宏任务队列中的 setTimeout,log 输出 setTimeout

经过以上一番缜 (xia) 密 (gao) 分析,希望没有绕晕你,最后的输出结果就是:
script start -> promise1 -> script end -> then11 -> promise2 -> then2-1 -> then12 -> then2-2 -> setTimeout

# 宏任务?微任务?

不知道大家看了宏任务和微任务之后会不会有一个疑惑,宏任务和微任务都是异步任务,微任务之前说过了是为了及时解决一些必要事件而产生的。

# 特殊的点

  1. async 隐式返回 Promise 作为结果
  2. 执行完 await 之后直接跳出 async 函数,让出执行的所有权
  3. 当前任务的其他代码执行完之后再次获得执行权进行执行
  4. 立即 resolve 的 Promise 对象,是在本轮 " 事件循环 " 的结束时执行,而不是在下一轮 " 事件循环 " 的开始时

# 再举个栗子

 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
console.log('script start')

  async function async1() {
      await async2()
      console.log('async1 end')
  }
  async function async2() {
      console.log('async2 end')
  }
  async1()

  setTimeout(function() {
      console.log('setTimeout')
  }, 0)

  newPromise(resolve => {
      console.log('Promise')
      resolve()
  })
  .then(function() {
      console.log('promise1')
  })
  .then(function() {
      console.log('promise2')
  })

  console.log('script end')

按照之前的分析方法去分析之后就会得出一个结果:
script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout

可以看出 async1 函数获取执行权是作为微任务的队尾,但是,在 Chrome73(金丝雀) 版本之后,async 的执行优化了,它会在 promise1 和 promise2 的输出之前执行。笔者大概了解了一下应该是用 PromiseResolve 对 await 进行了优化,减少了 Promise 的再次创建,有兴趣的小伙伴可以看看 Chrome 的源码。

# Node 中的 Event Loop

Node 中也有宏任务和微任务,与浏览器中的事件循环类似。Node 与浏览器事件循环不同,其中有多个宏任务队列,而浏览器是只有一个宏任务队列。

Node 的架构底层是有 libuv,它是 Node 自身的动力来源之一,通过它可以去调用一些底层操作,Node 中的 Event Loop 功能就是在 libuv 中封装实现的。

# 宏任务和微任务

Node 中的宏任务和微任务在浏览器端的 JS 相比增加了一些,这里只列出浏览器端没有的:

# 事件循环机制的六个阶段

image-20200402223627057

Node 的事件循环分成了六个阶段,每个阶段对应一个宏任务队列,相当于是宏任务进行了一个分类。

  1. timers(计时器)
    执行 setTimeout 以及 setInterval 的回调
  2. I/O callbacks
    处理网络、流、TCP 的错误回调
  3. idel, prepare — 闲置阶段
    node 内部使用
  4. poll(轮循)
    执行 poll 中的 I/O 队列,检查定时器是否到时间
  5. check(检查)
    存放 setImmediate 回调
  6. close callbacks
    关闭回调,例如 sockect.on(‘close’)

# 轮循顺序

执行的轮循顺序 — 每个阶段都要等对应的宏任务队列执行完毕才会进入到下一个阶段的宏任务队列

  1. timers
  2. I/O callbacks
  3. poll
  4. setImmediate
  5. close events

每两个阶段之间执行微任务队列

# Event Loop 过程

  1. 执行全局的 script 同步代码
  2. 执行微任务队列,先执行所有 Next Tick 队列中的所有任务,再执行其他的微任务队列中的所有任务
  3. 开始执行宏任务,共六个阶段,从第一个阶段开始执行自己宏任务队列中的所有任务 (浏览器是从宏任务队列中取第一个执行!!)
  4. 每个阶段的宏任务执行完毕之后,开始执行微任务
  5. TimersQueue -> 步骤 2 -> I/O Queue -> 步骤 2 -> Check Queue -> 步骤 2 -> Close Callback Queue -> 步骤 2 -> TimersQueue …

这里要注意的是,nextTick 事件是一个单独的队列,它的优先级会高于微任务,所以在当前宏任务/同步任务执行完成之后,会先执行 nextTick 队列中的所有任务,再去执行微任务队列中的所有任务。

# Node 与浏览器的 Event Loop 差异

浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务

img

接下我们通过一个例子来说明两者区别:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)

浏览器端运行结果:timer1=>promise1=>timer2=>promise2

浏览器端的处理过程如下:

img

Node 端运行结果:timer1=>timer2=>promise1=>promise2

Node 端的处理过程如下:

img

浏览器和 Node 环境下,microtask 任务队列的执行时机不同

# setTimeout 和 setImmediate

在这里要单独说一下 setTimeout 和 setImmediate,setTimeout 定时器很熟悉,那就说说 setImmediate

setImmediate() 方法用于把一些需要长时间运行的操作放在一个回调函数里,并在浏览器完成其他操作(如事件和显示更新)后立即运行回调函数。从定义来看就是为了防止一些耗时长的操作阻塞后面的操作,这也是为什么 check 阶段运行顺序排的比较后。

# 举个栗子

我们来看这样的一个例子:

1
2
3
4
5
6
7
setTimeout(() => {
  console.log('setTimeout')
}, 0)

setImmediate(() => {
  console.log('setImmediate')
})

这里涉及 timers 阶段和 check 阶段,按照上面的运行顺序来说,timers 阶段是在第一个执行的,会早于 check 阶段。运行这段程序可以看到如下的结果:

image-20200402224016596

可是再多运行几次,你就会看到如下的结果:

image-20200402224028075

setImmediate 的输出跑到 setTimeout 前面去了,这时候就是:小朋友你是否有很多的问号❓

# 分析

我们来分析一下原因,timers 阶段确实是在 check 阶段之前,但是在 timers 阶段时候,这里的 setTimeout 真的到了执行的时间吗?

这里就要先看看 setTiemout(fn, 0),这个语句的意思不是指不延迟的执行,而是指在可以执行 setTimeout 的时候就立即执行它的回调,也就是处理完当前事件的时候立即执行回调。

在 Node 中 setTimeout 第二个时间参数的最小值是 1ms,小于 1ms 会被初始化为 1(浏览器中最小值是 4ms),所以在这里 setTimeout(fn, 0) === setTimeout(fn, 1)

setTimeout 的回调函数在 timers 阶段执行,setImmediate 的回调函数在 check 阶段执行,Event Loop 的开始会先检查 timers 阶段,但是在代码开始运行之前到 timers 阶段 (代码的启动、运行) 会消耗一定的时间,所以会出现两种情况:

  1. timers 前的准备时间超过 1ms,满足 loop -> timers >= 1,setTimeout 的时钟周期到了,则执行 timers 阶段 (setTimeout) 的回调函数
  2. timers 前的准备时间小于 1ms,还没到 setTimeout 预设的时间,则先执行 check 阶段 (setImmediate) 的回调函数,下一次 Event Loop 再进入 timers 阶段执行 timer 阶段 (setTimeout) 的回调函数

最开始就说了,一个优秀的程序员要让自己的代码按照自己想要的顺序运行,下面我们就来控制一下 setTimeout 和 setImediate 的运行。

1
2
3
4
5
6
7
8
9
const start = Date.now()
  while (Date.now() - start < 10)
  setTimeout(() => {
  console.log('setTimeout')
  }, 0)

  setImmediate(() => {
    console.log('setImmediate')
  })
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const fs = require('fs')

fs.readFile(__dirname, () => {
  setTimeout(() => {
    console.log('setTimeout')
  }, 0)
  
  setImmediate(() => {
    console.log('setImmediate')
  })
})

# Node 11.x 的变化

timers 阶段的执行有所变化

1
2
3
4
5
setTimeout(() =>console.log('timeout1'))
setTimeout(() => {
 console.log('timeout2')
 Promise.resolve().then(() =>console.log('promise resolve'))
})
  1. node 10 及之前的版本:
    要考虑上一个定时器执行完成时,下一个定时器是否到时间加入了任务队列中,如果未到时间,先执行其他的代码。
    比如:
    timer1 执行完之后 timer2 到了任务队列中,顺序为 timer1 -> timer2 -> promise resolve
    timer2 执行完之后 timer2 还没到任务队列中,顺序为 timer1 -> promise resolve -> timer2
  2. node 11 及其之后的版本:
    timeout1 -> timeout2 -> promise resolve
    一旦执行某个阶段里的一个宏任务之后就立刻执行微任务队列,这和浏览器端运行是一致的。

# 小结

Node 和端浏览器端有什么不同

  1. 浏览器端的 Event Loop 和 Node.js 中的 Event Loop 是不同的,实现机制也不一样
  2. Node.js 可以理解成有 4 个宏任务队列和 2 个微任务队列,但是执行宏任务时有 6 个阶段
  3. Node.js 中限制性全局 script 代码,执行完同步代码后,先从微任务队列 Next Tick Queue 中取出所有任务放入调用栈执行,再从其他微任务队列中取出所有任务放入调用栈中执行,然后开始宏任务的 6 个阶段,每个阶段都将其宏任务队列中的所有任务都取出来执行 (浏览器是只取第一个执行),每个宏任务阶段执行完毕之后开始执行微任务,再开始执行下一阶段宏任务,以此构成事件循环
  4. 宏任务包括 ….
  5. 微任务包括 ….

看到这里,你应该对浏览器端和 Node 端的 Event Loop 有了一定的了解,那就留一个题目。

image-20200402224502960

不直接放代码是想让大家先自己思考然后在敲代码运行一遍

# 浏览器存储

# Cookies

# 多种浏览器存储方式并存,如何选择?

img

API

服务端向客户端发送的 cookie(HTTP 头,不带参数):
Set-Cookie: = (name 可选)

服务端向客户端发送的 cookie(HTTP 头,带参数):
Set-Cookie: =;(可选参数1);(可选参数2)

客户端设置 cookie:

1
document.cookie = "<cookie-name>=<cookie-value>;(可选参数1);(可选参数2)"

可选参数:
Expires=:cookie 的最长有效时间,若不设置则 cookie 生命期与会话期相同

Max-Age=:cookie 生成后失效的秒数

Domain=:指定 cookie 可以送达的主机域名,若一级域名设置了则二级域名也能获取。

Path=:指定一个 URL,例如指定 path=/docs,则”/docs”、”/docs/Web/“、”/docs/Web/Http”均满足匹配条件

Secure:必须在请求使用 SSL 或 HTTPS 协议的时候 cookie 才回被发送到服务器

HttpOnly:客户端无法更改 Cookie,客户端设置 cookie 时不能使用这个参数,一般是服务器端使用

示例:

1
2
3
Set-Cookie: sessionid=aes7a8; HttpOnly; Path=/

document.cookie = "KMKNKK=1234;Sercure"

可选前缀:
__Secure-:以 __Secure- 为前缀的 cookie,必须与 secure 属性一同设置,同时必须应用于安全页面(即使用 HTTPS)

__Host-:以 __Host- 为前缀的 cookie,必须与 secure 属性一同设置,同时必须应用于安全页面(即使用 HTTPS)。必须不能设置 domian 属性(这样可以防止二级域名获取一级域名的 cookie),path 属性的值必须为”/“。

前缀使用示例:

1
2
3
4
5
Set-Cookie: __Secure-ID=123; Secure; Domain=example.com
Set-Cookie: __Host-ID=123; Secure; Path=/

document.cookie = "__Secure-KMKNKK=1234;Sercure"
document.cookie = "__Host-KMKNKK=1234;Sercure;path=/"

# Local Storage

API

1
2
3
4
5
6
//sessionStorage用法相同
localStorage.setItem("name",1);   // 以"x"为名字存储一个数值
localStorage.getItem("name");     // 获取数值
localStorage.key(i);              // 获取第i对的名字
localStorage.removeItem("name");  // 获取该对的值
localStorage.clear();             // 全部删除

# Session Storage

# indexedDB

1
为应用创建离线版本

cookie 种在主站下,二级域名也会携带这个域名,造成流量的浪费

# Service Worker 产生的意义

# PWA 与 Service Worker

# Chrom 插件 Lighthouse

检测是不是一个渐进式 web app

特点

# Service Worker

service worker 是一个脚本,浏览器独立于当前页面,将其在后台运行,为实现一些不依赖页面的或者用户交互的特性打开了一扇大门。在未来这些特性将包括消息推送,背景后台同步,geofencing(地理围栏定位),但他将推出的第一个首要的特性,就是拦截和处理网络请求的能力,包括以编程方式来管理被缓存的响应。

img

# 案例分析

Service Worker学习与实践

了解servie worker

1
2
chrome://serviceworker-internals/`  
 `chrome://inspect/#service-worker/

service worker 网络拦截能力,存储 Cache Storage,实现离线应用

# Service Worker 离线应用

serviceworker 需要 https 协议

# 如何实现 ServiceWorker 与主页面之间的通信

[lavas](