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

彻底弄懂 JavaScript 执行机制,Event Loop 宏任务与微任务

首页2019-09-22 10:20:54Front-End
JavaScriptEvent Loop异步编程面试题

面试里被问「下面这段代码输出什么」,一堆 setTimeout 套 Promise 套 process.nextTick,能背出答案的人不少,能讲清楚为什么的人少很多。而真到了排查线上问题的时候,靠的恰恰是后者:为什么这个 loading 明明 setState 了却晚了一帧才消失,为什么这个 setTimeout(fn, 0) 在某台机器上要等半秒。

这篇把 JavaScript 的执行机制从头捋一遍,从单线程为什么必须有事件循环讲到宏任务微任务的实际排队规则,再到浏览器和 Node 之间那些容易踩的差异。文中的关键结论我都在 Node 22.23.1 上跑过。

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

  • 单线程为什么需要事件循环,同步任务和异步任务分别去了哪
  • setTimeout 的延迟为什么经常对不上,4 毫秒下限到底怎么回事
  • 宏任务和微任务的划分,以及 process.nextTick 其实不是微任务这件事
  • 一道经典面试题的完整推演,以及在 Node 22 上的实测结果
  • 浏览器和 Node 的事件循环差异,setImmediate 该怎么理解
  • 微任务和页面渲染的关系,为什么 requestAnimationFrame 不能用来做延时

javascript 是一门单线程语言。在 HTML5 中提出了 Web Worker,但 javascript 是单线程这一核心仍未改变。所以一切 javascript 版的「多线程」都是用单线程模拟出来的。

# 一、javascript 事件循环

既然 js 是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理 js 任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。

那么问题来了。假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?

聪明的程序员把任务分为了两类:

  • 同步任务
  • 异步任务

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制,所以我们用导图来说明:

同步任务与异步任务的执行流程示意图

  • 同步和异步任务分别进入不同的执行「场所」,同步的进入主线程,异步的进入 Event Table 并注册函数
  • 当指定的事情完成时,Event Table 会将这个函数移入 Event Queue
  • 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行
  • 上述过程会不断重复,也就是常说的 Event Loop(事件循环)

我们不禁要问了,那怎么知道主线程执行栈为空啊?

原文这里的说法是 js 引擎存在 monitoring process 进程持续检查执行栈。这个描述是一种便于理解的比喻,规范里并没有这么一个进程。真实情况是事件循环本身就是一个循环体,跑完一个任务就回到循环开头看队列里还有没有下一个,没有就阻塞等待。同样地,Event Table 和 Event Queue 也是这篇文章为了讲清楚而起的名字,HTML 规范里对应的术语是任务队列(task queue)和微任务队列(microtask queue)。

这套模型讲执行顺序是够用的,只是别把它当成规范原文去背,面试官追问的时候容易露怯。

let data = [];
$.ajax({
    url:www.javascript.com,
    data:data,
    success:() => {
        console.log('发送成功!');
    }
})
console.log('代码执行结束');
@前端进阶之旅: 代码已经复制到剪贴板

上面是一段简易的 ajax 请求代码:

  • ajax 进入 Event Table,注册回调函数 success
  • 执行 console.log('代码执行结束')
  • ajax 事件完成,回调函数 success 进入 Event Queue
  • 主线程从 Event Queue 读取回调函数 success 并执行

还有一点值得点破。发起网络请求、计时、读文件这些事,并不是 JavaScript 线程自己在做,是宿主环境(浏览器或 Node)用另外的线程在做。JavaScript 只有一个执行线程,但浏览器不是单线程的。所以「单线程」限制的是你写的代码同一时刻只有一行在跑,不限制底下的 I/O 并发。

# 二、setTimeout 和 setInterval

# 2.1 setTimeout

大名鼎鼎的 setTimeout 无需再多言,大家对它的第一印象就是异步可以延时执行,我们经常这么实现延时 3 秒执行:

setTimeout(() => {
    console.log('延时3秒');
},3000)
@前端进阶之旅: 代码已经复制到剪贴板

渐渐地 setTimeout 用的地方多了,问题也出现了。有时候明明写的延时 3 秒,实际却 5、6 秒才执行函数,这又咋回事啊?

setTimeout(() => {
    task();
},3000)
console.log('执行console');
@前端进阶之旅: 代码已经复制到剪贴板

根据前面我们的结论,setTimeout 是异步的,应该先执行 console.log 这个同步任务,所以我们的结论是:

//执行console
//task()
@前端进阶之旅: 代码已经复制到剪贴板

去验证一下,结果正确。然后我们修改一下前面的代码:

setTimeout(() => {
    task()
},3000)

sleep(10000000)
@前端进阶之旅: 代码已经复制到剪贴板

乍一看其实差不多,但我们把这段代码在 chrome 执行一下,却发现控制台执行 task() 需要的时间远远超过 3 秒。说好的延时三秒,为啥现在需要这么长时间啊?

这时候我们需要重新理解 setTimeout 的定义。先说上述代码是怎么执行的:

  • task() 进入 Event Table 并注册,计时开始
  • 执行 sleep 函数,很慢,非常慢,计时仍在继续
  • 3 秒到了,计时事件 timeout 完成,task() 进入 Event Queue,但是 sleep 也太慢了吧,还没执行完,只好等着
  • sleep 终于执行完了,task() 终于从 Event Queue 进入了主线程执行

