Code前端首页关于Code前端联系我们

Vue3 nextTick到底怎么用?搞懂这5个核心场景才算真会

terry 1周前 (08-13) 阅读数 858 #Vue
文章标签 Vue3 nextTick场景

你有没有在Vue3开发中遇到过这种情况:刚用ref或reactive改了数据,紧接着去取DOM元素的尺寸、位置或者内容,结果拿到的还是旧值?比如写了个列表渲染,数据push了新项后去数列表DOM的长度,居然比数据数组少一个?这时候大概率别人会告诉你“用nextTick呀”,但到底为什么要用到它?它是怎么解决问题的?除了这种最基础的场景,还有哪些地方非用不可?今天咱们就把这些问题掰扯得明明白白。

Vue3的DOM更新机制到底是什么?搞懂原理才敢随便用nextTick

很多人用nextTick都是“知其然不知其所以然”,每次遇到DOM获取旧值就无脑加,但其实只要摸透Vue3的DOM更新策略,就能准确判断什么时候需要、什么时候完全没必要。

Vue3的核心响应式原理大家应该都略知一二:通过Proxy代理数据,当数据发生变化时,会触发依赖这个数据的组件重新渲染,但这里的“重新渲染”不是同步的——不是你改一行数据,它立刻就去改DOM,Vue3做了一个“批量异步更新队列”的优化:在同一个事件循环(Event Loop)中,不管你改了多少次数据,哪怕是循环赋值了100次,它只会收集一次需要更新的组件,把它们推到异步队列里,等当前同步代码执行完,微任务阶段再统一处理DOM更新。

为什么要这么做?主要是为了性能,DOM操作是前端最昂贵的操作之一,如果每次改数据都立刻重绘重排,浏览器的CPU和渲染性能会直接拉胯,尤其是处理大量数据或者复杂组件的时候,举个简单的例子:你要把一个数组里的100个元素都替换成新的,如果同步更新,就会触发100次组件的虚拟DOM diff和真实DOM更新;但用了异步队列,只会在所有数据修改完之后做1次,差了整整99倍的性能消耗。

nextTick在Vue3中的基本定义和写法是什么?两种用法都得掌握

那nextTick的作用就很明确了:它就是一个钩子函数,能让你在Vue完成下一次DOM更新批量操作之后,再去执行你指定的回调函数,这时候你拿到的DOM状态肯定是最新的。

在Vue3中,nextTick有两种常用的写法,适应不同的场景:

第一种:回调函数写法(兼容Vue2习惯,写法最直接)

这种写法和Vue2基本没区别,直接从vue包里引入nextTick函数,然后把要执行的代码放在回调里,适合只需要在DOM更新后做一两件简单的事,或者不想处理Promise的场景。

比如刚才提到的列表渲染加元素后数长度的问题:

import { ref, nextTick } from 'vue'
export default {
  setup() {
    const list = ref([1, 2, 3])
    const addItem = () => {
      list.value.push(4)
      // 这里直接取,拿到的还是3
      console.log(document.querySelectorAll('.list-item').length) 
      nextTick(() => {
        // 这里拿到的就是4了,因为DOM已经更新
        console.log(document.querySelectorAll('.list-item').length) 
      })
    }
    return { list, addItem }
  }
}

第二种:Promise写法(Vue3官方更推荐,支持async/await,代码更优雅)

Vue3把nextTick封装成了一个返回Promise的函数,所以你可以用async/await来写,代码逻辑更清晰,不会出现回调地狱的情况,尤其是需要多次操作DOM或者依赖多个nextTick结果的时候,这种写法优势特别明显。

同样的例子用Promise/async-await改写:

import { ref, nextTick } from 'vue'
export default {
  setup() {
    const list = ref([1, 2, 3])
    const addItem = async () => {
      list.value.push(4)
      console.log(document.querySelectorAll('.list-item').length) // 3
      await nextTick()
      console.log(document.querySelectorAll('.list-item').length) // 4
      // 还可以继续加DOM操作,比如给新添加的元素加个动画
      document.querySelector('.list-item:last-child').classList.add('animate__fadeIn')
    }
    return { list, addItem }
  }
}

