【翻译】如何使用 useTemplateRef 访问 DOM 元素

4 阅读8分钟

原文链接:michaelnthiessen.com/how-to-acce…

作者:Michael Thiessen

在 Vue 3.5 中,新增了一个内置组合器:useTemplateRef

它比我们之前的方式更优雅地实现了直接访问 DOM 元素的功能。

以下是最基础的示例:

<script setup>
import { useTemplateRef, onMounted } from 'vue'

const searchInput = useTemplateRef('search')

onMounted(() => {
  if (!focusRef.value) return

  // Focus on the search input when the component mounts
  searchInput.value.focus()
})
</script>

<template>
  <div>
    <input ref="search" />
  </div>
</template>

当此组件挂载时,我们将立即将焦点置于搜索input。为此,我们首先使用 useTemplateRef 设置模板引用,并传入键值 "search"。随后在模板中,我们为input添加具有相同键值的 ref 属性。

在后台,Vue 会自动连接这两部分,现在我们就能在 Vue 代码中直接访问该输入元素了!

为何在Vue中引入TemplateRef

在探讨更高级的应用方式及最佳实践之前,让我们先了解其重要性和实用价值。

模板引用在Vue中并非新概念。此前我们通过声明常规ref来访问模板内容,例如:

<script setup>
import { ref, onMounted } from 'vue'

const searchInput = ref(null)

onMounted(() => {
  if (!focusRef.value) return

  // Focus on the search input when the component mounts
  searchInput.value.focus()
})
</script>

<template>
  <div>
    <input ref="searchInput" />
  </div>
</template>

这里主要有两个区别:

  1. 我们使用 ref(null) 代替 useTemplateRef('search')
  2. 我们将 ref 属性设置为变量本身,而非使用字符串

代码本身差异不大,但这种实现方式带来了三个挑战:

  1. 可读性较差
  2. 使用模板引用的组合器难以编写
  3. 类型推断效果不佳

首先,我们无法区分"常规"引用与模板引用。必须查看模板才能了解其用法,仅凭脚本块无法获取该信息。

而使用 useTemplateRef 时,我们能一目了然——因为它采用 useTemplateRef 定义,而非像其他引用那样使用 ref

其次,编写操作 DOM 的组合式组件需要不断传递这些模板引用。如果我们将这个焦点示例打包成组合式组件,结果如下:

import { onMounted, ref } from 'vue'

export default function useFocus(focusRef) {
  onMounted(() => {
    // Make sure we were given a ref
    if (focusRef.value) {
      focusRef.value.focus()
    }
  })
}

要使用它,我们首先需要创建引用,然后将其传递进去:

<script setup>
import { ref } from 'vue'
import useFocus from './useFocus.js'

const searchInput = ref(null)
useFocus(searchInput)
</script>

<template>
  <div>
    <input ref="searchInput" />
  </div>
</template>

相反,通过使用 useTemplateRef,我们可以将其重构得更简单且更灵活:

import { onMounted, useTemplateRef } from 'vue'

export default function useFocus() {
  const focusRef = useTemplateRef('focus')
  onMounted(() => {
    if (!focusRef.value) return
    focusRef.value.focus()
  })
}

我们还需更新模板以使用“焦点”键:

<script setup>
import useFocus from './useFocus.js'
useFocus()
</script>

<template>
  <div>
    <input ref="focus" />
  </div>
</template>

通过本次更新,我们再也不需要传递 ref 参数了!使用 useTemplateRef 编写可组合函数时还能实现其他酷炫功能,我们将在本文后续部分详细探讨。

最后,useTemplateRef 可组合函数通过两种方式显著提升了类型推断能力:

  1. 实际引用类型自动推断
  2. 应用程序中定义的键支持自动补全

当在组件或可组合函数中使用 useTemplateRef(且应用对象为静态元素)时,系统会自动推断该元素类型。例如textarea 将推断为 HTMLTextAreaElement 类型,div 元素则推断为 HTMLDivElement 类型。

我们还能为传递给 useTemplateRef 的键获得出色的自动补全功能,因为 TypeScript 完全了解它们应如何关联。这为开发者带来了极佳的体验!

好了,既然已经说明了它的必要性,现在让我们深入探讨如何在应用中使用它。

如何使用 useTemplateRef 可组合函数

我们已经了解了最基础的使用方式:

<script setup>
import { useTemplateRef, onMounted } from 'vue'

const searchInput = useTemplateRef('search')

onMounted(() => {
  if (!searchInput.value) return

  // Focus on the search input when the component mounts
  searchInput.value.focus()
})
</script>

<template>
  <div>
    <input ref="search" />
  </div>
</template>

我们需要完成以下几项工作:

  1. 在模板中设置键值并将其传递给 useTemplateRef
  2. 使用 onMounted 确保组件已渲染完毕再进行后续操作
  3. 同时检查值是否为 null,尤其当元素使用 v-if 时!

我们还可以像使用先前模板引用那样使用创建的模板引用,并在模板中直接使用它