上述的流程走完,我们知道 setTimeout 这个函数,是经过指定时间后,把要执行的任务(本例中为 task())加入到 Event Queue 中。又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于 3 秒。

所以 setTimeout 的第二个参数是「最少等这么久」,不是「正好等这么久」。

我们还经常遇到 setTimeout(fn, 0) 这样的代码,0 秒后执行又是什么意思呢?是不是可以立即执行呢?

答案是不会的。setTimeout(fn, 0) 的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成、栈为空就马上执行。举例说明:

//代码1
console.log('先执行这里');
setTimeout(() => {
    console.log('执行啦')
},0);
@前端进阶之旅: 代码已经复制到剪贴板
//代码2
console.log('先执行这里');
setTimeout(() => {
    console.log('执行啦')
},3000);
@前端进阶之旅: 代码已经复制到剪贴板

代码 1 的输出结果是:

//先执行这里
//执行啦
@前端进阶之旅: 代码已经复制到剪贴板

代码 2 的输出结果是:

//先执行这里
// ... 3s later
// 执行啦
@前端进阶之旅: 代码已经复制到剪贴板

关于 setTimeout 要补充的是,即便主线程为空,0 毫秒实际上也是达不到的,根据 HTML 的标准最低是 4 毫秒。

这条得说得更准确一点。HTML 规范里的规则是:定时器有一个嵌套层级计数,当嵌套层级超过 5 层、并且传入的时间小于 4 毫秒时,才会被钳制到 4 毫秒。也就是说第一次调用 setTimeout(fn, 0) 并不会被钳到 4ms,是在定时器回调里再套定时器、连续套超过五层之后才会触发。所以那些用 setTimeout 递归做轮询的代码,跑着跑着间隔会自己变长。

还有一条实际影响更大的:页面切到后台标签页时,浏览器会把 setTimeout 的最小间隔限制到 1 秒甚至更长,用来省电。所以靠 setInterval 累加计时器做倒计时,用户切走再切回来一定会对不上,正确做法是每次都用 Date.now() 重新算差值。这个我踩过,本地测一点问题没有,用户反馈说倒计时慢了好几分钟。

# 2.2 setInterval

上面说完了 setTimeout,当然不能错过它的孪生兄弟 setInterval。他俩差不多,只不过后者是循环执行。对于执行顺序来说,setInterval 会每隔指定的时间将注册的函数置入 Event Queue,如果前面的任务耗时太久,那么同样需要等待。

唯一需要注意的一点是,对于 setInterval(fn, ms) 来说,我们已经知道不是每过 ms 毫秒会执行一次 fn,而是每过 ms 毫秒会有 fn 进入 Event Queue。一旦 setInterval 的回调函数 fn 执行时间超过了延迟时间 ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。

补一句实践上的结论:需要周期执行又不希望任务堆积的场景,用 setTimeout 递归代替 setInterval。

function poll() {
  doSomething()
  timer = setTimeout(poll, 1000)   // 上一次干完了才排下一次
}
@前端进阶之旅: 代码已经复制到剪贴板

这样能保证两次执行之间至少隔 1 秒,而不是「每 1 秒往队列里塞一个」。另外定时器忘了清是内存泄漏的常见来源,组件销毁的时候记得 clearTimeout / clearInterval,相关排查手段我写在 JavaScript 内存泄漏排查 那篇里。

# 三、Promise 与 process.nextTick(callback)

传统的定时器我们已经研究过了,接着我们探究 Promise 与 process.nextTick(callback) 的表现。

process.nextTick(callback) 类似 node.js 版的「setTimeout」,在事件循环的下一次循环中调用 callback 回调函数。

除了广义的同步任务和异步任务,我们对任务有更精细的定义:

  • macro-task(宏任务):包括整体代码 script,setTimeout,setInterval
  • micro-task(微任务):Promise,process.nextTick

不同类型的任务会进入对应的 Event Queue,比如 setTimeout 和 setInterval 会进入相同的 Event Queue。

事件循环的顺序决定 js 代码的执行顺序。进入整体代码(宏任务)后开始第一次循环,接着执行所有的微任务,然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。听起来有点绕,我们用一段代码说明:

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

new Promise(function(resolve) {
    console.log('promise');
}).then(function() {
    console.log('then');
})

console.log('console');
@前端进阶之旅: 代码已经复制到剪贴板
fe
  • 一、javascript 事件循环
  • 二、setTimeout 和 setInterval
    • 2.1 setTimeout
    • 2.2 setInterval
  • 三、Promise 与 process.nextTick(callback)
    • 3.1 关于任务分类,有三处要修正
    • 3.2 微任务可以饿死主线程
  • 四、一道经典题的完整推演
    • 4.1 这个答案在老版本 Node 上是不一样的
  • 五、浏览器和 Node 的事件循环差异
  • 六、微任务和渲染时机
  • 总结
  • 参考

← ES6 Set、WeakSet、Map、WeakMap 用法与区别详解Typescript实践总结 从基础类型到工程化与项目实战 →