技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

这一次,彻底弄懂 JavaScript 执行机制

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/127-这一次,彻底弄懂 JavaScript 执行机制
本文目录8 个章节
  1. 1. 关于 JavaScript
  2. 2. 事件循环的入门模型
  3. 3. 又爱又恨的 setTimeout
  4. 4. 又恨又爱的 setInterval
  5. 5. Promise 与 process.nextTick()
  6. 6. Node.js 中的复杂示例
  7. 7. 浏览器中的 microtask 与渲染
  8. 8. 写在最后

这一次,彻底弄懂 JavaScript 执行机制

Category(分类): JavaScript Status: 未知

本文的目的,是帮助我们分析给定代码的输出顺序。不论是面试题还是日常开发,都应该先区分两件事:当前 JavaScript job 中的同步代码按顺序执行,而异步回调要由宿主环境安排到后续任务中。不能把“JavaScript 通常在一个执行代理中按顺序执行”简单推导成“所有异步代码也按照源代码顺序立刻执行”。

1. 关于 JavaScript

JavaScript 语言本身规定的是执行语义;浏览器、Node.js、Worker 等宿主负责提供事件循环、定时器、网络、文件系统和渲染等能力。浏览器主线程通常一次执行一个 job,但 Web Worker 可以在独立的执行代理中运行 JavaScript,并通过消息或共享内存通信。因此“JavaScript 是单线程”只能作为主线程入门模型,不能当作所有宿主、所有 agent 的绝对结论。

普通同步代码确实按语句顺序执行:

const a = '1'
console.log(a)

const b = '2'
console.log(b)

原文配图 127-01

然而,定时器回调和 Promise 反应并不会在注册处立即执行:

setTimeout(() => {
  console.log('定时器开始啦')
}, 0)

new Promise(resolve => {
  console.log('马上执行 for 循环啦')
  for (let i = 0; i < 10000; i++) {
    if (i === 99) resolve()
  }
}).then(() => {
  console.log('执行 then 函数啦')
})

console.log('代码执行结束')

在浏览器和 Node.js 的常见实现中,同步输出通常是:

马上执行 for 循环啦
代码执行结束
执行 then 函数啦
定时器开始啦

Promise 的 then 回调属于 Promise jobs/microtasks;定时器回调属于宿主安排的后续任务。具体任务队列名称和渲染时机由宿主规范决定。

原文配图 127-02

2. 事件循环的入门模型

浏览器或 Node.js 会把同步代码、定时器、网络、文件系统等工作交给不同的宿主机制处理。异步操作完成后,宿主把相应的回调安排到可执行的任务队列;当当前 job 结束,事件循环才会在合适的时机取出任务执行。

过去常见的教学图会把这些部分称为“主线程、Event Table、Event Queue 和 monitoring process”。这套图有助于入门,但不是 HTML 或 ECMAScript 规范中的完整对象模型:现代浏览器会区分 task、microtask、渲染机会和多个任务源,Node.js 则由 libuv 的事件循环阶段协调 I/O 和定时器。

历史上的 AJAX 示例可以这样表示($.ajax 需要先加载 jQuery,URL 也必须是真实且满足 CORS 的地址):

$.ajax({
  url: 'https://example.com/api',
  data: [],
  success: () => {
    console.log('发送成功!')
  },
  error: error => {
    console.error('请求失败', error)
  }
})

console.log('代码执行结束')

现代 Web API 通常使用 Promise 化的 fetch

fetch('/api/data')
  .then(response => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    return response.json()
  })
  .then(data => console.log('发送成功!', data))
  .catch(error => console.error('请求失败', error))

console.log('代码执行结束')

一般可以这样理解:请求发出后当前代码继续执行;响应完成后,fetch 的 Promise 反应会在后续 microtask 中运行。但网络响应到达时间、任务安排和渲染时机不能由 JavaScript 源码单方面决定。

原文配图 127-03

3. 又爱又恨的 setTimeout

