Skip to content

异步与事件循环

作者:青见春山
发表于:2026-07-29
字数统计:5000 字
预计阅读17分钟

涵盖 JS 异步编程范式(回调 / Promise / async-await)、事件循环机制、Vue nextTick 与微任务的反直觉表现。

一、回调函数

JavaScript
// 传统回调
function fetchData(callback) {
    setTimeout(() => {
        const data = { id: 1, name: '张三' }
        callback(data)
    }, 1000)
}

fetchData((data) => {
    console.log('收到数据:', data)
})

回调函数的演进路径:

JavaScript
// 回调地狱:嵌套层级深,难维护
step1((result1) => {
    step2(result1, (result2) => {
        step3(result2, (result3) => {
            console.log(result3)
        })
    })
})

详见 函数大全 中的回调函数部分。

二、Promise

基本用法

JavaScript
function fetchData() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true
            if (success) {
                resolve({ id: 1, name: '张三' })
            } else {
                reject('获取数据失败')
            }
        }, 1000)
    })
}

fetchData()
    .then(data => {
        console.log('成功:', data)
        return data.name
    })
    .then(name => {
        console.log('姓名:', name)
    })
    .catch(error => {
        console.error('错误:', error)
    })
    .finally(() => {
        console.log('完成')
    })

Promise.all / race / allSettled / any

JavaScript
const promises = [
    Promise.resolve(1),
    Promise.resolve(2),
    Promise.resolve(3)
]
Promise.all(promises).then(results => console.log(results))  // [1, 2, 3] - 全部成功

Promise.race([
    new Promise(resolve => setTimeout(() => resolve('慢'), 1000)),
    new Promise(resolve => setTimeout(() => resolve('快'), 500))
]).then(console.log)  // '快' - 第一个完成

三、async/await(ES2017)

JavaScript
async function fetchUserData() {
    try {
        const data = await fetchData()
        console.log('用户数据:', data)
        return data
    } catch (error) {
        console.error('错误:', error)
        throw error
    }
}

async function processSteps() {
    const result1 = await step1()
    const result2 = await step2(result1)
    const result3 = await step3(result2)
    console.log(result3)
}

// 并行操作
async function parallelProcess() {
    const [data1, data2, data3] = await Promise.all([
        fetchData(),
        fetchData(),
        fetchData()
    ])
    console.log('所有数据:', data1, data2, data3)
}

// 顶级 await (ES2022)
// const data = await fetchData()

四、事件循环(Event Loop)

JS 是单线程语言,所有任务分为:

  • 同步任务:在主线程上排队执行
  • 异步任务:被推入任务队列,等待执行

异步任务又分:

  • 宏任务(macro task)setTimeout / setInterval / setImmediate(Node)/ I/O / UI 渲染
  • 微任务(micro task)Promise.then / catch / finally / MutationObserver / process.nextTick(Node)

执行顺序

  1. 执行同步代码(执行栈)
  2. 同步代码执行完后,清空微任务队列
  3. 微任务队列清空后,执行一个宏任务
  4. 重复 2-3

微任务总是优先于下一个宏任务执行

JavaScript
console.log('1')

setTimeout(() => console.log('2 - 宏任务'), 0)

Promise.resolve().then(() => console.log('3 - 微任务'))

console.log('4')

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

五、Vue nextTick 与微任务的反直觉表现

完整面试题

假如:1. dom更新,2. 一个微任务,3. 一个nexttick。那是 2 先执行还是 3 先执行?

真正的执行原理与顺序拆解

假设我们在组件中写下了严格的同步顺序:

JavaScript
// 1. 触发 DOM 更新
this.msg = 'hello'  // (或者 Vue3 的 msg.value = 'hello')
// 2. 注册普通微任务
Promise.resolve().then(() => console.log('普通微任务'))
// 3. 注册 nextTick
this.$nextTick(() => console.log('nextTick'))

Vue 2 中的真实表现

结果:DOM 更新 → nextTick → 普通微任务

