深入理解 JavaScript 同步与异步:从单线程到事件循环与 Promise
你写的
setTimeout真的会在 1 秒后执行吗?为什么fetch请求不会卡死页面?Promise 到底解决了什么问题?本文将带你从底层执行机制到实际应用,彻底搞懂 JS 的同步与异步。
一、为什么 JavaScript 是单线程的?
在设计之初,JavaScript 就被定位为一种简单的浏览器脚本语言,主要用来操作 DOM、处理用户交互。如果采用多线程模型,两个线程同时修改同一个 DOM 节点,浏览器将不知道该听谁的 ------ 这种复杂性是设计者极力要避免的。
所以,JS 选择了一条最简单的路:单线程。只有一个主线程,所有任务按顺序排队执行。
看一个简单的同步代码示例:
javascript
// 同步代码
let a = 1;
let b = 2;
let c = 3;
console.log(a + b + c); // 6
这种代码执行效率非常高,因为没有任何等待。但如果所有任务都是同步的,遇到一个耗时操作(比如网络请求、定时器),整个页面就会"卡死" ------ 这就是单线程面临的困境。
二、同步与异步:鱼与熊掌如何兼得?
2.1 同步的阻塞问题
试想:如果 setTimeout 是同步的,那么 1 秒钟内页面什么都做不了,这显然不可接受。因此,JS 引入了异步任务的概念。
2.2 常见的异步任务
setTimeout/setInterval------ 定时器- 事件监听(如
click、load) - 网络请求(
fetch、XMLHttpRequest) Promise的then/catchasync/awaitprocess.nextTick(Node.js)
2.3 一个最经典的例子
javascript
console.log('start');
setTimeout(() => {
console.log('222');
}, 1000);
console.log('end');
输出顺序:start → end → 222。
为什么?因为 setTimeout 的回调被放入异步队列,主线程会先执行完所有同步代码,然后再来处理异步任务。
三、JS 的执行机制:事件循环(Event Loop)
3.1 进程与线程
- 进程(Process):好比一个公司(PID),负责分配资源。
- 线程(Thread):好比公司里的员工,负责具体干活。
C++、Java 等可以开多个线程并行处理任务,效率高但编写复杂、容易出 bug。JS 则始终只有一个主线程在干活。
3.2 执行流程详解
当我们在浏览器或 Node.js 中运行一段 JS 代码时,背后发生的过程是这样的:
- 启动一个进程(PID),分配内存等资源。
- 进程启动主线程,开始执行代码。
- 同步任务 :立即执行,比如变量声明、函数调用、
console.log。 - 异步任务 :遇到
setTimeout、fetch、事件绑定等,不会等待结果,而是将它们交给浏览器的 Web API 或 Node.js 的 libuv 去处理,同时将回调函数注册到事件队列中。 - 主线程继续向下执行所有同步代码。
- 同步代码执行完毕后,主线程空闲 ,开始事件循环(Event Loop):不断检查事件队列中是否有待执行的回调。
- 当异步任务的条件满足(如定时器到点、请求返回数据),其回调被推入事件队列,主线程取出并执行。
这就是著名的 Event Loop 模型。
3.3 图解
arduino
┌─────────────┐
│ 同步代码 │ 立即执行
└──────┬──────┘
│
▼
┌─────────────┐
│ 异步任务触发 │ setTimeout, fetch, 事件...
└──────┬──────┘
│
▼
┌─────────────┐
│ Web APIs │ 浏览器/Node 背后处理
└──────┬──────┘
│
▼
┌─────────────┐
│ 任务队列 │ 回调函数排队
└──────┬──────┘
│
▼
┌─────────────┐
│ 事件循环 │ 主线程空闲时取队列
└─────────────┘
四、控制异步流程的进化史
有了异步,麻烦也来了 ------ 如果我们想串行执行多个异步任务怎么办?比如:先获取用户列表,再根据每个用户获取详细信息。
4.1 回调地狱
早期只能通过嵌套回调,代码横向发展,难以维护。
4.2 Promise 的诞生
ES6 引入的 Promise 成为了异步控制的终极方案。它是一个容器,里面装着一个未来才会结束的事件(通常是异步操作)的结果。
基本语法
javascript
const p = new Promise((resolve, reject) => {
console.log('许诺言'); // 同步执行!
setTimeout(() => {
resolve("网络错误"); // 成功时调用 resolve
// reject("失败原因"); // 失败时调用 reject
}, 2000);
});
console.log(p.__proto__); // 查看原型上的 then / catch
p.then((data) => {
console.log(data);
console.log('end');
}).catch((err) => {
console.log(err);
}).finally(() => {
console.log('finally');
});
关键点:
- Promise 构造函数接收的 executor 函数 会立即同步执行,所以 "许诺言" 会先打印。
resolve和reject用来改变 Promise 的状态,并传递结果给后续的then或catch。then的回调会在当前同步代码执行完后、微任务队列中被调用(比普通异步宏任务更早,这里先不展开)。
4.3 手写一个 sleep
利用 Promise 可以轻松实现类似其他语言的 sleep 效果:
javascript
function sleep(t) {
const p = new Promise((resolve, reject) => {
console.log('同步'); // 立即执行
setTimeout(() => {
resolve();
}, t);
});
return p;
}
sleep(2000).then(() => {
console.log('2s后再做');
});
4.4 Fetch API 与 Promise
fetch 底层就是 Promise。我们经常这样用:
javascript
console.log('start');
fetch('http://api.deepseek.com/chat/completions', {
method: 'post'
})
.then((data) => {
// 处理响应
})
.catch((err) => {
console.log(err);
});
console.log('end');
输出仍是 start → end → 请求完成后执行 then。这就保证了页面不会被网络请求阻塞。
五、深入理解事件循环:宏任务与微任务
上面我们提到了"普通异步宏任务",实际上 JS 中异步任务还分为两种:
- 宏任务(MacroTask) :
setTimeout、setInterval、I/O、UI 渲染、script整体代码。 - 微任务(MicroTask) :
Promise.then、Promise.catch、MutationObserver、process.nextTick。
执行顺序:
- 执行一个宏任务(同步代码可以看作第一个宏任务)。
- 执行过程中遇到微任务,将其加入微任务队列。
- 当前宏任务执行完毕,立即清空所有微任务(按顺序)。
- 执行下一个宏任务(从事件队列中取)。
这也就解释了为什么 Promise.then 总是比 setTimeout 先执行(即使二者延迟时间相同)。
六、实际开发建议
- 能不异步就不异步:简单逻辑直接用同步代码,清晰高效。
- 优先使用 Promise 而非回调:避免回调地狱,链式调用可读性高。
- 善用
async/await:它是 Promise 的语法糖,让异步代码像同步一样书写。 - 理解事件循环:调试异步 bug 时,搞清楚宏任务/微任务的顺序会帮你节省大量时间。
- 避免长同步任务 :比如大循环会阻塞主线程,考虑拆分成多个微任务或使用
setTimeout分片。
七、总结
- JavaScript 是单线程语言,通过事件循环机制实现非阻塞的异步操作。
- 同步任务立即执行,异步任务交给宿主环境(浏览器/Node)处理,回调进入任务队列。
- Promise 优雅地解决了异步流程控制问题,是如今异步编程的基石。
- 理解宏任务与微任务的区别,才能精准预测代码执行顺序。
异步是 JS 的灵魂,掌握了它,你才能真正驾驭这门语言。希望这篇文章能帮你彻底厘清这些概念,写出更高效、更健壮的代码。
如果你觉得这篇文章有帮助,欢迎点赞、评论、转发~ 你的支持是我持续创作的最大动力!