前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

JavaScript运行机制Event Loop浏览器与Node差异

首页2018-12-21 23:20:54Front-End
JavaScriptEvent LoopNode.js

同一段代码,在浏览器里跑输出 timer1, promise1, timer2, promise2,搬到 Node 里跑却可能变成 timer1, timer2, promise1, promise2。第一次遇到这个的时候我以为是自己看错了,反复跑了几次发现结果确实会变。

事件循环这个概念大部分人都能背两句,但一到「为什么这两个环境下不一样」「为什么我的页面点了没反应但也没报错」这类具体问题上就卡住了。这篇把浏览器和 Node 两套循环并排放在一起讲,重点在它们的差别,以及这些差别在实际排查中怎么用得上。

想看更完整的规范级拆解和面试题串讲,可以配合 JavaScript事件循环机制 一起读,这篇侧重双环境对比和排查手法。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • JS 为什么是单线程,Web Worker 有没有改变这一点
  • 任务队列、执行栈、回调函数三者的关系
  • 宏任务和微任务的准确分类(以及原文里过时的那几项)
  • 浏览器一轮事件循环的完整顺序,渲染插在哪
  • Node 六个阶段各自在干什么
  • 两个环境下同一段代码为什么会输出不同顺序
  • process.nextTick 和微任务的优先级差别
  • 页面卡死、定时器不准这类问题怎么排查

# 一、JavaScript是单线程

JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。

假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?为了避免这种复杂性,从一诞生 JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以这个标准并没有改变 JavaScript 单线程的本质特征。

这里要分清一件事。单线程说的是「执行 JS 代码的线程只有一个」,不是「浏览器只有一个线程」。渲染、网络请求、定时器计时这些都跑在别的线程上,它们干完活之后把回调塞进队列,等 JS 主线程有空了再来取。这是后面所有内容的前提。

# 二、任务队列

单线程就意味着,所有任务需要排队,前一个任务结束才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大 CPU 忙不过来,倒也算了。但是很多时候 CPU 是闲着的,因为 IO 设备很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来再往下执行。这种干等着实在太浪费。

所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。异步任务指的是,不进入主线程、而进入任务队列(task queue)的任务,只有任务队列通知主线程某个异步任务可以执行了,该任务才会进入主线程执行。

异步执行的运行机制可以拆成四步:

  • 所有同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外还存在一个任务队列(task queue)。只要异步任务有了运行结果,就在任务队列之中放置一个事件
  • 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务于是结束等待状态,进入执行栈开始执行
  • 主线程不断重复上面的第三步

主线程执行栈与任务队列的关系示意图

只要主线程空了,就会去读取任务队列,这就是 JavaScript 的运行机制。这个过程会不断重复。

# 三、事件和回调函数

任务队列是一个事件的队列(也可以理解成消息的队列)。IO 设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入执行栈了。主线程读取任务队列,就是读取里面有哪些事件。

任务队列中的事件除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入任务队列,等待主线程读取。

所谓回调函数(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

任务队列是一个先进先出的数据结构,排在前面的事件优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,任务队列上第一位的事件就自动进入主线程。但是由于存在定时器功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间才能返回主线程。

# 四、JS中的event loop

# 4.1 原理分析

主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。

事件循环示意图,堆与栈调用外部 API 向任务队列投递事件

上图中,主线程运行的时候产生堆(heap)和栈(stack),栈中的代码调用各种外部 API,它们在任务队列中加入各种事件(click、load、done)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。

JS 在执行的过程中会产生执行环境,这些执行环境会被顺序地加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task)队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行。

所以 JS 里所谓的异步,最后还是要回到这条单线程上排队执行,只是排队的时机被推迟了而已。

console.log('script start');

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

console.log('script end');
@前端进阶之旅: 代码已经复制到剪贴板

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为微任务(microtask)和宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。

console.log('script start');

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

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

console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout
@前端进阶之旅: 代码已经复制到剪贴板

以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,微任务在本轮循环末尾就被清空,宏任务要等下一轮。

