异步与事件循环
涵盖 JS 异步编程范式(回调 / Promise / async-await)、事件循环机制、Vue nextTick 与微任务的反直觉表现。
一、回调函数
// 传统回调
function fetchData(callback) {
setTimeout(() => {
const data = { id: 1, name: '张三' }
callback(data)
}, 1000)
}
fetchData((data) => {
console.log('收到数据:', data)
})回调函数的演进路径:
// 回调地狱:嵌套层级深,难维护
step1((result1) => {
step2(result1, (result2) => {
step3(result2, (result3) => {
console.log(result3)
})
})
})详见 函数大全 中的回调函数部分。
二、Promise
基本用法
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
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)
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)
执行顺序
- 执行同步代码(执行栈)
- 同步代码执行完后,清空微任务队列
- 微任务队列清空后,执行一个宏任务
- 重复 2-3
微任务总是优先于下一个宏任务执行。
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 先执行?
真正的执行原理与顺序拆解
假设我们在组件中写下了严格的同步顺序:
// 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 → 普通微任务
为什么?
this.msg = 'hello':Vue2 监听到数据变化,把组件更新 Watcher 推进内部的callbacks数组。此时,Vue2 第一次调用底层的Promise.resolve().then(flushCallbacks)把清空数组的操作送入了浏览器的微任务队列。Promise.then:浏览器按顺序把"普通微任务"推入微任务队列(排在 Vue 之后)。this.$nextTick:Vue 发现当前宏任务中已经申请过微任务了,于是它只把回调推入内部的callbacks数组,而不会再去生成新的底层 Promise 微任务。运行结果:JavaScript 运行环境开始执行微任务检查点。先拿出第一个微任务(Vue 安排的),开始遍历执行
callbacks数组,这就依次执行了 DOM 更新 和 nextTick。然后拿出第二个微任务,执行 普通微任务。
⚠️ 注:如果把上方代码的第 2 步移到第 1 步之前,执行结果就会变成:普通微任务 → DOM更新 → nextTick。完全取决于谁先向浏览器注册了微任务。
Vue 3 中的真实表现(非常反直觉,面试绝杀点)
结果:DOM 更新 → 普通微任务 → nextTick
为什么?
msg.value = 'hello':Vue3 把 DOM 更新(Effect)推入内部任务队列,并调用Promise.resolve().then(flushJobs)将 DOM 更新送到浏览器的微任务队列中。同时,Vue3 在内部保存了这个 Promise 实例(假设叫currentFlushPromise)。Promise.then:把"普通微任务"送到浏览器的微任务队列中(排在 flushJobs 之后)。nextTick:Vue3 的 nextTick 源码实现其实就是通过.then挂接在currentFlushPromise后面的。相当于执行了currentFlushPromise.then(回调)。运行结果:
- 浏览器开始出队,执行第一个微任务:
flushJobs。DOM 发生更新。这个操作执行完毕后,currentFlushPromiseresolve 了,这导致底层的 Promise 机制又把 nextTick 的回调作为新的微任务推入到了队列尾部。 - 此时微任务队列里排队的是刚刚第 2 步的"普通微任务",于是执行普通微任务。
- 最后,执行刚刚入队的 nextTick。
- 浏览器开始出队,执行第一个微任务:
满分面试回答
如果面试官问:"Vue 中的 DOM更新、普通微任务、nextTick 谁先执行?"
这不能一概而论,因为它们都依赖 JavaScript 的微任务队列,并且在 Vue2 和 Vue3 的底层调度逻辑有着根本的差异。假设我们在同一段同步代码中依次触发了数据修改(引发DOM更新)、普通 Promise.then 和 nextTick:
- 共同点:在这个“先触发组件更新、再注册
nextTick”的前提下,Vue 会先刷新待处理的 DOM 更新,再执行相应的nextTick回调。不要把该顺序扩展成脱离调用时机和版本的绝对定律。- 在 Vue2 中:表现通常是 DOM更新 → nextTick → 普通微任务。因为 Vue2 会通过一个内部的
callbacks数组收集当前刻发生的所有更新和 nextTick,然后用唯一一个底层的 Promise 微任务去清空它。由于数据修改发生在最前,Vue2 率先抢占了微任务队列,导致同属该微任务的 nextTick 被"顺带"提前执行了。- 在 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(生成器)提供暂停能力
// 注意这个星号
function* magicFunc() {
console.log('第一段');
yield; // 代码执行到这里,会【强制暂停】!
console.log('第二段');
}
let it = magicFunc(); // 拿到一个控制器
it.next(); // 打印:第一段 (然后卡在 yield 那里不动了)
// ... 过了一万年 ...
it.next(); // 打印:第二段 (从卡住的地方接着往下跑)第二步:把 Promise 和暂停键结合
Generator 有个超棒的机制:next() 里面是可以传值的,传进去的值,会变成上一个 yield 的结果!
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 把"自动挡"做到语言层面
- 把带有星号的
function*替换成async function。 - 把暂停键
yield替换成await。 - 官方在底层内置了"自动帮你调
.then()和.next()的引擎"(类似早年的co库)。
所以:
async function getData() {
let user = await fetch('http://api.xxx.com/user');
console.log(user);
}在 JS 引擎内部,其实它悄悄给你还原成了这样:
- 它是一个
Generator,遇到await fetch,就悄悄yield fetch暂停了。 - JS 底层的"自动机"接管了这个 Promise。
- 发现 Promise 变成功了,"自动机"把结果拿到,自动调用
.next(结果)。 - 函数内部恢复执行,变量
user拿到了值,继续往下走一行。
终极一句话理解
async/await 就是:Generator 函数(提供暂停和恢复的能力)+ 自动驱动器(自动帮你 .then() 并恢复函数执行)包装在一起的语法糖!
八、Promise 的标准面试回答(高分话术)
1. 是什么 & 解决什么问题
"首先,它本质上是 JS 里用来处理异步编程的一种解决方案。在它出现之前,我们如果要做异步操作(比如发接口请求),基本只能靠回调函数。如果几个接口有先后依赖关系,就会造成很深的嵌套,也就是我们常说的'回调地狱',代码很难看,也很难维护。Promise 的出现,就是为了把这种'嵌套'的写法,变成了扁平化的'链式调用'(.then.then),让逻辑清晰很多。"
2. 核心机制:状态机
"Promise 最核心的机制就是它的三种状态:pending(等待中)、fulfilled(成功了)、rejected(失败了)。而且它有一个很重要的特性:状态是不可逆的。一旦从 pending 变成了成功或者失败,它的状态就'锁死'了,再也不能改了。"
3. 基本用法
"在使用的时候,我们一般会
new一个 Promise。它里面有两个非常关键的参数:resolve和reject。如果我的异步代码执行成功了,我就调用resolve把数据发射出去;如果失败了,就调reject把错误扔出去。而在外面,我们就用.then()接收成功的结果,用.catch()专门来兜底捕获异常。这种把'执行代码'和'处理结果'分离开的设计,特别优雅。"
4. 常用 API(展示广度)
"除了单次请求,Promise 还可以很好地处理并发。比如开发里常遇到的,我要等两个接口都返回数据了才渲染页面,我就会用
Promise.all;如果我只想要最快返回的那个结果,就可以用Promise.race;或者是只要有一个成功就行的Promise.any。"
5. 拔高:演进与现状
"虽然 Promise 很好,但如果
.then()链条太长,看着还是不够直观。所以后来 ES8 又出了async / await。不过我知道async / await其实就是 Promise 的语法糖,底层的基石依然还是 Promise。"
面试官连招预警
- "那你能手写一个
Promise.all或者Promise.any吗?" —— 思路就是自己包一个new Promise,里面用计数器判断。详见 高频手写题。 - "Promise 里的 then 如果不返回 Promise 会怎样?" —— 答:会自动包装成一个成功的、值为返回值的 Promise。
- "你在说状态时提到了 pending、fulfilled 等,它跟微任务(Microtask)有什么关系?" —— 答:Promise 的
.then里的回调函数,会被放进微任务队列,在当前宏任务执行完后优先执行。这也是事件循环 Event Loop 经常考的点。
九、requestAnimationFrame(RAF)到底是什么任务?
结论
requestAnimationFrame(rAF)不是宏任务。rAF 的回调在浏览器准备渲染下一帧前由浏览器主动调用,执行时机介于渲染流水线的特定阶段(一般在清空微任务之后、渲染前的合成阶段)。
浏览器事件循环完整流程
1. 执行一个宏任务(从宏任务队列取出)
2. 清空微任务队列(执行所有 microtask)
3. requestAnimationFrame 队列(rAF 回调)执行
4. 开始渲染流水线(Layout → Paint → Composite)
5. 回到步骤 1,继续取下一个宏任务详细解释
- rAF 回调的执行时机不在宏任务队列中,而是浏览器渲染前的独立阶段。
- 所以它的执行顺序是:微任务 → rAF 回调 → 渲染 → 下个宏任务。
- 如果主线程长时间被阻塞(>16.67ms),rAF 回调会被合并或跳过,保证不掉帧。
验证实验
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 是浏览器渲染前的"调度钩子",由浏览器在每帧渲染前主动调用,跟任务队列解耦。
完整的任务执行顺序
同步代码
↓ 同步代码执行完
微任务队列全部清空
↓ 微任务清空完
requestAnimationFrame 回调
↓ rAF 回调执行完
浏览器渲染(Layout / Paint / Composite)
↓ 渲染完成
取下一个宏任务(setTimeout / setInterval / I/O)
↓ 进入下一轮循环关键点:rAF 不属于宏任务队列,是渲染前的独立阶段。
RAF 与 setTimeout 性能对比
setTimeout(fn, 16):只能"大致"保证 16ms 执行,但会进入宏任务队列,和 setTimeout/setInterval 一起排队,容易延迟到下一帧。requestAnimationFrame:浏览器保证在每帧渲染前执行,与显示器刷新率同步(一般 60Hz),动画更平滑。
最佳实践:做帧动画时优先用 rAF 而非 setTimeout。setTimeout 用于"延迟执行"场景,rAF 用于"动画场景"。
十、关联文档
- 04-函数大全(异步函数代码示例)
- 06-迭代器与生成器
- Vue ref、reactive 与 nextTick(Vue 视角)