setTimeout(fn, delay) 的意思是:在指定延迟之后,允许宿主把 fn 安排为后续任务;它不是“精确在 delay 毫秒时执行”。当前执行栈、其他任务、页面后台节流、系统调度和 Node.js 事件循环都可能让实际执行更晚:

setTimeout(() => {
  console.log('延时约 3 秒')
}, 3000)

先看一个简单例子:

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

console.log('执行 console')

通常先输出 执行 console,再输出 task()setTimeout(fn, 0) 也不会同步执行,它只表示没有额外的目标延迟,仍要等当前同步 job 结束并等待宿主调度。

如果主线程被长任务占用,定时器即使已经到期也只能等待:

function blockCpu(milliseconds) {
  const end = Date.now() + milliseconds
  while (Date.now() < end) {
    // 模拟占用主线程的同步长任务
  }
}

setTimeout(() => {
  console.log('定时器回调')
}, 0)

blockCpu(100)
console.log('同步长任务结束')

这里定时器的计时和主线程的执行是两件事:计时可能已经到期,但回调要等当前长任务结束后才有机会执行。不要用这种忙等方式实现真正的延迟,它会阻塞页面;示例只是为了说明调度关系。

在浏览器中,HTML 标准对嵌套定时器有最小延迟和节流规则,但“4 毫秒”不是所有环境、所有定时器和所有调用层级下的绝对保证。Node.js 的定时器也有自己的实现和调度规则。

4. 又恨又爱的 setInterval

setInterval(fn, ms) 会请求宿主按间隔安排回调,但不保证每次回调之间恰好相隔 ms。如果主线程繁忙、页面被后台节流,或回调本身耗时较长,回调可能延迟、合并或出现明显的时间漂移;不要用它实现精确计时。

需要避免异步任务重叠时,常见做法是递归 setTimeout,在本次工作完成后再安排下一次:

let timerId

function tick() {
  console.log('执行一次')
  timerId = setTimeout(tick, 1000)
}

timerId = setTimeout(tick, 1000)

// 需要停止时:clearTimeout(timerId)

5. Promise 与 process.nextTick()

Promise 的处理函数和 Node.js 的 process.nextTick() 不能混成同一个跨平台队列:

  • Promise 的反应处理属于 ECMAScript Promise jobs,浏览器通常把它作为 microtask 执行。
  • queueMicrotask() 是浏览器和现代 Node.js 都可用的显式 microtask API。
  • process.nextTick() 是 Node.js 特有的高优先级队列,不是 ECMAScript 标准的 Promise microtask;Node.js 通常会在继续事件循环前先清空它。
  • setTimeoutsetInterval 是宿主定时器任务;Node.js 还提供 setImmediate,浏览器通常没有同名标准 API。

因此,下面这段代码在常见浏览器/Node.js 环境中通常会先输出同步代码,再输出 then,最后输出定时器:

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

new Promise(resolve => {
  console.log('promise')
  resolve()
}).then(() => {
  console.log('then')
})

console.log('console')

常见输出:

promise
console
then
setTimeout

事件循环的简化顺序可以描述为:执行一个 task 的同步代码;当前 job 结束后清空 microtask;宿主在合适时机进行渲染或进入下一个 task。Node.js 还要结合 libuv 的 timers、poll、check 等阶段理解,不能用浏览器的一张队列图覆盖所有环境。

原文配图 127-04

6. Node.js 中的复杂示例

下面的示例明确标注为 Node.js 代码,因为浏览器没有 process.nextTick()

console.log('1')

setTimeout(() => {
  console.log('2')
  process.nextTick(() => {
    console.log('3')
  })
  new Promise(resolve => {
    console.log('4')
    resolve()
  }).then(() => {
    console.log('5')
  })
})

process.nextTick(() => {
  console.log('6')
})

new Promise(resolve => {
  console.log('7')
  resolve()
}).then(() => {
  console.log('8')
})

setTimeout(() => {
  console.log('9')
  process.nextTick(() => {
    console.log('10')
  })
  new Promise(resolve => {
    console.log('11')
    resolve()
  }).then(() => {
    console.log('12')
  })
})

