JavaScript 执行机制:单线程、Event Loop 与 Promise 异步编程

0 阅读7分钟

2026-06-15

阅读约 10 分钟 | 专栏:每日一课


前言

先来看一道经典面试题:

console.log(1)

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

new Promise((resolve) => {
  console.log(3)
  resolve(4)
}).then(res => {
  console.log(res)
})

console.log(5)

// 输出顺序是什么?

如果你的答案是 1 → 3 → 5 → 4 → 2,恭喜你,这篇文章你已经掌握了七成。如果猜错了,也别急——这正是本文要帮你彻底解决的问题。


一、单线程:JavaScript 的设计宿命

1.1 为什么偏偏是单线程?

C++ 有多线程,Java 有线程池,Go 有 goroutine。JavaScript 为什么非要单线程?

答案藏在它的出生证明里:JS 是为浏览器而生的。它的核心任务是:

  • 操作 DOM 节点
  • 响应用户点击、滚动、输入
  • 控制页面动画

想象一下多线程操作同一个 DOM 的场景:

线程A:我要删除这个按钮!
线程B:等等,我正给它改文字呢!
线程C:我刚给它换了颜色...

DOM:你们能不能先开个会?

这种竞态条件(Race Condition)会让页面渲染变得完全不可预测。为了避免这种灾难级复杂度,JS 从设计的第一天就选择了单线程。

// 单线程 = 可预测,一行一行来
let a = 1
let b = 2
let c = 3
console.log(a + b + c) // 6,永远是这个结果

1.2 单线程带来了什么代价?

单线程意味着所有任务必须排队。前一个不完成,后一个永远等。

如果有个任务需要等 3 秒——比如请求服务器数据——那用户在这 3 秒里就什么都做不了:按钮点不动,输入框打不了字,页面像是"死了"一样。这显然不可接受。

异步编程,由此诞生。


二、同步与异步的分工哲学

2.1 用一段代码感受差别

console.log('start')

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

console.log('end')

// 输出:start → end → async

setTimeout 里的回调明明写在 end 前面,却最后一个执行。这不是 bug——这是 JS 运行机制刻意为之。

2.2 "公司"类比:帮你建立心智模型

用一家公司来理解 JS 的运行时:

概念类比说明
进程(Process)董事长PID 是工号,负责分配系统资源
线程(Thread)经理TID 是工号,一个进程下可有多个线程
主线程唯一干活的经理JS 所有代码都在这一个线程上跑
Event Loop秘书台协调同步任务和异步任务的调度

启动一个 JS 程序(无论浏览器还是 Node.js)的流程:

  1. 操作系统启动一个进程,分配内存
  2. 进程内启动一个主线程——JS 是单线程,就这一个
  3. 主线程火速执行所有同步任务——这是用户能最快看到页面的关键
  4. 遇到 setTimeoutfetch、事件监听等异步任务时,不等待,把它们挂到 Event Loop
  5. 跳过异步,继续跑后面的同步代码
  6. 同步代码全部跑完后,Event Loop 开始处理异步队列

这就是为什么 end(同步)必须先于 async(异步)打印。


三、你想知道的异步任务都在这里

日常开发中,下面这些场景全部是异步的:

异步任务触发时机开发场景
setTimeout / setInterval定时到点后,回调进队列延迟执行、轮询
DOM 事件用户交互触发按钮点击、滚轮、键盘输入
网络请求fetchXMLHttpRequestaxios调 API、加载远程资源
Promiseresolve / reject 触发 .then / .catch链式异步编排
async/awaitawait 暂停当前函数,等 Promise 决议现代异步编程标配

一句话总结:所有你预期"未来某个时刻才完成"的操作,JS 都会丢给 Event Loop,绝不阻塞主线程。


四、Promise:从回调地狱到链式优雅

4.1 没有 Promise 的时代

ES6 之前,异步靠回调函数。多层依赖的代码长这样:

// 回调地狱 👿
fetchUser(userId, (user) => {
  fetchOrders(user.id, (orders) => {
    fetchDetails(orders[0].id, (details) => {
      fetchDelivery(details.address, (delivery) => {
        // 我已经看不到缩进的开头了...
      })
    })
  })
})

每一层依赖都必须嵌进上一层的回调里,像剥洋葱,越剥越想哭。Promise 就是来拯救我们的。

4.2 Promise 的本质

Promise(承诺)是一个异步任务的容器。它包裹着一个暂时还不存在的值,向你"承诺"在未来某个时刻给你结果。

Promise 有三种状态,且不可逆

 pending ──resolve()──→ fulfilled(履约)
    └─────reject()───→ rejected (违约)

一旦状态改变(settled),就永远凝固住了。你调用多少次 .then() 拿到的结果都一样。

4.3 拆解一个 Promise 的完整生命周期