为什么?

  1. this.msg = 'hello':Vue2 监听到数据变化,把组件更新 Watcher 推进内部的 callbacks 数组。此时,Vue2 第一次调用底层的 Promise.resolve().then(flushCallbacks) 把清空数组的操作送入了浏览器的微任务队列。

  2. Promise.then:浏览器按顺序把"普通微任务"推入微任务队列(排在 Vue 之后)。

  3. this.$nextTick:Vue 发现当前宏任务中已经申请过微任务了,于是它只把回调推入内部的 callbacks 数组,而不会再去生成新的底层 Promise 微任务。

  4. 运行结果:JavaScript 运行环境开始执行微任务检查点。先拿出第一个微任务(Vue 安排的),开始遍历执行 callbacks 数组,这就依次执行了 DOM 更新 和 nextTick。然后拿出第二个微任务,执行 普通微任务

⚠️ 注:如果把上方代码的第 2 步移到第 1 步之前,执行结果就会变成:普通微任务 → DOM更新 → nextTick。完全取决于谁先向浏览器注册了微任务。

Vue 3 中的真实表现(非常反直觉,面试绝杀点)

结果:DOM 更新 → 普通微任务 → nextTick

为什么?

  1. msg.value = 'hello':Vue3 把 DOM 更新(Effect)推入内部任务队列,并调用 Promise.resolve().then(flushJobs) 将 DOM 更新送到浏览器的微任务队列中。同时,Vue3 在内部保存了这个 Promise 实例(假设叫 currentFlushPromise)。

  2. Promise.then:把"普通微任务"送到浏览器的微任务队列中(排在 flushJobs 之后)。

  3. nextTick:Vue3 的 nextTick 源码实现其实就是通过 .then 挂接在 currentFlushPromise 后面的。相当于执行了 currentFlushPromise.then(回调)

  4. 运行结果

    • 浏览器开始出队,执行第一个微任务:flushJobsDOM 发生更新。这个操作执行完毕后,currentFlushPromise resolve 了,这导致底层的 Promise 机制又把 nextTick 的回调作为新的微任务推入到了队列尾部。
    • 此时微任务队列里排队的是刚刚第 2 步的"普通微任务",于是执行普通微任务
    • 最后,执行刚刚入队的 nextTick

满分面试回答

如果面试官问:"Vue 中的 DOM更新、普通微任务、nextTick 谁先执行?"

这不能一概而论,因为它们都依赖 JavaScript 的微任务队列,并且在 Vue2 和 Vue3 的底层调度逻辑有着根本的差异。假设我们在同一段同步代码中依次触发了数据修改(引发DOM更新)普通 Promise.thennextTick

  1. 共同点:在这个“先触发组件更新、再注册 nextTick”的前提下,Vue 会先刷新待处理的 DOM 更新,再执行相应的 nextTick 回调。不要把该顺序扩展成脱离调用时机和版本的绝对定律。
  2. 在 Vue2 中:表现通常是 DOM更新 → nextTick → 普通微任务。因为 Vue2 会通过一个内部的 callbacks 数组收集当前刻发生的所有更新和 nextTick,然后用唯一一个底层的 Promise 微任务去清空它。由于数据修改发生在最前,Vue2 率先抢占了微任务队列,导致同属该微任务的 nextTick 被"顺带"提前执行了。
  3. 在 Vue3 中:表现变成了 DOM更新 → 普通微任务 → nextTick。因为 Vue3 的 nextTick 是通过 Promise 链式调用,将回调挂接到清空任务队列的 Promise (flushJobs) 之后。这意味着 nextTick 的回调会在 DOM 更新(微任务 1)完成之后,才被重新推入底层的微任务队列。所以夹在中间的"普通微任务"反而会先于 nextTick 出队执行。

结论:并没有所谓的 Vue 专有高优先级队列。本质全都是 Event Loop 对于 Promise (.then) 注册顺序和链式调用的时序比拼。

七、面试满分通关话术(async/await)

"async/await 是 ECMAScript 规范定义的异步函数机制。它的行为可以借助 Generator + Promise + 自动执行器来类比或转译,但规范并没有规定引擎必须用 Generator 实现。