在 Node.js 24 的常见运行方式下,输出为:

1
7
6
8
2
4
3
5
9
11
10
12

第一轮主脚本同步执行时输出 17;随后 Node.js 先处理 process.nextTick,再处理 Promise reaction,得到 68。第一个定时器任务中同步输出 24,然后先输出 Node.js 的 nextTick3),再输出 Promise reaction(5)。第二个定时器同理输出 9111012

这个顺序依赖 Node.js 的调度模型和定时器安排;不要把它当成浏览器的通用答案。不同 Node.js 版本、启动方式或其他 I/O 任务可能造成定时器先后差异,因此面试题应先确认运行环境。

原文配图 127-05

7. 浏览器中的 microtask 与渲染

浏览器在一个 task 结束后通常会清空 microtask 队列,然后才可能进入渲染机会或下一个 task。Promise reaction 和 queueMicrotask 都能加入 microtask,但大量递归 microtask 也会让渲染和用户输入长时间得不到机会:

let count = 0

function schedule() {
  queueMicrotask(() => {
    count++
    if (count < 3) schedule()
    console.log('microtask', count)
  })
}

schedule()
setTimeout(() => console.log('task'), 0)

实际页面动画应优先考虑 requestAnimationFrame,后台轮询要考虑可见性、取消和节流;网络请求应使用 AbortController 管理取消,而不是只依赖定时器。

8. 写在最后

(1)JavaScript 的异步

JavaScript 的异步不是把同一段代码同时执行,而是由宿主在当前 job 结束后安排后续工作。网络、定时器、文件 I/O 和 Worker 都有自己的宿主机制;“单线程”不能解释所有调度细节。

(2)事件循环 Event Loop

事件循环是宿主安排任务、microtask、I/O 和渲染机会的一组机制。ECMAScript 规定 Promise jobs 等语言级语义,HTML 和 Node.js 则补充各自的事件循环。

(3)JavaScript 的执行和运行

JavaScript 在浏览器、Node.js、Worker、Deno 等宿主中的运行方式不同。引擎负责执行 ECMAScript 语义,宿主负责提供 API 和调度环境;因此分析输出时一定要注明运行环境。

(4)setImmediate

setImmediate 主要是 Node.js 的宿主 API(也曾在部分旧浏览器环境出现过非标准实现),不能当作浏览器通用 API。Node.js 中它与 setTimeout(..., 0) 的先后还可能取决于代码所在阶段和 I/O 情况。

(5)最后的最后

  • 当前同步 job 内的代码按顺序执行。
  • Promise reaction 和 queueMicrotask 通常在当前 job 后执行。
  • 定时器只是最早可调度时间,不是精确执行时间。
  • 浏览器、Node.js 和 Worker 的事件循环不能用一套绝对顺序替代。

原文配图 127-06

现代补充

现代异步代码还应关注以下实践:

  1. async/await 组织顺序流程,但并行任务应显式使用 Promise.allPromise.allSettled 等组合器,避免无意中串行化。
  2. AbortControllerAbortSignal.timeout() 处理请求取消和超时。
  3. 不要在主线程中使用忙等或同步 XHR;长计算可以拆分、移到 Worker,或使用合适的任务调度方式。
  4. 未处理的 Promise 拒绝应在边界处捕获;浏览器可监听 unhandledrejection,Node.js 的默认行为和进程退出策略也应按目标版本确认。
  5. 需要分析复杂顺序时,先画出“当前 task → microtask → 下一 task”的时间线,再标记 Node.js 的 nextTick 和 libuv 阶段,避免把所有东西都称为“宏任务/微任务”。

参考资料

  1. HTML Standard:Timers
  2. MDN:使用微任务
  3. Node.js:The Node.js Event Loop
  4. MDN:JavaScript event loop

作者:ssssyoki

链接:https://juejin.cn/post/6844903512845860872

来源:稀土掘金。著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS