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

Vue3里的nextTick到底有啥用?为啥很多场景不用它页面就抽风?

terry 1周前 (08-13) 阅读数 887 #Vue

先得搞懂Vue的更新逻辑是什么样的

聊nextTick的作用之前,咱们得先摸清楚Vue3处理DOM更新的底层小习惯——不然光说它“等DOM更新完再搞事”,你可能根本不知道它为啥要等、等的是什么时候。

你有没有发现一个特别有意思的现象?比如你在同一个事件循环里连改了好几个响应式数据:比如data里的count、message、userInfo的nickname,改完之后立刻去console.log这些对应的DOM元素的内容,结果大概率还是旧的;甚至有时候你改了count之后想立刻获取count对应的div的offsetWidth、offsetHeight,拿到的数值也是错的,这不是Vue没帮你改数据,也不是你的代码写错了,而是Vue故意这么做的——它把同一轮事件循环里的响应式数据修改,攒成了一个“批量更新任务包”,等本轮任务干完了,才一起去更新DOM。

为啥要攒着批量更新?这得从浏览器的渲染机制说起,咱们都知道,浏览器每次渲染页面,都要走重排(Reflow)和重绘(Repaint)这两个核心步骤——重排是改了元素的几何属性(比如宽高、位置、display属性),浏览器要重新计算布局树,把页面所有相关的元素位置都理清楚;重绘是改了元素的视觉属性(比如颜色、背景、透明度),不需要重新算布局,直接更新像素就行,不管是重排还是重绘,都特别耗性能——要是你每改一次响应式数据,Vue就立刻触发一次DOM更新,那你改10次数据,浏览器就得重排/重绘10次,要是遇到复杂的表格、轮播图这种有大量DOM的组件,页面直接就卡成PPT了,用户体验差到炸。

那Vue是怎么“攒任务包”的呢?这就用到了JavaScript的事件循环(Event Loop)机制,简单说,JavaScript是单线程的,它处理任务的时候会先把同步任务放到主线程的执行栈里按顺序来,等执行栈空了,再去任务队列里拿异步任务,任务队列又分两种:微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)——常见的宏任务有setTimeout、setInterval、事件监听回调、网络请求回调这些;常见的微任务有Promise.then/catch/finally、async/await(本质上也是Promise)、还有咱们今天要聊的Vue3的nextTick。

Vue3的响应式系统是用Proxy(代理)实现的,当你修改一个响应式数据的时候,Proxy会立刻捕获到这个修改,然后找到依赖这个数据的所有组件的副作用函数(就是负责更新DOM的函数),但它不会立刻执行这些副作用函数,而是把它们丢到一个叫“queue(队列)”的地方存着,然后给当前事件循环加一个微任务(这个微任务的优先级比宏任务高),等本轮所有的同步任务都干完了,主线程空下来,就先去处理这个微任务——这个微任务就是把queue里的所有副作用函数按顺序、批量执行一遍,触发DOM的批量更新,这就是Vue的“异步更新策略”。

那nextTick具体是干什么的?官方文档里说的“下次DOM更新循环结束之后执行延迟回调”到底是啥意思?

刚才咱们说了,Vue把修改数据后的DOM更新放到了一个微任务里,那nextTick的作用呢?就是让你往这个微任务队列的更靠后的位置加一个自己的回调函数,或者如果Vue已经加过那个更新微任务了(也就是同一轮修改已经攒好任务包了),就直接加到那个更新微任务的后面——你的回调函数一定会在Vue完成了所有同一轮触发的DOM批量更新之后才执行,这时候你再去获取DOM元素的最新属性、或者操作DOM(虽然Vue3推荐用ref操作但有时候也免不了手动),拿到的结果就是对的了。

官方文档里说的“下次DOM更新循环结束之后”,其实翻译成人话就是:当你在某一个时刻连续改了一堆响应式数据之后,Vue会开启一轮“DOM更新循环”——这轮循环里会把所有相关的副作用函数都执行完,DOM也就跟着更新成最新的了,nextTick的回调就是在这轮循环彻底结束之后才跑的。

举个最最常见的例子吧,比如你有一个聊天输入框和聊天记录列表,聊天记录列表是用v-for渲染的,每次用户点发送按钮,你就往聊天记录数组里push一条新消息,然后你想让聊天列表自动滚动到底部,显示最新的那条消息,这时候你会怎么写?

很多新手一开始都会这么写:

const sendMsg = () => {
  // 假设已经处理了输入框内容、数据校验这些
  msgList.value.push(newMsg);
  // 立刻获取聊天列表的DOM元素,设置scrollTop
  const msgContainer = document.querySelector('.msg-container');
  msgContainer.scrollTop = msgContainer.scrollHeight;
}

