🍳 餐厅里的 "事件循环":一场关于 JS 任务的奇妙晚餐

62 阅读4分钟

🌅 餐厅开业:单线程的厨师很忙

傍晚 6 点,"JS 小厨" 餐厅正式营业。后厨只有一位厨师(JS 引擎),他有个怪脾气 —— 一次只能做一道菜(单线程)。

"老板,为什么不多雇几个厨师?" 新来的服务员小异好奇地问。

老板指了指前厅:"你看那些餐桌(DOM),如果两个厨师同时给一张桌子上菜,岂不是要打翻盘子?单线程虽然慢,但能保证餐桌安全呀~"

厨师的工作台上贴着一张纸条:"同步任务优先炒,耗时任务先记着,做完手头活再看单"。这正是 JS 的执行原则:同步任务立即执行,异步任务放入队列等待。

📝 订单分类:哪些是 "加急",哪些是 "普通"?

第一个客人推门而入,递上菜单:

// 简化版订单
console.log('客人进店') // 同步任务
setTimeout(() => {
  console.log('打包带走') // 宏任务
}, 0)
Promise.resolve().then(() => {
  console.log('加个蛋') // 微任务
})
console.log('点完菜了') // 同步任务

厨师先炒同步菜:"客人进店" 和 "点完菜了" 先上。这时服务员递来两个便签:贴红标的 "加个蛋"(微任务)和蓝标的 "打包带走"(宏任务)。

"红标是加急单,蓝标是普通单," 厨师解释道,"同步菜炒完,先清所有红标单,再看蓝标。" 所以最终上菜顺序是:客人进店→点完菜了→加个蛋→打包带走。

🔴 红标加急单:微任务的秘密

🍳 第一类加急单:Promise.then ()

第二位客人点了三份 Promise 蛋炒饭:

// Promise订单
const promise1 = Promise.resolve('溏心蛋')
const promise2 = Promise.resolve('煎蛋')
const promise3 = new Promise(resolve => {
  console.log('正在打鸡蛋') // Promise构造函数是同步的
  resolve('炒蛋')
})
promise1.then(res => console.log(res))
promise2.then(res => console.log(res))
promise3.then(res => console.log(res))

厨师先喊:"正在打鸡蛋"(同步执行),然后把三个蛋的 "加做" 需求(then 回调)贴成红标。同步任务结束后,依次端出:溏心蛋→煎蛋→炒蛋。

🧱 第二类加急单:MutationObserver

隔壁桌客人想盯着厨师做菜,于是要求:"我加了配菜你就得告诉我"(监听 DOM 变化)。这就是MutationObserver:

// 监听需求
const target = document.createElement('div') // 空盘子
const observer = new MutationObserver(() => {
  console.log('客人:加了配菜!') // 微任务回调
})
observer.observe(target, { attributes: true }) // 开始监听
target.setAttribute('data-配菜', '番茄') // 加配菜
target.setAttribute('data-配菜', '洋葱') // 再加配菜

虽然客人加了两次配菜,但厨师炒完当前菜(同步任务)后,才会一次性告诉客人:"加了配菜!"(微任务合并执行)。这就是 DOM 操作的批量处理 —— 避免频繁通知浪费时间。

🚀 特殊加急单:process.nextTick

Node.js 包间的客人很着急,直接拍了桌子:"我的单必须最先做!" 这就是 process.nextTick:

// Node订单
console.log('开始做')
process.nextTick(() => console.log('老板的单!')) // 超级加急
Promise.resolve().then(() => console.log('普通加急'))
console.log('做完同步的')

上菜顺序是:开始做→做完同步的→老板的单→普通加急。原来在 Node 里,nextTick 比 Promise.then 优先级更高,就像老板的订单永远插队~

🔵 蓝标普通单:宏任务的排队规则

"厨师,我这汤不急,等会儿再上"(setTimeout)。这类耗时任务会被贴蓝标,放进宏任务队列。

但客人经常抱怨:"我点的汤明明说 3 分钟,怎么等了 5 分钟?" 这是因为 —— 蓝标单要等红标单全做完才轮到,前面的蓝标单没做完,后面的只能排队。

就像定时器:

// 定时器订单
setTimeout(() => {
  console.log('第一碗汤') 
  Promise.resolve().then(() => console.log('汤里加葱')) // 新红标
}, 0)
setTimeout(() => {
  console.log('第二碗汤')
}, 0)

第一碗汤端上来时,客人突然说 "加葱"(新的微任务),厨师必须先加完葱,才能去做第二碗汤。这就是宏任务执行中产生的微任务,要优先于下一个宏任务。

🔄 事件循环:厨房的工作流程

服务员总结出厨师的工作流程,写在黑板上:

  1. 先炒完当前蓝标单里的所有同步菜(执行一个宏任务)

  2. 清掉所有红标单(执行所有微任务)

  3. 擦桌子、摆餐具(页面渲染)

  4. 从蓝标队列取下一个单,重复步骤 1

就像这里的场景:

// 渲染时机
console.log("同步菜") // 步骤1
queueMicrotask(() => console.log("红标:擦桌子")) // 步骤2
console.log("同步菜结束") // 步骤1

厨师先做完 "同步菜" 和 "同步菜结束",再执行 "擦桌子",最后才会进行页面渲染 —— 这就是为什么微任务能在 DOM 更新后、渲染前操作元素~

🏁 打烊总结:记住这些小规律

  1. 同步任务→微任务→宏任务,按优先级上菜

  2. 微任务包括:Promise.then、MutationObserver、queueMicrotask、process.nextTick(Node)

  3. 宏任务包括:setTimeout、setInterval、DOM 事件、fetch 回调

  4. 每次宏任务执行完,必须清空所有微任务,再进行渲染

"今天真是忙碌的一天!" 厨师摘下帽子,"其实事件循环一点也不复杂,就像餐厅排队吃饭 —— 先到先得,加急优先~"

客人纷纷点头:"原来 JS 的执行顺序,和餐厅做菜一模一样啊!" 🍽️