nextTick在Vue3中的5个核心高频应用场景,每个都有实际代码示例

光知道原理和写法还不够,得知道什么时候该用,下面这5个场景是开发中遇到最多的,掌握了这些,你就能游刃有余地用nextTick了。

获取/操作更新后的DOM元素尺寸、位置、内容

这是nextTick最基础、最常用的场景,除了刚才的数列表长度,还有很多类似的需求,

  1. 给动态生成的图片添加懒加载占位符(需要先知道图片容器的尺寸)
  2. 拖拽元素结束后自动滚动到元素可见区域(需要知道元素的位置)
  3. 文本框输入完内容后自动聚焦到底部的新文本(比如聊天软件)
  4. 轮播图数据更新后自动重置当前索引(需要知道新的轮播项数量)

我们拿“聊天软件输入完消息自动滚动到底部”来做个更贴近实际的例子:

import { ref, nextTick } from 'vue'
export default {
  setup() {
    const messageList = ref(['你好', '今天天气怎么样', '听说Vue3很好用'])
    const newMessage = ref('')
    const scrollContainer = ref(null)
    const sendMessage = async () => {
      if (!newMessage.value.trim()) return
      messageList.value.push(newMessage.value.trim())
      newMessage.value = ''
      // 滚动容器需要用ref绑定,因为setup里没有this.$refs
      // 先更新数据,再等DOM更新
      await nextTick()
      // 直接把滚动高度设为容器的总高度,就能滚动到底部
      scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
    }
    return { messageList, newMessage, scrollContainer, sendMessage }
  }
}

对应的模板:

<template>
  <div class="chat-container">
    <div class="message-list" ref="scrollContainer">
      <div v-for="(msg, idx) in messageList" :key="idx" class="message-item">{{ msg }}</div>
    </div>
    <div class="input-area">
      <input v-model="newMessage" placeholder="输入消息..." @keyup.enter="sendMessage">
      <button @click="sendMessage">发送</button>
    </div>
  </div>
</template>

初始化时获取/操作动态生成的DOM

有些时候,DOM不是通过用户操作动态生成的,而是组件初始化时通过异步请求获取数据后生成的,这时候直接在setup的同步代码里或者onMounted里取DOM,也有可能拿到空值或者旧值。

为什么onMounted也可能不行?因为onMounted只是保证组件的“根DOM”已经挂载到页面上了,但如果组件内部的某些子DOM是通过v-if、v-for或者异步组件生成的,那么onMounted触发的时候,这些子DOM可能还没渲染出来。

比如我们有一个表格组件,数据是通过接口异步获取的,我们需要在表格完全渲染出来后,给第一行第一列的单元格加个高亮:

import { ref, nextTick, onMounted } from 'vue'
import axios from 'axios'
export default {
  setup() {
    const tableData = ref([])
    const getTableData = async () => {
      try {
        const res = await axios.get('/api/table-data')
        tableData.value = res.data.list
      } catch (err) {
        console.error('获取表格数据失败', err)
      }
    }
    const highlightFirstCell = async () => {
      // 先等接口请求完成,数据更新
      await getTableData()
      // 再等DOM更新
      await nextTick()
      const firstCell = document.querySelector('.table-container tr:first-child td:first-child')
      if (firstCell) firstCell.style.backgroundColor = '#ffeb3b'
    }
    onMounted(() => {
      // 不能直接在onMounted里写highlightFirstCell的同步代码,必须用async/await
      highlightFirstCell()
    })
    return { tableData }
  }
}

强制触发Vue的DOM更新(极少用,但关键时刻能救命)