await 会暂停当前异步函数的后续步骤,并在等待值对应的 Promise 敲定后通过微任务恢复;它与 yield 有相似的控制流效果,但两者的语义、可用位置和返回对象不同。

它让 Promise 链可以写成更接近同步流程的形式,同时仍需用 try/catch、并发组合与取消机制处理错误和任务生命周期。"

面试官可能会问的连招:

  • "async 函数的返回值是什么?" —— "无论里面 return 什么,async 函数最终一定会返回一个 Promise 对象。哪怕 return 的是一个普通数字 1,也会被包装成 Promise.resolve(1) 返回出去。"

async/await 的三步走拆解

第一步:Generator(生成器)提供暂停能力

JavaScript
// 注意这个星号
function* magicFunc() {
  console.log('第一段');
  yield; // 代码执行到这里,会【强制暂停】!
  console.log('第二段');
}

let it = magicFunc(); // 拿到一个控制器
it.next(); // 打印:第一段   (然后卡在 yield 那里不动了)
// ... 过了一万年 ...
it.next(); // 打印:第二段   (从卡住的地方接着往下跑)

第二步:把 Promise 和暂停键结合

Generator 有个超棒的机制:next() 里面是可以传值的,传进去的值,会变成上一个 yield 的结果!

JavaScript
function* getData() {
  // 1. request 是一个 Promise
  let request = fetch('http://api.xxx.com/user');

  // 2. 此时用 yield 把这个 Promise 扔到外面去,并且【暂停】
  let user = yield request;

  // 3. 将来恢复的时候,打印数据。这代码看起来就像同步一样!
  console.log(user);
}

// ============== 外部怎么操控它? ==============
let it = getData();

// 前进第一步,拿到那个暂未成功的 Promise
let p = it.next().value;

// 在外部等着 Promise 成功
p.then((res) => {
  // 成功之后,把拿到的结果 res,通过 next 塞回到函数内部!
  it.next(res);
});

虽然外面写着难看的 .then(),但是在 getData 内部,代码从头到尾看起来就像是同步的!没有回调!

第三步:async/await 把"自动挡"做到语言层面

  1. 把带有星号的 function* 替换成 async function
  2. 把暂停键 yield 替换成 await
  3. 官方在底层内置了"自动帮你调 .then().next() 的引擎"(类似早年的 co 库)。

所以:

JavaScript
async function getData() {
  let user = await fetch('http://api.xxx.com/user');
  console.log(user);
}

在 JS 引擎内部,其实它悄悄给你还原成了这样:

  1. 它是一个 Generator,遇到 await fetch,就悄悄 yield fetch 暂停了。
  2. JS 底层的"自动机"接管了这个 Promise。
  3. 发现 Promise 变成功了,"自动机"把结果拿到,自动调用 .next(结果)
  4. 函数内部恢复执行,变量 user 拿到了值,继续往下走一行。

终极一句话理解

async/await 就是:Generator 函数(提供暂停和恢复的能力)+ 自动驱动器(自动帮你 .then() 并恢复函数执行)包装在一起的语法糖!

八、Promise 的标准面试回答(高分话术)

1. 是什么 & 解决什么问题

"首先,它本质上是 JS 里用来处理异步编程的一种解决方案。在它出现之前,我们如果要做异步操作(比如发接口请求),基本只能靠回调函数。如果几个接口有先后依赖关系,就会造成很深的嵌套,也就是我们常说的'回调地狱',代码很难看,也很难维护。Promise 的出现,就是为了把这种'嵌套'的写法,变成了扁平化的'链式调用'(.then.then),让逻辑清晰很多。"

2. 核心机制:状态机

"Promise 最核心的机制就是它的三种状态:pending(等待中)、fulfilled(成功了)、rejected(失败了)。而且它有一个很重要的特性:状态是不可逆的。一旦从 pending 变成了成功或者失败,它的状态就'锁死'了,再也不能改了。"

3. 基本用法

