vue事件循环机制 - 指南
2025-09-24 15:30 tlnshuju 阅读(27) 评论(0) 收藏 举报一、基础:JavaScript 事件循环(复习)
Vue 的异步更新机制建立在 JS 事件循环之上,必须先理解这个基础。
核心概念:
- 调用栈(Call Stack): 执行同步代码的地方
- 任务队列(Task Queue): 存放宏任务(Macro Tasks),如
setTimeout,setInterval, I/O - 微任务队列(Microtask Queue): 存放微任务(Micro Tasks),如
Promise.then,MutationObserver - 事件循环流程:
- 执行同步代码(调用栈)
- 清空微任务队列(所有微任务)
- 渲染页面(如有需要)
- 取一个宏任务执行
- 重复步骤 1-4
console.log('1');
// 同步
setTimeout(() => console.log('2'), 0);
// 宏任务
Promise.resolve().then(() => console.log('3'));
// 微任务
console.log('4');
// 同步
// 输出顺序:1 → 4 → 3 → 2
二、Vue 的异步更新队列(核心机制)
这是 Vue 事件循环机制最独特和重要的部分。
1. 为什么要异步更新?
问题: 如果数据变化立即更新 DOM,在同一个事件循环中多次修改数据会导致不必要的重复渲染。
// 如果同步更新,会渲染3次,性能差
this.name = 'Alice';
this.age = 25;
this.city = 'Beijing';
解决方案: Vue 将 DOM 更新推迟到下一个事件循环的微任务中执行,批量更新。
2. 异步更新流程
// 示例代码
export default {
data() {
return { count: 0
}
},
methods: {
updateCount() {
this.count = 1;
// 修改数据
this.count = 2;
// 再次修改
this.count = 3;
// 再次修改
console.log('同步代码结束');
}
}
}
执行流程:
- 数据变化: 当
count被修改时,Vue 会通知所有依赖(Watcher) - 加入队列: Vue 不会立即更新 DOM,而是将需要更新的 Watcher 加入异步队列
- 去重优化: 同一个 Watcher 在同一个事件循环中被多次触发,只会被推入队列一次
- 异步执行: 在下一个事件循环的微任务中,Vue 清空队列,执行所有 Watcher 的更新
- DOM 更新: 最终 DOM 只更新一次
// 伪代码表示Vue内部机制
class Vue
{
constructor() {
this._watchers = [];
this._pending = false;
}
// 数据变化时调用
notify() {
// 将watcher加入队列
const watchers = this._watchers.slice();
if (!this._pending) {
this._pending = true;
// 使用微任务异步执行
Promise.resolve().then(() =>
{
this._pending = false;
// 清空队列,执行所有更新
for (let watcher of watchers) {
watcher.update();
}
});
}
}
}
三、$nextTick 的原理和应用
1. 什么是 $nextTick?
$nextTick 是 Vue 提供的 API,用于在 DOM 更新完成后执行回调函数。
this.count = 100;
this.$nextTick(() =>
{
// 这里可以获取到更新后的 DOM
console.log('DOM updated:', this.$el.textContent);
});
2. $nextTick 的实现原理
$nextTick 会尝试使用以下微任务API(按优先级降序):
Promise.then()(现代浏览器)MutationObserver(备选方案)setImmediate(IE)setTimeout(fn, 0)(降级方案)
核心思想: 将回调函数推迟到下一个事件循环的微任务中执行。
// 简化的nextTick实现
const callbacks = [];
let pending = false;
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
// 优先使用Promise
if (typeof Promise !== 'undefined') {
Promise.resolve().then(flushCallbacks);
}
// 降级方案
else {
setTimeout(flushCallbacks, 0);
}
}
}
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
四、实战代码示例
示例1:理解更新时机
export default {
data() {
return { message: 'Hello'
}
},
methods: {
updateMessage() {
this.message = 'Updated';
// 此时DOM还未更新
console.log('同步代码:', this.$el.textContent);
// 可能还是 'Hello'
this.$nextTick(() =>
{
// DOM已经更新
console.log('nextTick中:', this.$el.textContent);
// 'Updated'
});
}
}
}
示例2:批量更新的好处
export default {
data() {
return { list: []
}
},
methods: {
addItems() {
// 三次数据修改,但只触发一次DOM更新
this.list.push('Item 1');
this.list.push('Item 2');
this.list.push('Item 3');
// 此时DOM还未更新,list长度是3
console.log('List length:', this.list.length);
// 3
this.$nextTick(() =>
{
// DOM已更新,可以操作更新后的DOM
console.log('DOM更新完成');
});
}
}
}
示例3:事件循环顺序
export default {
methods: {
testEventLoop() {
console.log('1. 同步代码开始');
// 数据变化 - 加入Vue异步更新队列(微任务)
this.message = 'Updated';
// 宏任务
setTimeout(() => console.log('4. setTimeout'), 0);
// 微任务
Promise.resolve().then(() => console.log('3. Promise'));
// Vue的nextTick(微任务)
this.$nextTick(() => console.log('2. nextTick'));
console.log('1. 同步代码结束');
}
}
}
// 输出顺序:
// 1. 同步代码开始
// 1. 同步代码结束
// 2. nextTick(Vue异步更新在此执行)
// 3. Promise
// 4. setTimeout
五、面试常见问题
Q1:为什么Vue使用异步更新队列?
A: 为了性能优化。批量处理数据变化,避免不必要的重复渲染,确保在同一个事件循环中的多次数据变化只触发一次DOM更新。
Q2:$nextTick和setTimeout(fn, 0)有什么区别?
A:
$nextTick优先使用微任务(Promise/MutationObserver),执行时机更早setTimeout是宏任务,要等到下一个事件循环才执行- 在Vue中,
$nextTick能确保在DOM更新后立即执行,而setTimeout可能要等到浏览器渲染之后
Q3:什么时候需要使用$nextTick?
A:
- 操作更新后的DOM:数据变化后需要立即操作DOM
- 在created钩子中操作DOM:此时DOM还未渲染,需要等到下一个tick
- 等待子组件渲染完成
export default {
created() {
this.$nextTick(() =>
{
// 此时DOM已渲染完成
this.doSomethingWithDOM();
});
}
}
总结
Vue事件循环机制的核心要点:
- 异步更新:数据变化 → 通知Watcher → 加入队列 → 下一个tick批量更新DOM
- 性能优化:同一个事件循环中的多次数据变化只会触发一次渲染
- $nextTick原理:利用微任务队列,确保回调在DOM更新后执行
- 执行顺序:同步代码 → Vue异步更新(微任务)→ 其他微任务 → 宏任务
- 宏任务(MacroTask/Task): 代表一个个独立的、离散的工作单元。JavaScript 引擎在每次事件循环中会执行一个宏任务,然后检查并清空微任务队列。
- 微任务(MicroTask): 代表需要在当前宏任务结束后、渲染之前立即执行的任务。每个宏任务执行完后,会清空整个微任务队列。
宏任务(MacroTask)列表
宏任务由浏览器或 Node.js 环境本身调度。
| 类型 | 描述 | 示例 |
|---|---|---|
setTimeout / setInterval | 定时器回调 | setTimeout(cb, 0) |
| I/O 操作 | 文件读写、网络请求等(在 Node.js 中尤为常见) | fs.readFile('file.txt', cb) |
| UI 渲染 | 浏览器自行决定的渲染时机(注意: 渲染本身也是一个宏任务) | - |
| 事件回调 | 用户交互事件(点击、滚动等) | button.addEventListener('click', cb) |
setImmediate | (仅Node.js) 在当前事件循环结束时执行 | setImmediate(cb) |
requestAnimationFrame | (仅浏览器) 在下一次重绘之前执行,通常用于动画 | requestAnimationFrame(cb) |
MessageChannel | 用于跨文档通信或 Web Worker 通信 | channel.port1.onmessage = cb |
微任务(MicroTask)列表
微任务是由 JavaScript 引擎本身调度的,优先级更高。
| 类型 | 描述 | 示例 |
|---|---|---|
Promise.then() / catch() / finally() | 最常用、最主要的微任务 | Promise.resolve().then(cb) |
queueMicrotask() | 现代浏览器提供的专门用于创建微任务的 API | queueMicrotask(cb) |
MutationObserver | 监听 DOM 变化的接口,其回调是微任务 | new MutationObserver(cb) |
process.nextTick | (仅Node.js) 优先级甚至高于其他微任务 | process.nextTick(cb) |
经典面试题与执行顺序分析
理解执行顺序的最佳方式是通过代码。
示例1:基础顺序
console.log('1. 同步脚本开始');
// 同步代码
setTimeout(() =>
{
console.log('6. setTimeout - 宏任务');
}, 0);
Promise.resolve().then(() =>
{
console.log('4. Promise - 微任务');
});
console.log('2. 同步脚本结束');
// 同步代码
// 输出顺序:
// 1. 同步脚本开始
// 2. 同步脚本结束
// 4. Promise - 微任务
// 6. setTimeout - 宏任务
流程分析:
- 执行同步代码(第一个宏任务)。
- 遇到
setTimeout,将其回调函数放入宏任务队列。 - 遇到
Promise.resolve().then(),将其回调函数放入微任务队列。 - 同步代码执行完毕(第一个宏任务结束)。
- 清空微任务队列,执行
Promise回调。 - 微任务队列清空后,进行可能的页面渲染。
- 从宏任务队列中取出下一个任务(
setTimeout的回调)并执行。
示例2:微任务中产生新的微任务
console.log('脚本开始');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() =>
{
console.log('Promise 1');
// 在微任务中又产生了一个新的微任务
return Promise.resolve('内部Promise');
})
.then((res) =>
{
console.log('Promise 2', res);
});
console.log('脚本结束');
输出顺序:
脚本开始
脚本结束
Promise 1
Promise 2 内部Promise
setTimeout
分析: 引擎会持续清空微任务队列,直到队列为空,才会执行下一个宏任务。因此,即使微任务中产生了新的微任务,也会在同一个事件循环周期内被执行完毕。
示例3:混合场景(非常重要)
console.log('1. Start');
setTimeout(() =>
{
console.log('2. setTimeout - MacroTask');
Promise.resolve().then(() =>
{
console.log('3. Promise inside setTimeout - MicroTask');
});
}, 0);
Promise.resolve().then(() =>
{
console.log('4. Promise - MicroTask');
setTimeout(() =>
{
console.log('5. setTimeout inside Promise - MacroTask');
}, 0);
});
console.log('6. End');
输出顺序:
1. Start
6. End
4. Promise - MicroTask
2. setTimeout - MacroTask
3. Promise inside setTimeout - MicroTask
5. setTimeout inside Promise - MacroTask
流程分析:
- 第一个宏任务(主线程脚本):
- 输出
1. Start - 将
setTimeout回调加入宏任务队列。 - 将
Promise.then回调加入微任务队列。 - 输出
6. End - 宏任务结束,开始清空微任务队列。
- 输出
- 执行微任务(第一个Promise):
- 输出
4. Promise - MicroTask - 将内部的
setTimeout回调加入宏任务队列。
- 输出
- 微任务队列清空,执行下一个宏任务(第一个setTimeout):
- 输出
2. setTimeout - MacroTask - 将内部的
Promise.then回调加入微任务队列。 - 当前宏任务结束,开始清空微任务队列。
- 输出
- 执行微任务(setTimeout内部的Promise):
- 输出
3. Promise inside setTimeout - MicroTask
- 输出
- 微任务队列清空,执行下一个宏任务(Promise内部的setTimeout):
- 输出
5. setTimeout inside Promise - MacroTask
- 输出
总结与记忆技巧
| 特征 | 宏任务(MacroTask) | 微任务(MicroTask) |
|---|---|---|
| 触发时机 | 每次事件循环执行一个 | 在当前宏任务执行完后立即清空整个队列 |
| 调度者 | 浏览器/Node.js(宿主环境) | JavaScript 引擎(JS本身) |
| 典型例子 | setTimeout, setInterval, I/O, UI事件 | Promise.then, MutationObserver, queueMicrotask |
| 优先级 | 低 | 高 |
记忆口诀:
同(同步代码) > 微(微任务) > 渲(渲染) > 宏(宏任务)
面试要点:
- 能清晰解释事件循环的流程。
- 能准确判断给定代码的输出顺序。
- 理解
Promise、async/await(本质是Promise的语法糖)是微任务。 - 了解
Vue.$nextTick的原理就是利用微任务队列来实现异步更新DOM。
浙公网安备 33010602011771号