原文链接:michaelnthiessen.com/how-to-acce…
在 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>
这里主要有两个区别:
- 我们使用
ref(null)代替useTemplateRef('search') - 我们将
ref属性设置为变量本身,而非使用字符串
代码本身差异不大,但这种实现方式带来了三个挑战:
- 可读性较差
- 使用模板引用的组合器难以编写
- 类型推断效果不佳
首先,我们无法区分"常规"引用与模板引用。必须查看模板才能了解其用法,仅凭脚本块无法获取该信息。
而使用 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 可组合函数通过两种方式显著提升了类型推断能力:
- 实际引用类型自动推断
- 应用程序中定义的键支持自动补全
当在组件或可组合函数中使用 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>
我们需要完成以下几项工作:
- 在模板中设置键值并将其传递给
useTemplateRef - 使用
onMounted确保组件已渲染完毕再进行后续操作 - 同时检查值是否为
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主题通讯。