"在使用的时候,我们一般会 new 一个 Promise。它里面有两个非常关键的参数:resolvereject。如果我的异步代码执行成功了,我就调用 resolve 把数据发射出去;如果失败了,就调 reject 把错误扔出去。而在外面,我们就用 .then() 接收成功的结果,用 .catch() 专门来兜底捕获异常。这种把'执行代码'和'处理结果'分离开的设计,特别优雅。"

4. 常用 API(展示广度)

"除了单次请求,Promise 还可以很好地处理并发。比如开发里常遇到的,我要等两个接口都返回数据了才渲染页面,我就会用 Promise.all;如果我只想要最快返回的那个结果,就可以用 Promise.race;或者是只要有一个成功就行的 Promise.any。"

5. 拔高:演进与现状

"虽然 Promise 很好,但如果 .then() 链条太长,看着还是不够直观。所以后来 ES8 又出了 async / await。不过我知道 async / await 其实就是 Promise 的语法糖,底层的基石依然还是 Promise。"

面试官连招预警

  1. "那你能手写一个 Promise.all 或者 Promise.any 吗?" —— 思路就是自己包一个 new Promise,里面用计数器判断。详见 高频手写题
  2. "Promise 里的 then 如果不返回 Promise 会怎样?" —— 答:会自动包装成一个成功的、值为返回值的 Promise。
  3. "你在说状态时提到了 pending、fulfilled 等,它跟微任务(Microtask)有什么关系?" —— 答:Promise 的 .then 里的回调函数,会被放进微任务队列,在当前宏任务执行完后优先执行。这也是事件循环 Event Loop 经常考的点。

九、requestAnimationFrame(RAF)到底是什么任务?

结论

requestAnimationFrame(rAF)不是宏任务。rAF 的回调在浏览器准备渲染下一帧前由浏览器主动调用,执行时机介于渲染流水线的特定阶段(一般在清空微任务之后、渲染前的合成阶段)。

浏览器事件循环完整流程

text
1. 执行一个宏任务(从宏任务队列取出)
2. 清空微任务队列(执行所有 microtask)
3. requestAnimationFrame 队列(rAF 回调)执行
4. 开始渲染流水线(Layout → Paint → Composite)
5. 回到步骤 1,继续取下一个宏任务

详细解释

  • rAF 回调的执行时机不在宏任务队列中,而是浏览器渲染前的独立阶段。
  • 所以它的执行顺序是:微任务 → rAF 回调 → 渲染 → 下个宏任务
  • 如果主线程长时间被阻塞(>16.67ms),rAF 回调会被合并或跳过,保证不掉帧。

验证实验

JavaScript
console.log('1')

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

requestAnimationFrame(() => console.log('rAF'))

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

console.log('2')

// 输出顺序:1 → 2 → promise → setTimeout → rAF

为什么 rAF 不算宏任务?

  • 宏任务是"任务队列"(Task Queue)里的任务,每轮事件循环取一个执行。
  • rAF 是浏览器渲染前的"调度钩子",由浏览器在每帧渲染前主动调用,跟任务队列解耦。

完整的任务执行顺序

text
同步代码
↓ 同步代码执行完
微任务队列全部清空
↓ 微任务清空完
requestAnimationFrame 回调
↓ rAF 回调执行完
浏览器渲染(Layout / Paint / Composite)
↓ 渲染完成
取下一个宏任务(setTimeout / setInterval / I/O)
↓ 进入下一轮循环

关键点:rAF 不属于宏任务队列,是渲染前的独立阶段。

RAF 与 setTimeout 性能对比

  • setTimeout(fn, 16):只能"大致"保证 16ms 执行,但会进入宏任务队列,和 setTimeout/setInterval 一起排队,容易延迟到下一帧。
  • requestAnimationFrame:浏览器保证在每帧渲染前执行,与显示器刷新率同步(一般 60Hz),动画更平滑。

最佳实践:做帧动画时优先用 rAF 而非 setTimeoutsetTimeout 用于"延迟执行"场景,rAF 用于"动画场景"。

十、关联文档