const p = new Promise((resolve, reject) => {
  console.log('1. executor 立即同步执行')

  // 模拟耗时任务
  setTimeout(() => {
    const success = Math.random() > 0.5
    if (success) {
      resolve('2. 履约了!')
    } else {
      reject('2. 违约了!')
    }
  }, 2000)
})

p
  .then(data => {
    console.log(data, '→ 3. resolve 时触发')
  })
  .catch(err => {
    console.log(err, '→ 3. reject 时触发')
  })
  .finally(() => {
    console.log('4. 不论成败,我都会执行')
  })

关键细节:

  • new Promise(executor) 里的 executor 函数是同步执行
  • resolve 和 reject 是 Promise 内部提供给你的两个钩子——你来决定什么时候"履约"、什么时候"违约"
  • .then() 接收 resolve 的结果,.catch() 接收 reject 的原因
  • .finally() 不管成功失败都跑,适合做清理(关 loading、清定时器等)

4.4 一个容易踩的坑

console.log(1)

new Promise((resolve) => {
  console.log(2)  // ← 这是同步的!不是异步!
  resolve(3)
}).then(data => {
  console.log(data) // ← 这才是异步(微任务)
})

console.log(4)

// 输出:1 → 2 → 4 → 3

很多人以为 new Promise 里面就是异步的,其实 executor 是在创建 Promise 时就立刻跑了。真正异步的是 .then() / .catch() 里的回调。

4.5 fetch 就是 Promise 的马甲

浏览器内置的 fetch 底层就是 Promise:

console.log('start')

fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' })
})
  .then(res => res.json())
  .then(data => console.log('请求成功', data))
  .catch(err => console.log('请求失败', err))

console.log('end')

// 输出:start → end → (请求结果)

所以你能用 .then() 链式处理响应,也能用 .catch() 统一捕获网络异常——这都是因为 fetch 返回的是一个 Promise。

4.6 手写一个 sleep 函数

JS 没有原生的 sleep(),但一个 Promise 就能搞定:

function sleep(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms)
  })
}

// 用起来像这样
async function demo() {
  console.log('等 2 秒...')
  await sleep(2000)
  console.log('2 秒到了!')
}

这也是 Promise 的核心价值:把"完成时机"暴露成一个可组合的对象,用 .then()await 来编排依赖关系,而不是在回调里疯狂缩进。


五、链式调用与错误冒泡

5.1 链式是 Promise 最强大的武器

每个 .then() 都返回一个新的 Promise,所以可以一直链下去:

fetchUser(userId)
  .then(user => fetchOrders(user.id))    // 返回新 Promise
  .then(orders => fetchDetails(orders))   // 继续链
  .then(details => renderUI(details))     // 渲染
  .catch(err => showErrorToast(err))      // 一处 catch,捕获全链路

5.2 错误冒泡机制

Promise 链里任何一个环节出错,错误会沿着链一路向下冒泡,直到遇到第一个 .catch()

Promise.resolve()
  .then(() => { throw new Error('step 1 炸了') })
  .then(() => { console.log('这行不会执行') })
  .then(() => { console.log('这行也不会') })
  .catch(err => {
    console.log('捕获到:', err.message)  // ← 在这接住了
  })
  .then(() => { console.log('catch 后面还能继续链') })

这意味着你不需要在每一层回调里单独处理错误——只需要在链尾放一个 .catch(),就能兜住前面所有环节的异常。相比回调地狱里每个回调都得写 if (err),这简直是降维打击。


六、总结:一张地图帮你回顾全文

核心概念一句话理解
JS 单线程设计选择,避免多线程操作 DOM 的竞态复杂度
同步任务在主线程排队,前一个不完成后一个永远等
异步任务不阻塞主线程,交给 Event Loop 调度
Event LoopJS 运行时的"总调度",决定同步和异步的执行顺序
Promise异步任务容器,提供 resolve / reject 两个出口
executor 同步执行new Promise(fn) 里的 fn 是立刻跑,不是异步
.then / .catch / .finallyPromise 消费者:成功 / 失败 / 最终
链式调用每个 .then() 返回新 Promise,可无限链下去
错误冒泡链上任何一处报错,一路冒泡到最近的 .catch()

最后回到开篇那道题

console.log(1)                           // 同步 → 1

setTimeout(() => { console.log(2) }, 0)  // 宏任务 → 最后

new Promise((resolve) => {
  console.log(3)                         // executor 同步 → 3
  resolve(4)
}).then(res => console.log(res))         // 微任务 → 比宏任务先

console.log(5)                           // 同步 → 5

// 最终输出:1 → 3 → 5 → 4 → 2

同步 → 微任务 → 宏任务——掌握这个顺序,你就抓住了 JS 异步执行的命脉。

理解 JS 的同步与异步从来不只是背面试题。它是一种编程思维方式的转变:学会用异步的视角组织代码,你才能写出流畅、健壮、能扛住高并发的 JavaScript 应用。