<script setup>
import { useTemplateRef, onMounted } from 'vue'

const searchInputRef = useTemplateRef('search')

onMounted(() => {
  if (!searchInputRef.value) return

  // Focus on the search input when the component mounts
  searchInputRef.value.focus()
})
</script>

<template>
  <div>
    <input :ref="searchInputRef" />
  </div>
</template>

若在 v-for 循环内部使用 useTemplateRef,实际返回的是可操作的元素数组。但需注意:无法保证元素顺序保持一致,因此需要额外处理以维持排序:

<script setup>
import { useTemplateRef, onMounted, ref } from 'vue'

const questions = ref([
  { text: 'What is your mother\'s maiden name?', id: 'name' },
  { text: 'What is your birthday?', id: 'birthday' },
  { text: 'What street did you grow up on?', id: 'street' },
]);
const inputRefs = useTemplateRef('inputs')

onMounted(() => {
  // Do something with the array of template refs
})
</script>

<template>
  <div
      v-for="question in questions"
      :key="question.id"
    >
        {{ question.text }}
    <input ref="inputs" />
  </div>
</template>

(为保持清晰,我简化了上述示例,但请确保您的表单实际具备无障碍访问功能!)

有时您会进行导致布局变化的修改,或需要等待页面完全更新后才能执行后续操作。这种情况下,nextTick 将成为您的得力助手

<script setup>
import { useTemplateRef, onMounted, nextTick } from 'vue'

const templateRef = useTemplateRef('element')

onMounted(async () => {
  // Let the browser calculate the height of the element
  templateRef.value.style.height = 'auto'

  // Wait for the layout to be recalculated
  await nextTick()

  // Lock in the height so it doesn't change after this
  templateRef.value.style.height = `${templateRef.value.scrollHeight}px`
})
</script>

<template>
  <div>
    <div ref="element" />
  </div>
</template>

本文末尾将提供一个更复杂的组合式组件示例,该示例将运用所有这些技术。但在此之前,让我们先探索使用 useTemplateRefs 还能实现哪些功能。

使用 useTemplateRef 的高级组合式模板

让我们更进一步,应用灵活参数模式,从而能够选择是否传入自定义模板引用:

import { onMounted, useTemplateRef } from 'vue'

export default function useFocus(passedInRef) {
  const focusRef = passedInRef || useTemplateRef('focus')

  onMounted(() => {
    if (!focusRef.value) return
    focusRef.value.focus()
  })

  return focusRef
}

通常情况下,我们会使用 ref 来确保在组合函数中始终获取一个引用:

// Normal usage of the Flexible Arguments Pattern
export default function useComposable(myRef) {
  const internalRef = ref(myRef)
}

但在此处,我们无法将引用传递给 useTemplateRef,因此必须使用传统的 OR 操作来检查,并在必要时创建自己的模板引用。

不过我们还能进一步优化。通过修改输入参数,使其既能接受字符串也能接受引用,从而实现对键值的灵活控制:

import { onMounted, useTemplateRef, isRef } from 'vue'

