原文链接:certificates.dev/blog/wait-f…
了解 Vue 的 nextTick 如何运作,为何 DOM 更新会被批量处理,以及在使用 ref 和动画时如何避免时序问题。
作者:Abdel Awad
等待下个滴答
在 Vue.js 中,nextTick 是那些表明你的应用程序已跨越特定复杂性门槛的工具之一。
本文将探讨 Vue.js 中的 nextTick 与时间机制,并介绍如何巧妙运用它们实现有趣的功能。
DOM 更新并非即时生效
Vue.js 的快速运行源于多重因素,其中运用了诸多优化技术。其中一项关键在于其 DOM 更新调度机制。
假设你执行了一系列需要 UI 更新的响应式变更,可能采用如下操作:
<template>
<div>{{ count }}</div>
<button @click="doStuff">Do Stuff</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function doStuff() {
count.value++
count.value += 2
count.value *= 3
}
</script>
调用 doStuff 函数会更新 count 变量,UI 也会相应更新,但需要思考的是:UI 究竟更新了多少次?
我们对 count 变量执行了 3 次操作,因此可能预期 UI 会更新 3 次。但实际上,UI 只会更新一次。
这是因为 Vue 会批量应用 DOM 更新,意味着这 3 次操作会被同时执行。
这种机制不仅限于单值更新。当存在如下多重响应式变更时:
const count = ref(0)
const name = ref('')
function registerUser() {
name.value = 'Jane' count.value++
}
调用 registerUser 函数会更新 name 和 count 变量,界面也会相应更新,但界面仅更新一次。
换言之,Vue 会缓冲 DOM 更新操作,并一次性批量应用。
这种机制虽能提升性能,但若未意识到其特性,可能导致意外行为。
时机陷阱
考虑下面的例子
<template>
<div ref="box">{{ count }}</div>
<button @click="increment">+1</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const box = ref()
function increment() {
count.value++
console.log(box.value.textContent)
}
</script>
每当按钮文本被记录时,你会发现它滞后于实际值。每次点击按钮时,记录的文本始终保持不变。
你或许觉得这个例子有些牵强,但让我们用更贴近现实的场景来阐明核心问题。
假设你需要在某个元素渲染到 DOM 后为其添加类名。理想情况下应使用 onMounted 处理,但若该元素在挂载时不可用且稍后才出现,你就必须等待。
<template>
<button @click="show = !show">Toggle</button>
<div v-if="show" class="box" ref="box">Hello</div>
</template>
<script setup>
import { ref, watch, useTemplateRef } from 'vue'
const show = ref(false)
const box = useTemplateRef('box')
watch(show, value => {
if (value) {
box.value.classList.add('visible')
}
})
</script>
在此示例中, box 元素在挂载时不可用;当 show 状态变为 true 时才会出现。
您可在此处亲自运行代码。请注意监听器回调会因元素尚未可用而崩溃。
这是常见问题。每当涉及元素引用和动画操作时,您迟早会遇到此类情况。
您可能在Stack Overflow或AI生成的建议中看到推荐使用 setTimeout 等待元素可用的方案:
setTimeout(() => {
box.value.classList.add('visible')
})
虽然这能行得通,但理解其原理至关重要。而使用 setTimeout 时,你最终等待的时间会比必要时长稍长一些。
进入 nextTick
Vue全局API中的 nextTick 实用工具可让您等待下一个DOM更新周期。
在以下示例中,回调函数将在DOM更新后执行。
nextTick 有以下几种用法:
- 作为回调函数
nextTick(() => {
console.log('DOM updated')
})
2. 作为一个可等待的 promise
nextTick().then(() => {
console.log('DOM updated') }
)
// or as an async function
await nextTick()
console.log('DOM updated')
就个人而言,我更倾向于使用promise版本,因为它更明确、更易于理解,并且避免了嵌套。
无论采用哪种方式,在操作DOM元素前等待 nextTick() 完成,都能使示例按预期运行。
await nextTick()
// value is now the same as the `count` variable
console.log(box.value.textContent)
await nextTick()
// doesn't crash anymore
box.value.classList.add('visible')
nextTick的时机
nextTick 的实现相当简单:它利用微任务(promise 计时)在当前事件循环周期结束后调度回调函数。
相比之下,setTimeout 使用宏任务,因此执行时间晚于 nextTick;浏览器还可能强制执行约 4 毫秒的最小延迟。
快来测试!我们有三种调度回调的方式——你能猜出日志的执行顺序吗?
setTimeout(() => {
console.log('Timeout!')
}, 0)
Promise.resolve().then(() => {
console.log('Promise!')
})
nextTick(() => {
console.log('Tick!')
})
requestAnimationFrame(() => {
console.log('rAF!')
})
日志顺序是:
Promise!
Tick!
rAF!
Timeout!
尝试交换它们的位置,观察顺序是否发生变化。
你会发现无论代码片段顺序如何,执行顺序都相当稳定;但若将 Promise.resolve().then() 与 nextTick() 互换位置,顺序就会改变——先执行的那段代码将优先运行。
这是因为nextTick使用微任务机制,其底层本质上是Promise.then(),因此其执行时机与Promise.resolve().then()完全一致。
一个更实际的例子
以下是一个更实用的示例,展示如何使用 nextTick 在 DOM 更新后执行操作。
我们有一个表格会以任意顺序插入项,需要向用户展示刚刚插入的项。
<template>
<div class="demo">
<h2>Without nextTick (Broken)</h2>
<button @click="addRandomItem">Add Random Item</button>
<div class="table-container" ref="container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.id"
:data-id="item.id"
>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'Item 1', status: 'Active' },
{ id: 2, name: 'Item 2', status: 'Pending' },
{ id: 3, name: 'Item 3', status: 'Active' },
{ id: 4, name: 'Item 4', status: 'Completed' },
{ id: 5, name: 'Item 5', status: 'Active' },
{ id: 6, name: 'Item 6', status: 'Pending' },
{ id: 7, name: 'Item 7', status: 'Active' },
{ id: 8, name: 'Item 8', status: 'Completed' },
])
const container = ref(null)
function addRandomItem() {
const randomIndex = Math.floor(Math.random() * items.value.length)
const newId = Math.max(...items.value.map(i => i.id)) + 1
const newItem = { id: newId, name: `Item ${newId}`, status: 'New' }
items.value.splice(randomIndex, 0, newItem)
const item = document.querySelector(`[data-id='${newId}']`);
// Try to scroll to the new item - CRASHES! Element doesn't exist yet
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'center' })
item.classList.toggle('highlighted')
setTimeout(() => {
item.classList.remove('highlighted');
}, 3000);
} else {
console.error('Element not found - DOM not updated yet!')
}
}
</script>
<style scoped>
.demo {
padding: 40px;
}
.table-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f5f5f5;
position: sticky;
top: 0;
}
tr.highlighted {
background: #4CAF50;
color: white;
animation: highlight 0.5s ease-out;
}
@keyframes highlight {
0% { background: #FFC107; }
100% { background: #4CAF50; }
}
button {
padding: 12px 24px;
font-size: 16px;
background: #2196F3;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
margin-bottom: 20px;
}
</style>
如果不添加 nextTick,该元素在 DOM 中尚不可见,因此即使代码正确地将元素添加到 DOM 中,程序仍无法正常运行。
请尝试在正确的位置添加 nextTick 并验证效果,以下是可运行的解决方案:
有趣的用例
在创建了几个 Vue.js 库之后,我想展示生态系统中其他库如何利用 nextTick 实现一些有趣的功能。
@vueuse/core
@vueuse/core 中一个典型的例子是 until 实用工具,我在项目中非常喜欢使用它。
它会等待某个响应式条件变为 true——特别适合等待数据获取器完成。
实现方式相当简单:它在 Promise 内部使用一个监视器,当条件成立时 Promise 便会解析,随后通过 nextTick 停止监视器,避免过早调用 stop函数。
vue-router
Vue路由库使用 nextTick 方法等待UI更新完成后,才会尝试为导航到的路由设置滚动位置。
@formwerk/core
老王卖瓜:我创建了一个名为@formwerk/core的库,用于在Vue.js中构建表单,我认为它实现了nextTick最酷的应用场景之一。
Formwerk 实现了表单状态事务系统,使其能够抵御 v-for 改变字段顺序、以及字段数组或重复器导致表单状态混乱的问题。
其工作原理是等待下一个 tick 周期——即用户执行 .splice 或 .push 操作后,系统会将若干事务排入队列,在下个 tick 周期进行处理。
因此字段无需声明:
I'm changing my name from `names[0]` to `names[1]`
并立即处理,它说:
I will change my name from `names[0]` to `names[1]`
这使得库只能在用户完成操作且一切稳定后才处理该事务。
结论
nextTick 是一个非常实用的工具,当你需要等待 DOM 更新完成后再执行操作时,它能派上大用场。同样地,当你需要在 DOM 更新完成后执行操作时,它也是绝佳的选择。
正如你所见,Vue.js 生态中的众多库都运用 nextTick 实现了诸多巧妙功能,让作为 Vue.js 开发者的你事半功倍。