nextTick 原理

为什么修改数据后不能立刻获取 DOM?

背景:事件循环

JavaScript 执行是单线程的,靠事件循环驱动:

┌─────────────────────────────────────────┐ │ 宏任务队列 │ │ setTimeout | setInterval | I/O | ... │ └─────────────────┬───────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 微任务队列 │ │ Promise.then | MutationObserver | ... │ └─────────────────┬───────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 执行栈 │ │ (当前正在执行的代码) │ └─────────────────────────────────────────┘

每执行完一个宏任务,会清空所有微任务,然后再执行下一个宏任务。

Vue 更新是异步的

当你修改响应式数据时,Vue 不会立刻更新 DOM,而是:

  1. 触发 setter
  2. 触发 watcher
  3. 把"需要更新"这个任务放进异步队列
// 假设模板是 {{ 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、聚焦、滚动、第三方库集成