【翻译】等待下个滴答

31 阅读7分钟

原文链接: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 函数会更新 namecount 变量,界面也会相应更新,但界面仅更新一次。

换言之,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 有以下几种用法:

  1. 作为回调函数
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 开发者的你事半功倍。