有一处容易被漏掉,new Promise 里传的那个执行器函数是同步执行的,所以 Promise 这行会跟着 script start 一起先打出来,只有 .then 里的回调才是微任务。

# 4.2 微任务

  • process.nextTick(Node 专属)
  • promise 的 then / catch / finally
  • queueMicrotask
  • MutationObserver

原文这里列了 Object.observe。那个 API 早就被从规范里撤掉了,各家浏览器也都移除了实现,现在写它只会报未定义,我把它换成了 queueMicrotask。

queueMicrotask 是标准化之后专门用来手动往微任务队列里塞回调的入口。在它之前大家用 Promise.resolve().then(fn) 凑合,那个写法会多创建一个 Promise 对象,而且回调里抛错会被 Promise 吞成 rejection,用 queueMicrotask 抛出来的错误则会正常走到全局错误处理。

process.nextTick 严格说也不属于标准的微任务队列,它在 Node 里有一条独立的队列,优先级比 Promise 的微任务还要高,后面会单独说。

# 4.3 宏任务

  • script
  • setTimeout
  • setInterval
  • setImmediate(只有 Node 和老 IE 有,其他浏览器没有)
  • I/O
  • UI rendering

宏任务中包括了 script,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务。

# 4.4 一轮完整的事件循环

浏览器里一轮循环的顺序是这样的:

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

「执行所有微任务」这几个字要抠一下。微任务队列是要被彻底清空的,包括在执行微任务过程中新产生的微任务,也要在本轮一起处理掉。这就带来一个后果,如果你在微任务里递归地生成新微任务,这个循环永远不会结束,浏览器会彻底冻死,连页面都不会重绘。

而同样的死循环换成宏任务(比如 setTimeout 里再 setTimeout),页面还是能响应的,因为每两个宏任务之间浏览器有机会插入渲染。这个差别在排查「页面完全没反应」的问题时非常有用。

通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的界面响应,我们可以把操作 DOM 放入微任务中。

# 五、Node 中的 Event loop

Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。Node 的 Event loop 分为 6 个阶段,它们会按照顺序反复运行。

Node.js 事件循环六个阶段的流转示意图

# 5.1 Node.js的运行机制

  • V8 引擎解析 JavaScript 脚本
  • 解析后的代码调用 Node API
  • libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎
  • V8 引擎再将结果返回给用户

除了 setTimeout 和 setInterval 这两个方法,Node.js 还提供了另外两个与任务队列有关的方法,process.nextTick 和 setImmediate。它们可以帮助我们加深对任务队列的理解。

这里最关键的差别就出来了。浏览器只有一条宏任务队列(不同任务源有各自的队列,但取的时候一次取一个),而 Node 有六个阶段,每个阶段有自己的回调队列。所以在 Node 里问「这个回调什么时候执行」,得先问「它属于哪个阶段」。

# 5.2 各个阶段

fe
  • 一、JavaScript是单线程
  • 二、任务队列
  • 三、事件和回调函数
  • 四、JS中的event loop
    • 4.1 原理分析
    • 4.2 微任务
    • 4.3 宏任务
    • 4.4 一轮完整的事件循环
  • 五、Node 中的 Event loop
    • 5.1 Node.js的运行机制
    • 5.2 各个阶段
      • 5.2.1 timer
      • 5.2.2 I/O
      • 5.2.3 idle, prepare
      • 5.2.4 poll
      • 5.2.5 check
      • 5.2.6 close callbacks
    • 5.3 setTimeout 和 setImmediate 谁先
    • 5.4 微任务在 Node 里什么时候执行
  • 六、这些知识在排查时怎么用
    • 6.1 页面卡住不动,先看是哪种卡
    • 6.2 定时器不准,先确认是不是被排队了
    • 6.3 UI 更新滞后一帧
    • 6.4 Node 服务响应变慢
  • 总结
  • 参考

← 浏览器渲染原理 从DOM树到合成层的五个阶段JavaScript事件机制冒泡捕获与事件委托 →