结果呢?大概率会发现,聊天列表根本不会自动滚动到底部,或者有时候能滚有时候不能滚——这就是因为你立刻获取DOM的时候,Vue还没把新push的那条消息渲染到DOM里呢,msgContainer.scrollHeight还是旧消息的高度,scrollTop设了也白设。

这时候nextTick就该上场了,你只需要把获取DOM、设置scrollTop的代码放到nextTick的回调里就行:

// Vue3引入nextTick的方式有两种:全局API的nextTick和组件实例上的$nextTick
// <script setup>里只能用全局API的,得先从vue里import
import { ref, nextTick } from 'vue';
const msgList = ref([]);
const sendMsg = async () => {
  msgList.value.push(newMsg);
  // 第一种写法:用回调函数
  nextTick(() => {
    const msgContainer = document.querySelector('.msg-container');
    msgContainer.scrollTop = msgContainer.scrollHeight;
  });
  // 第二种写法:因为nextTick返回一个Promise,所以可以用async/await,更简洁
  // await nextTick();
  // const msgContainer = document.querySelector('.msg-container');
  // msgContainer.scrollTop = msgContainer.scrollHeight;
}

这样写之后,不管你点发送点多快,每次push完新消息,nextTick的回调都会等Vue把新消息渲染到DOM里之后再执行,这时候scrollHeight就是包含了最新消息的完整高度,scrollTop设上去,聊天列表就会乖乖滚到底部了。

除了自动滚动聊天列表,nextTick还有哪些常见的实际应用场景?

刚才的聊天列表自动滚动是最基础的,nextTick在Vue3的开发里还有很多高频场景,我挑几个最实用、新手最容易踩坑的来说说。

获取通过v-if/v-show控制显示/隐藏的DOM元素的最新属性

这个场景太常见了,比如你有一个弹窗,默认是隐藏的(v-if="showModal"),点一下按钮就把showModal设为true,这时候你想获取弹窗里某个表单元素的高度,或者给它设置焦点——比如给搜索框自动聚焦,方便用户输入。

新手一开始可能会这么写:

const openModal = () => {
  showModal.value = true;
  const searchInput = document.querySelector('.modal-search-input');
  searchInput.focus();
}

结果呢?大概率会报错“Uncaught TypeError: Cannot read properties of null (reading 'focus')”——因为你把showModal设为true之后,Vue还没把弹窗的DOM元素创建出来呢(如果是v-if的话),或者还没把它从display:none改成display:block之类的可见状态(如果是v-show的话,虽然DOM元素存在但可能获取不到offsetWidth这些需要布局的属性),document.querySelector当然找不到元素或者找到的是不可用的元素。

这时候加nextTick就解决了:

const openModal = async () => {
  showModal.value = true;
  await nextTick();
  const searchInput = document.querySelector('.modal-search-input');
  if (searchInput) searchInput.focus(); // 加个判断更安全,虽然大概率能找到
}

解决第三方库和Vue响应式系统的冲突

现在开发Vue项目,几乎都会用到第三方UI组件库或者功能库,比如ECharts、高德地图、TinyMCE富文本编辑器这些,这些第三方库很多都是直接操作DOM的,而Vue是通过响应式数据间接控制DOM的,有时候两者配合不好就会出问题。

比如你用ECharts做了一个折线图,折线图的数据是从后端接口获取的,接口返回数据之后,你立刻修改了绑定的响应式数据,然后立刻调用ECharts的setOption方法更新图表——这时候你会发现,有时候图表能更新,有时候不能,或者更新的图表样式是错的。

为什么会这样?因为第三方库的DOM操作是同步的,而Vue的DOM更新是异步的,接口返回数据之后你立刻改了响应式数据,但Vue还没把图表容器的宽高调整好(比如图表容器是用flex布局,响应式数据修改后父元素的宽度变了,容器的宽度也跟着变,但还没更新完),这时候调用setOption,ECharts拿到的容器宽高是旧的,渲染出来的图表自然就不对。

这时候你可以在修改响应式数据之后,加一个nextTick,等Vue把容器的DOM更新完,再调用setOption:

import { ref, onMounted, nextTick } from 'vue';
import * as echarts from 'echarts';
const chartContainer = ref(null); // 用ref获取容器,比querySelector更安全
const chartData = ref([]);
let myChart = null;
onMounted(() => {
  myChart = echarts.init(chartContainer.value);
  fetchData(); // 页面加载完成后获取数据
});
const fetchData = async () => {
  // 模拟从后端获取数据
  const res = await getChartApi();
  chartData.value = res.data;
  // 修改完数据之后,等Vue更新容器的DOM
  await nextTick();
  // 再更新ECharts图表
  myChart.setOption({
    xAxis: { type: 'category', data: res.data.xData },
    yAxis: { type: 'value' },
    series: [{ data: res.data.yData, type: 'line' }]
  });
};