虽然Vue的响应式系统已经很完善了,但有时候还是会遇到一些“响应式失效”的情况,

  1. 直接修改数组的索引(比如arr[0] = 'newVal',不过Vue3用Proxy已经解决了这个问题,主要是Vue2遗留的坑)
  2. 直接添加对象的新属性(Vue3也解决了,但如果用的是shallowRef或者shallowReactive,还是会失效)
  3. 修改了某些第三方UI库内部的非响应式数据

这时候除了用ref重新赋值整个对象/数组,还可以用nextTick强制触发一次DOM更新——不过要注意,这只是“临时救命”的办法,最好还是找到响应式失效的根本原因,用正确的方式处理数据。

比如用shallowReactive创建的对象,直接添加新属性不会触发更新,这时候可以用nextTick配合强制更新(不过更推荐给shallowReactive的根对象重新赋值,或者用reactive):

import { shallowReactive, nextTick, getCurrentInstance } from 'vue'
export default {
  setup() {
    // 只有根属性是响应式的,根属性内部的变化或者新根属性的添加不会触发更新
    const user = shallowReactive({ name: '张三' })
    const { proxy } = getCurrentInstance()
    const addAge = async () => {
      // 直接添加新根属性,不会触发更新
      user.age = 18
      console.log(proxy.user) // 能看到age:18,但页面上没显示
      // 强制触发一次更新(临时办法)
      await nextTick()
      proxy.$forceUpdate() // Vue3中$forceUpdate还能用,但仅限根组件或者通过getCurrentInstance获取的proxy
    }
    return { user, addAge }
  }
}

配合v-if/v-show切换DOM后立即操作

v-if是“真正的条件渲染”,会销毁和重建DOM;v-show是“切换CSS的display属性”,不会销毁DOM,不管是哪一种,在切换条件后立即操作DOM,都需要用nextTick。

比如我们有一个弹窗组件,点击按钮打开弹窗(v-if="isShow"),打开后需要自动聚焦到弹窗里的输入框:

import { ref, nextTick } from 'vue'
export default {
  setup() {
    const isShow = ref(false)
    const inputRef = ref(null)
    const openModal = async () => {
      isShow.value = true
      // 直接取inputRef.value,可能是null(v-if刚设为true,DOM还没重建)
      await nextTick()
      inputRef.value.focus()
    }
    return { isShow, inputRef, openModal }
  }
}

对应的模板:

<template>
  <button @click="openModal">打开弹窗</button>
  <div v-if="isShow" class="modal">
    <input ref="inputRef" placeholder="请输入内容...">
  </div>
</template>

