Event Loop 教你高效 “划水”:JS 单线程的“摸鱼”指南
前言
各位前端打工人,有没有过这种经历:明明写了 setTimeout(() => console.log('摸鱼')),结果同步代码还没跑完,摸鱼计划就被打断?其实 JS 单线程就像一个只能专注干一件事的打工人,而 Event Loop 就是它的 “高效摸鱼手册”—— 既能按时完成核心工作,又能把耗时任务 “挂起摸鱼”,今天咱们就一起好好聊聊这份手册!
一、先搞懂:JS 打工人为啥不能 “硬卷”?(进程线程的底层逻辑)
要想摸鱼,得先知道 “工作台” 的规矩:
-
进程:好比公司的独立部门 —— 比如浏览器开个新标签页,就是开了个新部门,每个部门都有自己的办公资源(电脑、文件)。
-
线程:部门里真正干活的打工人 —— 浏览器部门里就有三个核心员工:
- 渲染线程(负责画页面,比如给按钮上色、排版文字);
- JS 引擎线程(咱们的主角,负责跑代码);
- HTTP 请求线程(负责发接口,比如向服务器要数据)。
但这里有个 “办公室规定”:JS 引擎线程和渲染线程是 “互斥同事” ——JS 能修改 DOM(比如把按钮改成红色),要是它俩同时干活,页面就会出现 “排版错乱”(比如按钮画到一半被改成红色),所以必须 “你歇我干”。
更关键的是:JS 引擎线程是个 “独生子” (V8 引擎默认只开一个线程)。这就意味着:如果 JS 遇到一个耗时 10 秒的计算任务(比如统计 100 万条数据),它就会一直死磕这个任务,导致渲染线程没法干活,页面直接卡成 “PPT”—— 这就是 “硬卷” 的下场!
所以 JS 打工人的生存法则是:能摸鱼就不硬卷,耗时任务先 “挂起”,等核心工作做完再处理—— 这就是 “异步摸鱼” 的核心逻辑。
二、Event Loop:摸鱼任务的 “优先级排序”
JS 里的 “摸鱼任务”(异步任务) 分两类,就像公司里的 “紧急任务” 和 “常规任务”,得按顺序处理,不能乱摸鱼:
-
微任务:紧急摸鱼任务(优先级高)—— 比如
Promise.then()、async/await后续代码、process.nextTick()(Node 环境),相当于 “老板临时交代的小任务,必须在下班前做完”; -
宏任务:常规摸鱼任务(优先级低)—— 比如
setTimeout、setInterval、ajax 请求、I/O 操作、UI 渲染,相当于 “下周要交的报告,先放一放”; - 还有个特殊角色:同步任务—— 核心工作(比如写代码、算结果),必须优先做完,相当于 “当天要交的核心 KPI”。
Event Loop 就是这套摸鱼规则的 “监督者”,它的工作流程就像打工人的一天,记好这 4 步,摸鱼不翻车:
- 先清核心 KPI:先把当天的同步任务 (核心工作) 全部做完,遇到异步任务 (摸鱼任务),就按类型扔进 “微任务队列” (紧急摸鱼) 和 “宏任务队列” (常规摸鱼);
- 再处理紧急摸鱼:核心 KPI 做完后,把 “微任务队列” 里的所有任务一次性清完(比如老板临时交代的 3 个小任务,必须连续做完,不能中途打断);
- 中场休息(渲染页面) :紧急摸鱼任务处理完,浏览器会进行 “页面渲染”(比如更新 DOM、刷新页面),相当于打工人喝杯咖啡歇一歇;
- 开启下一轮摸鱼:从 “宏任务队列” 里拿一个任务执行,然后重复 1-3 步,直到所有任务做完。
三、实战摸鱼:用代码例子验证规则
光说不练假把式,咱们用真实代码模拟 JS 打工人的 “摸鱼一天”,看看 Event Loop 是怎么安排任务的!
例子 1:setTimeout为啥 “跑不赢” 同步代码?
先看这串经典代码:
let a = 1;
setTimeout(() => {
a = 2
}, 1000)
console.log(a);
分析摸鱼过程:
- 同步代码(属于宏任务)先跑:
let a=1→ 执行console.log(a),此时a还是 1; -
setTimeout是宏任务,被扔进 “宏任务队列” 排队; - 同步跑完后,微任务队列为空,直接执行下一个宏任务(也就是 1 秒后的
a=2)。
所以结果是:先输出 1,1 秒后a才变成 2。
例子 2:Promise.then的 “VIP 特权”
我们看一道经典面试题:
console.log(1);
new Promise((resolve) => {
console.log(2);
resolve();
})
.then(() => {
console.log(3);
setTimeout(() => {
console.log(4);
}, 0)
})
setTimeout(() => {
console.log(5);
setTimeout(() => {
console.log(6);
}, 0)
}, 0)
console.log(7);
是不是已经头皮发麻了?根本不清楚打印顺序是啥,但是这道面试题我们必须拿下!
摸鱼步骤拆解:
-
常规摸鱼(宏任务)开跑:
- 先执行
console.log(1)→ 输出1; - 遇到
new Promise:Promise 构造函数里的代码是同步的,执行console.log(2)→ 输出2,然后resolve(); -
then是微任务,扔进 “微任务队列”; - 遇到外层
setTimeout:宏任务,扔进 “宏任务队列”; - 最后执行
console.log(7)→ 输出7。
- 先执行
-
紧急摸鱼(微任务)接棒:
- 微任务队列里只有
then的回调,执行它:console.log(3)→ 输出3; - 回调里的
setTimeout(4)是宏任务,扔进 “宏任务队列”。
- 微任务队列里只有
-
宏任务队列开跑(下一轮摸鱼) :
- 先拿第一个宏任务(外层
setTimeout):执行console.log(5)→ 输出5; - 里面的
setTimeout(6)扔进宏任务队列; - 再拿下一个宏任务(
then里的setTimeout(4)):执行console.log(4)→ 输出4; - 最后拿
setTimeout(6):执行console.log(6)→ 输出6。
- 先拿第一个宏任务(外层
最终输出顺序:1 → 2 → 7 → 3 → 5 → 4 → 6
上图更清晰:
例子 3:async/await 是 “优雅摸鱼” 的语法糖
async/await 本质是 Promise 的语法糖,相当于给摸鱼任务加了 “自动排队” 功能,先搞懂它的用法:
console.log('script start');
async function async1() {
await async2()
console.log('async1 end');
}
async function async2() {
console.log('async2 end');
}
async1();
关键规则:
-
async函数本身相当于 “返回 Promise 的函数”; -
await fn()的本质是:把await后面的代码,塞进了fn()返回的 Promise 的then里(也就是微任务队列) 。
拿这段代码分析:
-
同步执行
console.log('script start')→ 输出; -
执行
async1():- 进入
async1,遇到await async2()→ 先执行async2()(同步),输出async2 end; -
await把后续的console.log('async1 end')扔进微任务队列;
- 进入
-
继续执行同步代码
OK既然知道了原理我们就实战摸鱼:
// 模拟耗时任务:向服务器要数据(宏任务)
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
console.log('常规摸鱼:发接口请求(耗时 1 秒)');
resolve('接口返回数据:用户列表');
}, 1000);
});
}
// 核心工作函数(async 标记为异步函数)
async function work() {
console.log('核心工作:开始处理用户数据');
// await 相当于“等待摸鱼任务完成,再继续核心工作”
const data = await fetchData();
// 这行代码会被扔进微任务队列,相当于“紧急摸鱼后的收尾工作”
console.log(`核心工作:使用${data}完成报表`);
}
// 执行核心工作
work();
// 其他同步任务
console.log('核心工作:处理其他紧急事务');
摸鱼流程拆解:
-
执行同步任务:
- 调用
work()函数,打印核心工作:开始处理用户数据; - 遇到
await fetchData(),先执行fetchData(),里面的setTimeout被扔进 “宏任务队列”(常规摸鱼); -
await会暂停work函数,跳出去执行其他同步任务,打印核心工作:处理其他紧急事务→ 同步任务完成。
- 调用
-
微任务队列为空,直接进入中场休息。
-
处理宏任务队列(常规摸鱼):
- 1 秒后,执行
setTimeout回调,打印常规摸鱼:发接口请求(耗时 1 秒),Promiseresolve 后,await后面的代码被扔进 “微任务队列”。
- 1 秒后,执行
-
再次处理微任务队列:
- 执行
console.log(核心工作:使用 ${data} 完成报表)→ 核心工作收尾。
- 执行
这里的关键是:await 后面的代码会被自动塞进微任务队列,相当于 “摸鱼结束后,优先处理收尾工作”,不用手动写 then 回调,摸鱼更优雅!
大家可以复制代码去运行一下,时间延迟照片体现不出来~~
四、摸鱼避坑:这些误区千万别踩
- 误区 1:setTimeout 延迟时间是 “准确时间”
错! setTimeout(() => {}, 1000) 不是 “1 秒后立即执行”,而是 “1 秒后把任务扔进宏任务队列”,得等同步任务和微任务全部完成后才会执行。如果前面的任务耗时 2 秒,那摸鱼就得等 2 秒后才开始。
- 误区 2:Promise 构造函数里的代码是异步的
错! new Promise((resolve) => { 同步代码 }) 里的代码是同步执行的,只有 then、catch 回调才是微任务(异步)。比如下面的代码,会先打印 同步代码,再打印 微任务:
new Promise((resolve) => {
console.log('同步代码');
resolve();
})
.then(() => {
console.log('微任务')
});
3. 误区 3:async 函数返回值是 “原始数据”
错! async 函数默认返回一个 Promise 对象,哪怕你写 async function fn() { return 1; },调用 fn() 得到的也是 Promise { 1 },需要用 await 或 then 才能拿到值。
五、总结:Event Loop 摸鱼口诀(记熟直接用)
同步任务先干完,微任务队列清干净;
渲染页面歇一歇,宏任务来轮着干;
await 后藏微任务,Promise 构造是同步;
Event Loop 掌节奏,摸鱼工作两不误!
结语
其实 JS 单线程的 “摸鱼哲学”,本质是 “优先级管理”—— 核心工作优先做,耗时任务排队做,既不耽误事,又不浪费时间。掌握了 Event Loop,你不仅能看懂 JS 异步代码的执行顺序,还能写出更高效的代码,就像打工人掌握了摸鱼技巧,工作效率翻倍,摸鱼也不心慌!