nextTick 原理
为什么修改数据后不能立刻获取 DOM?
背景:事件循环
JavaScript 执行是单线程的,靠事件循环驱动:
┌─────────────────────────────────────────┐
│ 宏任务队列 │
│ setTimeout | setInterval | I/O | ... │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 微任务队列 │
│ Promise.then | MutationObserver | ... │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 执行栈 │
│ (当前正在执行的代码) │
└─────────────────────────────────────────┘
每执行完一个宏任务,会清空所有微任务,然后再执行下一个宏任务。
Vue 更新是异步的
当你修改响应式数据时,Vue 不会立刻更新 DOM,而是:
- 触发 setter
- 触发 watcher
- 把"需要更新"这个任务放进异步队列
// 假设模板是 {{ count }}
this.count = 1; // 触发 setter,放入队列
this.count = 2; // 再次放入队列?不,合并!
this.count = 3; // 放入队列
// 最终只更新一次 DOM,count = 3
同一个事件循环中的多次修改会合并,这是 Vue 的性能优化策略。
问题:修改后立刻获取 DOM,拿到的还是旧的。
this.count = 1;
console.log(this.$el.textContent); // 可能是 '0',还没更新!
nextTick 干什么用?
等 DOM 更新完成后,再执行回调:
this.count = 1;
this.$nextTick(() => {
console.log(this.$el.textContent); // '1',DOM 已更新
});
Vue 3 用法
<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
const el = ref(null)
async function update() {
count.value = 1
await nextTick()
console.log(el.value.textContent) // '1'
}
</script>
<template>
<div ref="el">{{ count }}</div>
</template>
原理详解
核心实现
// 简化的 nextTick 实现
let callbacks = [] // 回调队列
let pending = false // 是否正在执行
function nextTick(cb) {
callbacks.push(cb)
if (!pending) {
pending = true
// 用微任务执行
Promise.resolve().then(() => {
pending = false
const copies = callbacks.slice(0)
callbacks = []
copies.forEach(cb => cb())
})
}
}
关键点:
- 所有回调存在
callbacks 数组
- 用
pending 标记确保同一时间只有一个 Promise 在等待
- 回调通过微任务执行,在 DOM 更新之后
Vue 2 降级策略
Vue 2 还需要兼容旧浏览器:
// 优先级从高到低
const p = Promise.resolve()
export function nextTick(cb) {
return p.then(() => cb())
}
// 降级方案
// 1. MutationObserver - 监听 DOM 变化
// 2. setImmediate - IE 专用
// 3. setTimeout(fn, 0) - 保底
Vue 3
直接用 Promise.resolve(),不再考虑 IE 兼容性,代码更简洁。
异步更新队列
Vue 内部维护一个 watcher 队列:
// 简化的队列机制
let queue = []
let isFlushing = false
function queueWatcher(watcher) {
if (!queue.includes(watcher)) {
queue.push(watcher)
}
if (!isFlushing) {
isFlushing = true
nextTick(flushQueue)
}
}
function flushQueue() {
queue.forEach(watcher => watcher.run())
queue = []
isFlushing = false
}
为什么要用队列?
// 模板: {{ a }} {{ b }}
this.a = 1
this.b = 2
- 如果每个修改都立即更新 DOM,性能很差
- 队列机制保证 同一事件循环内只更新一次
- 这就是 Vue 的 批处理(batching)
场景
1. 操作更新后的 DOM
<script>
export default {
methods: {
async addItem() {
this.list.push({ id: Date.now() })
// 获取新渲染的 DOM 高度
await this.$nextTick()
this.scrollToBottom()
}
}
}
</script>
2. 修改数据后获取元素尺寸
this.width = '200px'
this.$nextTick(() => {
const box = this.$refs.box
console.log(box.offsetWidth) // 200
})
3. 父子组件通信
<!-- Parent -->
<Child :show="showModal" />
<!-- Child -->
watch: {
show(val) {
if (val) {
this.$nextTick(() => {
this.$refs.input.focus() // DOM 渲染后聚焦
})
}
}
}
4. 表单验证
this.errors.clear()
this.errors.add('name', '必填')
await this.$nextTick()
this.$refs.nameInput.$el.scrollIntoView()
注意事项
1. 不要在 nextTick 里再修改数据
// ❌ 不好
this.$nextTick(() => {
this.count = 2 // 可能触发无限循环
})
// ✅ 正确
this.count = 1
this.$nextTick(() => {
console.log(this.count)
})
2. 连续多个 nextTick
// 不需要嵌套!
// ❌
this.$nextTick(() => {
this.$nextTick(() => {
console.log(this.$el)
})
})
// ✅ 一次就够了,DOM 更新是一次性的
this.$nextTick(() => {
console.log(this.$el)
})
总结
- Vue 更新是异步的,会合并同一事件循环内的多次修改
nextTick 利用微任务(Promise),在 DOM 更新后执行回调
- Vue 3 直接用 Promise,Vue 2 有降级策略
- 适用场景:获取更新后的 DOM、聚焦、滚动、第三方库集成