如果用v-show的话,虽然DOM不会销毁,但第一次打开的时候(v-show从true变成false再变成true?不,第一次是false→true,DOM也是刚从隐藏变显示吗?不对,v-show第一次加载的时候,如果是false,display就是none,但DOM是已经挂载了的,不过为了保险起见,不管是v-if还是v-show,切换条件后操作DOM都建议加nextTick,尤其是第三方UI库的组件。

结合watch/watchEffect监听数据变化后操作DOM

有时候我们需要监听某个数据的变化,变化后立即操作DOM,这时候也需要用nextTick,因为watch/watchEffect的回调是在数据变化后、DOM更新前触发的(除非你设置了flush: 'post')。

哦对了,这里要提一下Vue3 watch/watchEffect的flush选项,它有三个值:

  1. 'pre'(默认值):在数据变化后、DOM更新前触发
  2. 'post':在DOM更新后触发
  3. 'sync':同步触发,慎用,性能差

如果你不想用nextTick,也可以直接把flush设为'post',效果是一样的,不过用nextTick更灵活,尤其是在watch回调里需要做一些同步操作之后再操作DOM的情况。

比如我们要监听输入框的内容变化,内容长度超过10个字符时,把输入框的边框改成红色:

import { ref, watch, nextTick } from 'vue'
export default {
  setup() {
    const inputValue = ref('')
    const inputRef = ref(null)
    // 方式一:用默认的flush: 'pre' + nextTick
    watch(inputValue, async (newVal) => {
      // 先做一些同步验证
      const isInvalid = newVal.length > 10
      console.log('输入内容是否过长:', isInvalid)
      // 等DOM更新
      await nextTick()
      if (isInvalid) {
        inputRef.value.style.border = '2px solid #f44336'
      } else {
        inputRef.value.style.border = '1px solid #ccc'
      }
    })
    // 方式二:用flush: 'post',不需要nextTick
    // watch(inputValue, (newVal) => {
    //   const isInvalid = newVal.length > 10
    //   if (isInvalid) {
    //     inputRef.value.style.border = '2px solid #f44336'
    //   } else {
    //     inputRef.value.style.border = '1px solid #ccc'
    //   }
    // }, { flush: 'post' })
    return { inputValue, inputRef }
  }
}

使用nextTick时的3个常见误区,别再踩坑了

虽然nextTick很好用,但也不能乱用,下面这3个误区是很多新手甚至是有经验的开发者都会踩的:

所有操作DOM的地方都加nextTick

很多人因为之前踩过获取旧DOM的坑,就养成了“只要操作DOM就加nextTick”的习惯,这其实是完全没必要的,nextTick会把回调推到微任务队列里,虽然微任务执行很快,但还是会有一点点延迟,而且代码里到处都是nextTick,也会影响可读性。

那什么时候不需要加nextTick呢?

  1. 操作的是静态DOM(不会因为数据变化而改变的DOM)
  2. 操作的是之前已经通过nextTick或者flush: 'post'确保更新过的DOM
  3. 在onMounted里操作的是组件的静态根DOM或者不会延迟渲染的子DOM

在nextTick的回调里再次修改数据,不加新的nextTick

如果你在nextTick的回调里再次修改了依赖DOM的数据,那么这个修改触发的DOM更新还是异步的,如果你紧接着又要操作这个新的DOM,还需要再加一个nextTick。

import { ref, nextTick } from 'vue'
export default {
  setup() {
    const count = ref(0)
    const countRef = ref(null)
    const doubleCount = async () => {
      count.value = 5
      await nextTick()
      console.log(countRef.value.textContent) // 5
      // 再次修改数据
      count.value = count.value * 2
      // 这里直接取,还是5
      console.log(countRef.value.textContent) 
      // 必须再加一个nextTick
      await nextTick()
      console.log(countRef.value.textContent) // 10
    }
    return { count, countRef, doubleCount }
  }
}

滥用$nextTick或者全局的nextTick,而不是用flush: 'post'

刚才提到了,Vue3的watch/watchEffect有flush: 'post'选项,它的作用就是让回调在DOM更新后触发,完全可以替代watch/watchEffect里的nextTick,代码更简洁,也更符合Vue的设计理念。

在Vue3的setup里,尽量不要用getCurrentInstance获取的proxy.$nextTick,而是直接从vue包里引入nextTick函数,因为proxy.$nextTick主要是为了兼容Vue2的this.$nextTick,在setup里用原生的nextTick更规范。

Vue3 nextTick是性能优化的产物,用对场景才是关键

最后咱们来总结一下:

  1. 原理:Vue3的DOM更新是批量异步的,nextTick能让你在DOM更新完成后执行回调
  2. 写法:有回调函数和Promise/async-await两种,推荐用后者
  3. 核心场景:获取更新后的DOM、初始化时获取动态DOM、强制更新(临时)、配合v-if/v-show、结合watch/watchEffect
  4. 常见误区:不要到处加、再次修改数据要加新的、能用flush: 'post'就别用nextTick

掌握了这些,你就能在Vue3开发中灵活运用nextTick了,再也不会遇到“DOM获取旧值”的尴尬情况,最好的办法还是尽量减少直接操作DOM的次数,多用Vue的响应式数据和指令,比如v-bind:style、v-bind:class、v-model等等,这才是Vue的核心优势所在。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门