export default function useFocus(refOrString) {
  let focusRef

  if (isRef(refOrString) {
    // Use the template ref we've been given
    focusRef = refOrString
  } else if (typeof refOrString === 'string') {
    // Use the string to create our own template ref
    focusRef = useTemplateRef(refOrString)
  } else {
    // Create our own template ref with our own key
    focusRef = useTemplateRef('focus')
  }

  onMounted(() => {
    if (!focusRef.value) return
    focusRef.value.focus()
  })

  // Return the template ref
  return focusRef
}

这使我们能够完全掌控如何使用这个可组合组件。我们可以传入自己的模板引用:

<script setup>
import useFocus from './useFocus.js'
import { useTemplateRef } from 'vue'

const el = useTemplateRef('some-other-key')

useFocus(el)
</script>

<template>
  <div>
    <input ref="some-other-key" />
  </div>
</template>

或者我们可以传入自己的字符串:

<script setup>
import useFocus from './useFocus.js'
import { useTemplateRef } from 'vue'

const key = 'some-other-key'
useFocus(key)
</script>

<template>
  <div>
    <!-- We can also use a variable with the ref attribute -->
    <input :ref="key" />
  </div>
</template>

如果我们并不在意,可以使用默认引用。但必须知道组合器使用了什么字符串作为键:

<script setup>
import useFocus from './useFocus.js'
useFocus()
</script>

<template>
  <div>
    <input ref="focus" />
  </div>
</template>

为了解决这个问题,我们可以更新组合函数使其返回默认键。顺便一提,我们还应确保它返回当前使用的任何键——无论我们传入了自定义键还是使用默认键:

import { onMounted, useTemplateRef, isRef } from 'vue'

const defaultKey = 'focus'

export default function useFocus(refOrString) {
  let focusRef
  let key = defaultKey

  if (isRef(refOrString) {
    // Use the template ref we've been given
    focusRef = refOrString
  } else if (typeof refOrString === 'string') {
    // Use the string to create our own template ref
    focusRef = useTemplateRef(refOrString)

    // Update the key that we're using
    key = refOrString
  } else {
    // Create our own template ref with our own key
    focusRef = useTemplateRef(defaultKey)
  }

  onMounted(() => {
    if (!focusRef.value) return
    focusRef.value.focus()
  })

  return {
    ref: focusRef,
    key,
  };
}

现在我们不必猜测组合器将使用什么作为键:

<script setup>
import useFocus from './useFocus.js'
const { key } = useFocus()
</script>

<template>
  <div>
    <input :ref="key" />
  </div>
</template>

示例:高级可组合组件 以下是《Nuxt 深度解析》中用于为 AI 聊天应用添加滚动功能的可组合组件示例。该组件可显示/隐藏用于滚动到底部的按钮,当用户已处于页面底部时,新消息推送时会保持当前位置:

/**
 * A composable function that handles chat scroll behavior including:
 * - Tracking scroll position
 * - Smooth scrolling to bottom
 * - Auto-scrolling on new messages
 * - Scroll button visibility
 */
export default function useChatScroll() {
  // Template refs for accessing DOM elements
  const scrollContainer = useTemplateRef<HTMLDivElement>(
    'scrollContainer'
  )
  const textareaRef =
    useTemplateRef<HTMLTextAreaElement>('textareaRef')

  // Reactive state for tracking scroll position
  const isAtBottom = ref(true)
  const showScrollButton = ref(false)

  /**
   * Checks if the chat is scrolled to the bottom
   * Considers the chat "at bottom" if within 200px of the bottom
   * Updates both isAtBottom and showScrollButton states
   */
  const checkScrollPosition = (): void => {
    if (scrollContainer.value) {
      const { scrollTop, scrollHeight, clientHeight } =
        scrollContainer.value
      isAtBottom.value =
        scrollTop + clientHeight >= scrollHeight - 200
      showScrollButton.value = !isAtBottom.value
    }
  }

  /**
   * Smoothly scrolls the chat container to the bottom
   * @param immediate - If true, scrolls instantly without animation
   */
  const scrollToBottom = (immediate = false): void => {
    if (!scrollContainer.value) return

    // Calculate the target scroll position (bottom of container)
    const targetScrollTop =
      scrollContainer.value.scrollHeight -
      scrollContainer.value.clientHeight

    // If immediate scroll requested, do it instantly
    if (immediate) {
      scrollContainer.value.scrollTop = targetScrollTop
      return
    }

    // Setup for smooth scrolling animation
    const startScrollTop = scrollContainer.value.scrollTop
    const distance = targetScrollTop - startScrollTop
    const duration = 300 // Animation duration in milliseconds

    // Animation frame handler for smooth scrolling
    const startTime = performance.now()
    function step(currentTime: number): void {
      const elapsed = currentTime - startTime
      const progress = Math.min(elapsed / duration, 1)
      // Cubic easing function for smooth acceleration/deceleration
      const easeInOutCubic =
        progress < 0.5
          ? 4 * progress * progress * progress
          : 1 - Math.pow(-2 * progress + 2, 3) / 2

      if (scrollContainer.value) {
        scrollContainer.value.scrollTop =
          startScrollTop + distance * easeInOutCubic

        // Continue animation if not complete
        if (progress < 1) {
          requestAnimationFrame(step)
        }
      }
    }

    requestAnimationFrame(step)
  }

  /**
   * Forces scroll to bottom when new messages arrive
   * Only scrolls if already at bottom to prevent disrupting user's reading
   */
  async function pinToBottom() {
    if (isAtBottom.value) {
      // Force immediate scroll without animation when messages change
      if (scrollContainer.value) {
        await nextTick()
        scrollContainer.value.scrollTop =
          scrollContainer.value.scrollHeight
      }
    }
  }

  // Lifecycle hooks
  onMounted(() => {
    if (scrollContainer.value) {
      // Add scroll event listener to track position
      scrollContainer.value.addEventListener(
        'scroll',
        checkScrollPosition
      )
      nextTick(() => {
        scrollToBottom(true) // Initial scroll to bottom
        textareaRef.value?.focus() // Focus the input
      })
    }
  })

  // Cleanup scroll event listener
  onUnmounted(() => {
    if (scrollContainer.value) {
      scrollContainer.value.removeEventListener(
        'scroll',
        checkScrollPosition
      )
    }
  })

  // Check scroll position after any updates
  onUpdated(() => {
    checkScrollPosition()
  })

  // Expose necessary functions and state
  return {
    isAtBottom,
    showScrollButton,
    scrollToBottom,
    textareaRef,
    pinToBottom,
  }
}

总结

我认为 useTemplateRef 是近期 Vue 中最有趣的新特性之一,值得大家尝试。

它让处理 DOM 元素变得更加优雅,为我们提供了更高的灵活性和更佳的开发体验。

您可在此查阅文档获取更多信息,同时了解其支持的高级类型系统

若您喜欢本文并希望及时获取后续内容(以及更多Vue实用技巧),欢迎订阅我每周发布的Vue与Nuxt主题通讯