vue事件循环机制
一、基础: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(() => {// 这里可以获取到更新后的 DOMconsole.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;// 优先使用Promiseif (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长度是3console.log('List length:', this.list.length); // 3this.$nextTick(() => {// DOM已更新,可以操作更新后的DOMconsole.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。