还有一种情况,比如你用TinyMCE富文本编辑器,编辑器的内容是通过v-model绑定的,但有时候你修改了绑定的响应式数据之后,编辑器里的内容并没有更新——这是因为TinyMCE的编辑器实例是在组件挂载之后才初始化的,它自己有一套内容更新机制,不会自动监听Vue的响应式数据变化,这时候你可以在修改数据之后,加一个nextTick,然后手动调用编辑器的setContent方法,不过这个场景其实用watch监听响应式数据变化更常用,但nextTick有时候也能派上用场,比如初始化编辑器的时候,等Vue把容器的DOM渲染出来再init。

强制更新DOM(虽然不推荐,但有时候确实需要)

Vue3的响应式系统已经非常强大了,用Proxy可以监听到几乎所有的响应式数据变化——比如对象新增/删除属性、数组的push/pop/splice/shift/unshift/reverse/sort这些方法、甚至直接修改数组的索引和length(Vue2里直接修改索引和length是不会触发更新的,得用$set或者数组的变异方法)。

但有时候还是会遇到一些极端情况,比如你修改了一个非常深层的、嵌套了很多层的响应式对象的属性,但Vue的Proxy没有监听到(虽然这种情况在Vue3里已经很少见了,但万一呢?比如你修改的是某个第三方库返回的对象的属性,而那个对象没有被Vue正确地代理),或者你修改的是DOM元素的原生属性(比如input元素的value,但其实用v-model就能搞定),这时候页面不会自动更新,你可以用nextTick来强制触发一次DOM更新吗?

其实Vue3官方不推荐用nextTick来强制更新,官方提供了一个专门的方法叫forceUpdate,也就是全局API的forceUpdate或者组件实例上的$forceUpdate,它会强制触发当前组件的重新渲染,不管有没有修改响应式数据,但forceUpdate只会重新渲染当前组件,不会重新渲染子组件(除非子组件的props变了),而且它也会遵循Vue的异步更新策略,所以如果你用了forceUpdate之后想获取最新的DOM,还是得加nextTick。

不过再强调一遍,除非万不得已,千万不要随便用forceUpdate——它会破坏Vue的响应式系统的逻辑,增加性能消耗,你应该先排查为什么响应式数据修改后页面不更新,比如是不是没有正确地把数据设为响应式的(比如没有用ref/reactive包裹),是不是修改的是对象的原型属性,是不是修改的是冻结的对象(Object.freeze)。

处理某些特定的浏览器兼容性问题

虽然现在大部分浏览器都已经支持Vue3的Proxy了,但有时候还是会遇到一些奇怪的浏览器兼容性问题,比如在某些旧版本的移动端浏览器上,修改响应式数据后DOM更新的时机不对,导致页面出现闪烁、错位等问题,这时候加一个nextTick有时候能解决这些问题——不过这种情况比较少见,最好还是先检查一下你的代码逻辑,或者用polyfill(不过Vue3的Proxy polyfill比较复杂,官方推荐如果要兼容不支持Proxy的浏览器(比如IE11),还是用Vue2)。

Vue3的nextTick和Vue2的nextTick有什么区别?

聊到这里,很多用过Vue2的同学可能会问,Vue3的nextTick和Vue2的有啥不一样吗?其实从用法上来说,两者几乎没什么区别——都是全局API的nextTick和组件实例上的$nextTick,都可以用回调函数或者Promise的方式调用,作用也都是等DOM更新完再执行回调。

但从底层实现上来说,两者还是有很大的区别的,这主要是因为Vue2和Vue3的响应式系统不一样,而且事件循环的处理也更完善了。

底层实现的区别

Vue2的响应式系统是用Object.defineProperty实现的,它的异步更新策略也是用事件循环,但Vue2的nextTick实现比Vue3复杂一点——因为在Vue2的时候,浏览器对微任务的支持还不是特别完善(比如IE11根本不支持Promise),所以Vue2的nextTick会做一个降级处理:首先尝试用Promise.then(微任务),如果浏览器不支持Promise,就尝试用MutationObserver(微任务),如果还是不支持,就尝试用setImmediate(只有IE和Edge支持的宏任务),最后如果都不支持,就用setTimeout(fn, 0)(宏任务)。

而Vue3不一样,Vue3的目标浏览器是支持ES6+的现代浏览器(如果要兼容IE11就得用Vue2),所以现代浏览器都支持Promise和async/await,所以Vue3的nextTick直接用Promise.then实现了,没有了Vue2的那些降级处理,代码更简洁,性能也更好。

另一个小区别:组件实例上的$nextTick

在Vue2中,组件实例上的$nextTick的回调函数的this指向的是当前组件实例;而在Vue3的