Vue3 模板引用 useTemplateRef 详解
核心概念理解
什么是模板引用?
模板引用(Template Ref)是 Vue 提供的一种直接访问 DOM 元素或组件实例的方式。通过 useTemplateRef
,我们可以在 Composition API 中获取模板中的元素引用。
为什么需要模板引用?
- 直接操作 DOM 元素(如聚焦、动画等)
- 调用子组件的方法
- 获取元素的尺寸和位置信息
- 集成第三方库(如图表、地图等)
基础用法
1. 基本模板引用
vue
<template>
<div class="template-ref-demo">
<h2>基础模板引用</h2>
<!-- 绑定 ref 到元素 -->
<input
ref="inputRef"
v-model="inputValue"
placeholder="输入文字试试"
class="form-input"
>
<div class="button-group">
<button @click="focusInput">聚焦输入框</button>
<button @click="selectInputText">选中文字</button>
<button @click="clearInput">清空输入</button>
</div>
<!-- 绑定 ref 到元素 -->
<div
ref="boxRef"
class="color-box"
:style="{ backgroundColor: boxColor }"
>
点击我改变颜色
</div>
<div class="box-controls">
<button @click="changeBoxColor">改变颜色</button>
<button @click="getBoxInfo">获取盒子信息</button>
</div>
<div class="info-display">
<p>输入框值: {{ inputValue }}</p>
<p>盒子颜色: {{ boxColor }}</p>
<p v-if="boxInfo">盒子信息: {{ boxInfo }}</p>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef } from 'vue'
const inputValue = ref('Hello Vue3!')
const boxColor = ref('#42b983')
const boxInfo = ref('')
// 获取模板引用
const inputRef = useTemplateRef('inputRef')
const boxRef = useTemplateRef('boxRef')
// 操作输入框
const focusInput = () => {
if (inputRef.value) {
inputRef.value.focus()
}
}
const selectInputText = () => {
if (inputRef.value) {
inputRef.value.select()
}
}
const clearInput = () => {
inputValue.value = ''
if (inputRef.value) {
inputRef.value.focus()
}
}
// 操作盒子
const changeBoxColor = () => {
const colors = ['#42b983', '#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4']
const randomColor = colors[Math.floor(Math.random() * colors.length)]
boxColor.value = randomColor
}
const getBoxInfo = () => {
if (boxRef.value) {
const rect = boxRef.value.getBoundingClientRect()
boxInfo.value = `宽: ${rect.width.toFixed(1)}px, 高: ${rect.height.toFixed(1)}px, X: ${rect.x}, Y: ${rect.y}`
}
}
</script>
<style>
.template-ref-demo {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin-bottom: 20px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.button-group, .box-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
button {
padding: 10px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #0056b3;
}
.color-box {
width: 200px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
border-radius: 8px;
margin: 20px 0;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
}
.color-box:hover {
transform: scale(1.05);
}
.info-display {
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
margin-top: 20px;
}
.info-display p {
margin: 8px 0;
}
</style>
与传统 ref 的对比
1. 传统方式 vs useTemplateRef
vue
<template>
<div class="comparison-demo">
<h2>传统 ref vs useTemplateRef 对比</h2>
<!-- 传统方式 -->
<div class="demo-section">
<h3>传统方式 (ref)</h3>
<input
ref="traditionalRef"
v-model="traditionalValue"
placeholder="传统方式输入"
class="form-input"
>
<div class="button-group">
<button @click="useTraditionalRef">使用传统 ref</button>
</div>
</div>
<!-- useTemplateRef 方式 -->
<div class="demo-section">
<h3>useTemplateRef 方式</h3>
<input
ref="templateRef"
v-model="templateValue"
placeholder="useTemplateRef 方式输入"
class="form-input"
>
<div class="button-group">
<button @click="useTemplateRefMethod">使用 useTemplateRef</button>
</div>
</div>
<!-- 循环中的引用 -->
<div class="demo-section">
<h3>循环中的引用</h3>
<div class="item-list">
<div
v-for="item in items"
:key="item.id"
:ref="`item-${item.id}`"
class="list-item"
@click="highlightItem(item.id)"
>
{{ item.name }}
</div>
</div>
<div class="button-group">
<button @click="getAllItems">获取所有项目</button>
<button @click="clearHighlights">清除高亮</button>
</div>
</div>
<div class="result-section">
<h3>结果展示</h3>
<p>传统方式值: {{ traditionalValue }}</p>
<p>useTemplateRef 方式值: {{ templateValue }}</p>
<p v-if="selectedItem">选中项目: {{ selectedItem }}</p>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'
// 传统方式
const traditionalValue = ref('传统方式')
const traditionalRef = ref(null)
// useTemplateRef 方式
const templateValue = ref('useTemplateRef 方式')
const templateRef = useTemplateRef('templateRef')
// 循环数据
const items = ref([
{ id: 1, name: '项目 1' },
{ id: 2, name: '项目 2' },
{ id: 3, name: '项目 3' },
{ id: 4, name: '项目 4' }
])
const selectedItem = ref('')
// 传统方式操作
const useTraditionalRef = () => {
if (traditionalRef.value) {
traditionalRef.value.style.backgroundColor = '#d4edda'
traditionalRef.value.focus()
setTimeout(() => {
if (traditionalRef.value) {
traditionalRef.value.style.backgroundColor = ''
}
}, 1000)
}
}
// useTemplateRef 方式操作
const useTemplateRefMethod = () => {
if (templateRef.value) {
templateRef.value.style.backgroundColor = '#cce7ff'
templateRef.value.focus()
setTimeout(() => {
if (templateRef.value) {
templateRef.value.style.backgroundColor = ''
}
}, 1000)
}
}
// 循环中的引用操作
const highlightItem = (itemId) => {
// 获取特定项目的引用
const itemRef = useTemplateRef(`item-${itemId}`)
if (itemRef.value) {
itemRef.value.style.backgroundColor = '#fff3cd'
selectedItem.value = items.value.find(item => item.id === itemId)?.name
}
}
const getAllItems = () => {
console.log('获取所有项目引用')
items.value.forEach(item => {
const itemRef = useTemplateRef(`item-${item.id}`)
if (itemRef.value) {
console.log(`项目 ${item.id}:`, itemRef.value.textContent)
}
})
}
const clearHighlights = () => {
items.value.forEach(item => {
const itemRef = useTemplateRef(`item-${item.id}`)
if (itemRef.value) {
itemRef.value.style.backgroundColor = ''
}
})
selectedItem.value = ''
}
// 组件挂载后演示
onMounted(() => {
console.log('组件已挂载')
})
</script>
<style>
.comparison-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.demo-section h3 {
margin-top: 0;
color: #495057;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin: 10px 0;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.item-list {
margin: 15px 0;
}
.list-item {
padding: 12px;
margin: 8px 0;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.list-item:hover {
background-color: #e9ecef;
transform: translateX(5px);
}
.result-section {
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.result-section h3 {
margin-top: 0;
color: #495057;
}
</style>
实际应用场景
1. 表单操作和验证
vue
<template>
<div class="form-ref-demo">
<h2>表单操作和验证</h2>
<form @submit.prevent="submitForm" class="validation-form">
<!-- 姓名输入 -->
<div class="form-group">
<label for="name">姓名 *</label>
<input
id="name"
ref="nameRef"
v-model="formData.name"
type="text"
placeholder="请输入姓名"
:class="{ error: errors.name }"
@blur="validateName"
>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<!-- 邮箱输入 -->
<div class="form-group">
<label for="email">邮箱 *</label>
<input
id="email"
ref="emailRef"
v-model="formData.email"
type="email"
placeholder="请输入邮箱"
:class="{ error: errors.email }"
@blur="validateEmail"
>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<!-- 电话输入 -->
<div class="form-group">
<label for="phone">电话</label>
<input
id="phone"
ref="phoneRef"
v-model="formData.phone"
type="tel"
placeholder="请输入电话号码"
:class="{ error: errors.phone }"
@blur="validatePhone"
>
<span v-if="errors.phone" class="error-message">{{ errors.phone }}</span>
</div>
<!-- 文本域 -->
<div class="form-group">
<label for="message">留言</label>
<textarea
id="message"
ref="messageRef"
v-model="formData.message"
placeholder="请输入留言内容"
rows="4"
class="form-textarea"
></textarea>
<div class="char-count">{{ formData.message.length }}/200</div>
</div>
<!-- 表单控件 -->
<div class="form-controls">
<button type="submit" class="submit-btn">提交表单</button>
<button @click="focusFirstError" type="button" class="focus-btn">定位错误</button>
<button @click="resetForm" type="button" class="reset-btn">重置表单</button>
</div>
</form>
<!-- 提交结果 -->
<div v-if="submittedData" class="result-section">
<h3>提交成功!</h3>
<pre>{{ JSON.stringify(submittedData, null, 2) }}</pre>
<button @click="submittedData = null" class="reset-btn">重新填写</button>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef, reactive } from 'vue'
// 表单数据
const formData = reactive({
name: '',
email: '',
phone: '',
message: ''
})
// 错误信息
const errors = reactive({
name: '',
email: '',
phone: ''
})
const submittedData = ref(null)
// 获取模板引用
const nameRef = useTemplateRef('nameRef')
const emailRef = useTemplateRef('emailRef')
const phoneRef = useTemplateRef('phoneRef')
const messageRef = useTemplateRef('messageRef')
// 验证函数
const validateName = () => {
if (!formData.name.trim()) {
errors.name = '姓名不能为空'
} else if (formData.name.length < 2) {
errors.name = '姓名至少2个字符'
} else {
errors.name = ''
}
}
const validateEmail = () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formData.email.trim()) {
errors.email = '邮箱不能为空'
} else if (!emailRegex.test(formData.email)) {
errors.email = '请输入有效的邮箱地址'
} else {
errors.email = ''
}
}
const validatePhone = () => {
if (formData.phone && !/^1[3-9]\d{9}$/.test(formData.phone)) {
errors.phone = '请输入有效的手机号码'
} else {
errors.phone = ''
}
}
// 聚焦到第一个错误字段
const focusFirstError = () => {
if (errors.name && nameRef.value) {
nameRef.value.focus()
nameRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else if (errors.email && emailRef.value) {
emailRef.value.focus()
emailRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else if (errors.phone && phoneRef.value) {
phoneRef.value.focus()
phoneRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else {
alert('没有发现错误字段')
}
}
// 重置表单
const resetForm = () => {
Object.assign(formData, {
name: '',
email: '',
phone: '',
message: ''
})
Object.keys(errors).forEach(key => {
errors[key] = ''
})
// 聚焦到第一个输入框
setTimeout(() => {
if (nameRef.value) {
nameRef.value.focus()
}
}, 100)
}
// 提交表单
const submitForm = () => {
// 验证所有字段
validateName()
validateEmail()
validatePhone()
// 检查是否有错误
const hasErrors = Object.values(errors).some(error => error)
const hasEmptyRequired = !formData.name.trim() || !formData.email.trim()
if (hasErrors || hasEmptyRequired) {
focusFirstError()
return
}
// 提交成功
submittedData.value = { ...formData }
console.log('表单提交:', formData)
}
</script>
<style>
.form-ref-demo {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.validation-form {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #495057;
}
.form-group input, .form-textarea {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.2s ease;
}
.form-group input:focus, .form-textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-group input.error {
border-color: #dc3545;
}
.form-textarea {
resize: vertical;
font-family: inherit;
}
.char-count {
text-align: right;
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
.error-message {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
display: block;
}
.form-controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-top: 30px;
}
.submit-btn, .focus-btn, .reset-btn {
padding: 12px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-btn {
background-color: #28a745;
color: white;
flex: 1;
}
.submit-btn:hover {
background-color: #218838;
}
.focus-btn {
background-color: #ffc107;
color: #212529;
}
.focus-btn:hover {
background-color: #e0a800;
}
.reset-btn {
background-color: #6c757d;
color: white;
}
.reset-btn:hover {
background-color: #5a6268;
}
.result-section {
padding: 20px;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 8px;
}
.result-section h3 {
color: #155724;
margin-top: 0;
}
.result-section pre {
background-color: #fff;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
margin: 15px 0;
font-size: 14px;
}
</style>
2. 动画和交互效果
vue
<template>
<div class="animation-demo">
<h2>动画和交互效果</h2>
<!-- 拖拽区域 -->
<div class="drag-section">
<h3>拖拽功能</h3>
<div
ref="dragAreaRef"
class="drag-area"
@mousedown="startDrag"
@touchstart="startDrag"
>
<div
ref="draggableBoxRef"
class="draggable-box"
:style="{
left: boxPosition.x + 'px',
top: boxPosition.y + 'px',
cursor: isDragging ? 'grabbing' : 'grab'
}"
>
拖拽我
</div>
</div>
<div class="position-info">
位置: X: {{ boxPosition.x }}, Y: {{ boxPosition.y }}
</div>
</div>
<!-- 滚动同步 -->
<div class="scroll-section">
<h3>滚动同步</h3>
<div class="scroll-container">
<div
ref="scrollContainer1Ref"
class="scroll-box"
@scroll="syncScroll"
>
<div class="scroll-content" style="height: 500px;">
<div
v-for="item in 50"
:key="item"
class="scroll-item"
>
内容项目 {{ item }}
</div>
</div>
</div>
<div
ref="scrollContainer2Ref"
class="scroll-box"
@scroll="syncScroll"
>
<div class="scroll-content" style="height: 500px;">
<div
v-for="item in 50"
:key="item"
class="scroll-item"
>
同步项目 {{ item }}
</div>
</div>
</div>
</div>
</div>
<!-- 焦点管理 -->
<div class="focus-section">
<h3>焦点管理</h3>
<div class="focus-demo">
<input
ref="focusInput1Ref"
v-model="focusValues.input1"
placeholder="输入框 1"
class="focus-input"
@keydown="handleKeyNavigation"
>
<input
ref="focusInput2Ref"
v-model="focusValues.input2"
placeholder="输入框 2"
class="focus-input"
@keydown="handleKeyNavigation"
>
<input
ref="focusInput3Ref"
v-model="focusValues.input3"
placeholder="输入框 3"
class="focus-input"
@keydown="handleKeyNavigation"
>
<textarea
ref="focusTextareaRef"
v-model="focusValues.textarea"
placeholder="文本域"
rows="3"
class="focus-textarea"
@keydown="handleKeyNavigation"
></textarea>
</div>
<div class="focus-controls">
<button @click="focusNext">下一个</button>
<button @click="focusPrevious">上一个</button>
<button @click="focusFirst">第一个</button>
<button @click="focusLast">最后一个</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef, reactive } from 'vue'
// 拖拽相关
const boxPosition = ref({ x: 50, y: 50 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const dragAreaRef = useTemplateRef('dragAreaRef')
const draggableBoxRef = useTemplateRef('draggableBoxRef')
// 滚动相关
const scrollContainer1Ref = useTemplateRef('scrollContainer1Ref')
const scrollContainer2Ref = useTemplateRef('scrollContainer2Ref')
// 焦点相关
const focusValues = reactive({
input1: '',
input2: '',
input3: '',
textarea: ''
})
const focusInput1Ref = useTemplateRef('focusInput1Ref')
const focusInput2Ref = useTemplateRef('focusInput2Ref')
const focusInput3Ref = useTemplateRef('focusInput3Ref')
const focusTextareaRef = useTemplateRef('focusTextareaRef')
const focusRefs = [
focusInput1Ref,
focusInput2Ref,
focusInput3Ref,
focusTextareaRef
]
// 拖拽功能
const startDrag = (event) => {
isDragging.value = true
const clientX = event.clientX || event.touches[0].clientX
const clientY = event.clientY || event.touches[0].clientY
dragStart.value = {
x: clientX - boxPosition.value.x,
y: clientY - boxPosition.value.y
}
document.addEventListener('mousemove', drag)
document.addEventListener('touchmove', drag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchend', stopDrag)
}
const drag = (event) => {
if (!isDragging.value || !dragAreaRef.value) return
const clientX = event.clientX || event.touches[0].clientX
const clientY = event.clientY || event.touches[0].clientY
const rect = dragAreaRef.value.getBoundingClientRect()
const maxX = rect.width - 100
const maxY = rect.height - 100
boxPosition.value = {
x: Math.max(0, Math.min(maxX, clientX - dragStart.value.x - rect.left)),
y: Math.max(0, Math.min(maxY, clientY - dragStart.value.y - rect.top))
}
}
const stopDrag = () => {
isDragging.value = false
document.removeEventListener('mousemove', drag)
document.removeEventListener('touchmove', drag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchend', stopDrag)
}
// 滚动同步
const syncScroll = (event) => {
const scrollTop = event.target.scrollTop
const scrollLeft = event.target.scrollLeft
if (event.target === scrollContainer1Ref.value && scrollContainer2Ref.value) {
scrollContainer2Ref.value.scrollTop = scrollTop
scrollContainer2Ref.value.scrollLeft = scrollLeft
} else if (event.target === scrollContainer2Ref.value && scrollContainer1Ref.value) {
scrollContainer1Ref.value.scrollTop = scrollTop
scrollContainer1Ref.value.scrollLeft = scrollLeft
}
}
// 焦点管理
const handleKeyNavigation = (event) => {
if (event.key === 'Tab') {
// Tab 键已经在浏览器中处理了焦点切换
return
}
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault()
focusNext()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusPrevious()
}
}
const focusNext = () => {
const currentIndex = focusRefs.findIndex(ref =>
ref.value === document.activeElement
)
const nextIndex = (currentIndex + 1) % focusRefs.length
if (focusRefs[nextIndex].value) {
focusRefs[nextIndex].value.focus()
}
}
const focusPrevious = () => {
const currentIndex = focusRefs.findIndex(ref =>
ref.value === document.activeElement
)
const prevIndex = (currentIndex - 1 + focusRefs.length) % focusRefs.length
if (focusRefs[prevIndex].value) {
focusRefs[prevIndex].value.focus()
}
}
const focusFirst = () => {
if (focusRefs[0].value) {
focusRefs[0].value.focus()
}
}
const focusLast = () => {
const lastIndex = focusRefs.length - 1
if (focusRefs[lastIndex].value) {
focusRefs[lastIndex].value.focus()
}
}
</script>
<style>
.animation-demo {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.drag-section, .scroll-section, .focus-section {
margin-bottom: 40px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.drag-section h3, .scroll-section h3, .focus-section h3 {
margin-top: 0;
color: #495057;
}
.drag-area {
width: 100%;
height: 300px;
background-color: #e3f2fd;
border: 2px dashed #2196f3;
border-radius: 8px;
position: relative;
overflow: hidden;
margin-bottom: 15px;
}
.draggable-box {
position: absolute;
width: 100px;
height: 100px;
background-color: #ff6b6b;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
user-select: none;
transition: none;
}
.position-info {
text-align: center;
font-weight: bold;
color: #007bff;
}
.scroll-container {
display: flex;
gap: 20px;
margin-bottom: 15px;
}
.scroll-box {
flex: 1;
height: 300px;
overflow: auto;
border: 1px solid #ced4da;
border-radius: 4px;
background-color: #fff;
}
.scroll-content {
padding: 10px;
}
.scroll-item {
padding: 10px;
margin: 5px 0;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.focus-demo {
margin-bottom: 20px;
}
.focus-input, .focus-textarea {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin-bottom: 10px;
}
.focus-input:focus, .focus-textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.focus-textarea {
resize: vertical;
font-family: inherit;
}
.focus-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.focus-controls button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
.focus-controls button:hover {
background-color: #0056b3;
}
</style>
与子组件的交互
1. 调用子组件方法
vue
<!-- ParentComponent.vue -->
<template>
<div class="parent-demo">
<h2>与子组件交互</h2>
<!-- 子组件引用 -->
<div class="component-section">
<h3>图表组件</h3>
<ChartComponent
ref="chartRef"
:data="chartData"
width="400"
height="300"
/>
<div class="chart-controls">
<button @click="updateChartData">更新数据</button>
<button @click="animateChart">动画效果</button>
<button @click="resetChart">重置图表</button>
<button @click="exportChart">导出图表</button>
</div>
</div>
<!-- 表格组件 -->
<div class="component-section">
<h3>表格组件</h3>
<TableComponent
ref="tableRef"
:data="tableData"
:columns="tableColumns"
/>
<div class="table-controls">
<button @click="addTableRow">添加行</button>
<button @click="deleteSelectedRows">删除选中行</button>
<button @click="selectAllRows">全选</button>
<button @click="clearSelection">清空选择</button>
</div>
</div>
<!-- 状态显示 -->
<div class="status-section">
<h3>组件状态</h3>
<div class="status-grid">
<div class="status-item">
<span>图表状态:</span>
<span>{{ chartStatus }}</span>
</div>
<div class="status-item">
<span>表格状态:</span>
<span>{{ tableStatus }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef, reactive } from 'vue'
import ChartComponent from './ChartComponent.vue'
import TableComponent from './TableComponent.vue'
// 图表相关
const chartRef = useTemplateRef('chartRef')
const chartStatus = ref('就绪')
const chartData = ref([
{ name: 'Jan', value: 400 },
{ name: 'Feb', value: 300 },
{ name: 'Mar', value: 200 },
{ name: 'Apr', value: 278 },
{ name: 'May', value: 189 }
])
// 表格相关
const tableRef = useTemplateRef('tableRef')
const tableStatus = ref('就绪')
const tableData = ref([
{ id: 1, name: '张三', age: 25, city: '北京' },
{ id: 2, name: '李四', age: 30, city: '上海' },
{ id: 3, name: '王五', age: 28, city: '广州' }
])
const tableColumns = ref([
{ key: 'id', title: 'ID' },
{ key: 'name', title: '姓名' },
{ key: 'age', title: '年龄' },
{ key: 'city', title: '城市' }
])
// 图表操作
const updateChartData = () => {
if (chartRef.value) {
const newData = chartData.value.map(item => ({
...item,
value: Math.floor(Math.random() * 500) + 100
}))
chartData.value = newData
chartStatus.value = '数据已更新'
setTimeout(() => {
chartStatus.value = '就绪'
}, 2000)
}
}
const animateChart = () => {
if (chartRef.value && chartRef.value.animate) {
chartRef.value.animate()
chartStatus.value = '动画播放中'
}
}
const resetChart = () => {
if (chartRef.value && chartRef.value.reset) {
chartRef.value.reset()
chartStatus.value = '图表已重置'
setTimeout(() => {
chartStatus.value = '就绪'
}, 2000)
}
}
const exportChart = () => {
if (chartRef.value && chartRef.value.export) {
const result = chartRef.value.export()
chartStatus.value = `导出成功: ${result}`
setTimeout(() => {
chartStatus.value = '就绪'
}, 2000)
}
}
// 表格操作
const addTableRow = () => {
if (tableRef.value) {
const newId = Math.max(...tableData.value.map(item => item.id)) + 1
tableData.value.push({
id: newId,
name: `用户${newId}`,
age: Math.floor(Math.random() * 30) + 20,
city: ['北京', '上海', '广州', '深圳', '杭州'][Math.floor(Math.random() * 5)]
})
tableStatus.value = '已添加新行'
setTimeout(() => {
tableStatus.value = '就绪'
}, 2000)
}
}
const deleteSelectedRows = () => {
if (tableRef.value && tableRef.value.getSelectedRows) {
const selected = tableRef.value.getSelectedRows()
if (selected.length > 0) {
tableData.value = tableData.value.filter(
item => !selected.includes(item.id)
)
tableStatus.value = `已删除 ${selected.length} 行`
setTimeout(() => {
tableStatus.value = '就绪'
}, 2000)
} else {
tableStatus.value = '请选择要删除的行'
}
}
}
const selectAllRows = () => {
if (tableRef.value && tableRef.value.selectAll) {
tableRef.value.selectAll()
tableStatus.value = '已全选'
}
}
const clearSelection = () => {
if (tableRef.value && tableRef.value.clearSelection) {
tableRef.value.clearSelection()
tableStatus.value = '已清空选择'
}
}
</script>
<style>
.parent-demo {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.component-section {
margin-bottom: 30px;
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.component-section h3 {
margin-top: 0;
color: #495057;
}
.chart-controls, .table-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 15px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #0056b3;
}
.status-section {
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.status-section h3 {
margin-top: 0;
color: #495057;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #fff;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.status-item span:first-child {
font-weight: bold;
color: #495057;
}
</style>
vue
<!-- ChartComponent.vue -->
<template>
<div class="chart-component">
<svg
:width="width"
:height="height"
class="chart-svg"
>
<!-- 简单的柱状图 -->
<rect
v-for="(item, index) in data"
:key="index"
:x="index * (width / data.length) + 20"
:y="height - (item.value / maxValue) * (height - 50) - 20"
:width="(width / data.length) - 40"
:height="(item.value / maxValue) * (height - 50)"
:fill="`hsl(${index * 60}, 70%, 50%)`"
class="chart-bar"
/>
<!-- 标签 -->
<text
v-for="(item, index) in data"
:key="'label-' + index"
:x="index * (width / data.length) + (width / data.length / 2)"
:y="height - 5"
text-anchor="middle"
font-size="12"
fill="#333"
>
{{ item.name }}
</text>
</svg>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true
},
width: {
type: [Number, String],
default: 400
},
height: {
type: [Number, String],
default: 300
}
})
const maxValue = computed(() => {
return Math.max(...props.data.map(item => item.value), 1)
})
// 子组件暴露的方法
defineExpose({
animate() {
console.log('图表动画播放')
// 这里可以实现动画逻辑
},
reset() {
console.log('图表重置')
// 这里可以实现重置逻辑
},
export() {
console.log('图表导出')
return 'chart-export.png'
}
})
</script>
<style>
.chart-component {
display: inline-block;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #fff;
}
.chart-svg {
display: block;
}
.chart-bar {
transition: all 0.3s ease;
}
.chart-bar:hover {
opacity: 0.8;
transform: scale(1.05);
}
</style>
vue
<!-- TableComponent.vue -->
<template>
<div class="table-component">
<table class="data-table">
<thead>
<tr>
<th>
<input
type="checkbox"
:checked="isAllSelected"
@change="toggleAllSelection"
>
</th>
<th
v-for="column in columns"
:key="column.key"
>
{{ column.title }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in data"
:key="row.id"
:class="{ selected: selectedRows.includes(row.id) }"
@click="toggleRowSelection(row.id)"
>
<td>
<input
type="checkbox"
:checked="selectedRows.includes(row.id)"
@click.stop="toggleRowSelection(row.id)"
>
</td>
<td v-for="column in columns" :key="column.key">
{{ row[column.key] }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
data: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
}
})
const selectedRows = ref([])
const isAllSelected = computed(() => {
return selectedRows.value.length > 0 &&
selectedRows.value.length === props.data.length
})
const toggleRowSelection = (rowId) => {
const index = selectedRows.value.indexOf(rowId)
if (index > -1) {
selectedRows.value.splice(index, 1)
} else {
selectedRows.value.push(rowId)
}
}
const toggleAllSelection = () => {
if (isAllSelected.value) {
selectedRows.value = []
} else {
selectedRows.value = props.data.map(item => item.id)
}
}
// 子组件暴露的方法
defineExpose({
getSelectedRows() {
return [...selectedRows.value]
},
selectAll() {
selectedRows.value = props.data.map(item => item.id)
},
clearSelection() {
selectedRows.value = []
}
})
</script>
<style>
.table-component {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
background-color: #fff;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border: 1px solid #dee2e6;
}
.data-table th {
background-color: #f8f9fa;
font-weight: bold;
}
.data-table tbody tr:hover {
background-color: #f8f9fa;
}
.data-table tbody tr.selected {
background-color: #d1ecf1;
}
.data-table input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
</style>
注意事项和最佳实践
1. 生命周期和访问时机
vue
<template>
<div class="lifecycle-demo">
<h2>生命周期和访问时机</h2>
<div class="demo-section">
<h3>访问时机测试</h3>
<input
ref="testRef"
v-model="testValue"
placeholder="测试输入框"
class="form-input"
>
<div class="button-group">
<button @click="testImmediateAccess">立即访问</button>
<button @click="testNextTickAccess">nextTick 访问</button>
<button @click="testMountedAccess">挂载后访问</button>
<button @click="testUpdatedAccess">更新后访问</button>
</div>
<div class="log-section">
<h4>访问日志:</h4>
<ul class="log-list">
<li v-for="(log, index) in accessLogs" :key="index">
{{ log }}
</li>
</ul>
</div>
</div>
<!-- 条件渲染测试 -->
<div class="demo-section">
<h3>条件渲染测试</h3>
<label class="checkbox-label">
<input
v-model="showElement"
type="checkbox"
>
显示元素
</label>
<div v-if="showElement" class="conditional-element">
<input
ref="conditionalRef"
v-model="conditionalValue"
placeholder="条件渲染的输入框"
class="form-input"
>
<button @click="accessConditionalRef">访问条件引用</button>
</div>
<p>条件引用状态: {{ conditionalRefStatus }}</p>
</div>
<!-- 循环引用测试 -->
<div class="demo-section">
<h3>循环引用测试</h3>
<div class="list-controls">
<button @click="addItem">添加项目</button>
<button @click="removeItem">删除项目</button>
<button @click="accessAllRefs">访问所有引用</button>
</div>
<div class="item-list">
<div
v-for="item in listItems"
:key="item.id"
class="list-item"
>
<input
:ref="`item-${item.id}`"
v-model="item.value"
:placeholder="`项目 ${item.id}`"
class="form-input small"
>
<span>项目 {{ item.id }}</span>
</div>
</div>
<p>列表项目数: {{ listItems.length }}</p>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef, onMounted, onUpdated, nextTick } from 'vue'
// 基础测试
const testValue = ref('测试值')
const testRef = useTemplateRef('testRef')
const accessLogs = ref([])
const listItems = ref([
{ id: 1, value: '项目1' },
{ id: 2, value: '项目2' }
])
// 条件渲染
const showElement = ref(true)
const conditionalValue = ref('条件值')
const conditionalRef = useTemplateRef('conditionalRef')
const conditionalRefStatus = ref('未访问')
// 立即访问(可能为 null)
const testImmediateAccess = () => {
const result = testRef.value ? '成功' : '失败(null)'
accessLogs.value.push(`立即访问: ${result} - ${new Date().toLocaleTimeString()}`)
console.log('立即访问:', testRef.value)
}
// nextTick 访问
const testNextTickAccess = () => {
nextTick(() => {
const result = testRef.value ? '成功' : '失败(null)'
accessLogs.value.push(`nextTick 访问: ${result} - ${new Date().toLocaleTimeString()}`)
console.log('nextTick 访问:', testRef.value)
})
}
// 挂载后访问
const testMountedAccess = () => {
const result = testRef.value ? '成功' : '失败(null)'
accessLogs.value.push(`挂载后访问: ${result} - ${new Date().toLocaleTimeString()}`)
console.log('挂载后访问:', testRef.value)
}
// 更新后访问
const testUpdatedAccess = () => {
const result = testRef.value ? '成功' : '失败(null)'
accessLogs.value.push(`更新后访问: ${result} - ${new Date().toLocaleTimeString()}`)
console.log('更新后访问:', testRef.value)
}
// 条件引用访问
const accessConditionalRef = () => {
if (showElement.value && conditionalRef.value) {
conditionalRef.value.focus()
conditionalRefStatus.value = `成功访问 - 值: ${conditionalValue.value}`
} else {
conditionalRefStatus.value = '无法访问(元素未显示或引用为空)'
}
}
// 循环引用操作
const addItem = () => {
const newId = listItems.value.length > 0
? Math.max(...listItems.value.map(item => item.id)) + 1
: 1
listItems.value.push({ id: newId, value: `项目${newId}` })
}
const removeItem = () => {
if (listItems.value.length > 0) {
listItems.value.pop()
}
}
const accessAllRefs = () => {
console.log('访问所有循环引用:')
listItems.value.forEach(item => {
const itemRef = useTemplateRef(`item-${item.id}`)
if (itemRef.value) {
console.log(`项目 ${item.id}:`, itemRef.value.value)
} else {
console.log(`项目 ${item.id}: 引用为空`)
}
})
}
// 生命周期钩子
onMounted(() => {
accessLogs.value.push(`组件挂载完成 - ${new Date().toLocaleTimeString()}`)
console.log('组件挂载完成,引用应该可用:', testRef.value)
})
onUpdated(() => {
accessLogs.value.push(`组件更新完成 - ${new Date().toLocaleTimeString()}`)
console.log('组件更新完成')
})
</script>
<style>
.lifecycle-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.demo-section h3 {
margin-top: 0;
color: #495057;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin: 10px 0;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-input.small {
width: 150px;
display: inline-block;
margin-right: 10px;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 15px 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
margin: 15px 0;
cursor: pointer;
user-select: none;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
.conditional-element {
padding: 15px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 15px 0;
}
.list-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.item-list {
margin: 15px 0;
}
.list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
margin: 8px 0;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.log-section {
margin-top: 20px;
}
.log-section h4 {
margin-top: 0;
color: #495057;
}
.log-list {
max-height: 200px;
overflow-y: auto;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin: 0;
font-size: 14px;
}
.log-list li {
padding: 3px 0;
border-bottom: 1px solid #eee;
}
.log-list li:last-child {
border-bottom: none;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #0056b3;
}
</style>
2. 常见陷阱和解决方案
vue
<template>
<div class="pitfalls-demo">
<h2>常见陷阱和解决方案</h2>
<!-- 陷阱1: 异步组件引用 -->
<div class="demo-section">
<h3>陷阱1: 异步组件引用</h3>
<div class="async-demo">
<button @click="loadAsyncComponent">加载异步组件</button>
<div v-if="showAsyncComponent" class="async-container">
<AsyncComponent ref="asyncRef" />
</div>
<button
@click="callAsyncMethod"
:disabled="!showAsyncComponent"
class="call-btn"
>
调用异步方法
</button>
<p>异步状态: {{ asyncStatus }}</p>
</div>
</div>
<!-- 陷阱2: 响应式更新问题 -->
<div class="demo-section">
<h3>陷阱2: 响应式更新问题</h3>
<div class="reactivity-demo">
<input
ref="reactiveRef"
v-model="reactiveValue"
placeholder="响应式输入"
class="form-input"
>
<div class="button-group">
<button @click="changeReactiveValue">改变值</button>
<button @click="directDOMChange">直接 DOM 操作</button>
<button @click="syncReactiveValue">同步响应式值</button>
</div>
<p>响应式值: {{ reactiveValue }}</p>
<p>DOM 值: <span ref="domValueRef"></span></p>
</div>
</div>
<!-- 陷阱3: 内存泄漏防护 -->
<div class="demo-section">
<h3>陷阱3: 内存泄漏防护</h3>
<div class="memory-demo">
<div class="counter-controls">
<button @click="incrementCounter">增加计数器</button>
<button @click="decrementCounter">减少计数器</button>
</div>
<div class="counter-display">
<div
v-for="n in counter"
:key="n"
class="counter-item"
>
<input
:ref="`counter-${n}`"
:value="`计数器${n}`"
class="form-input small"
>
</div>
</div>
<p>当前计数器数: {{ counter }}</p>
<button @click="cleanupRefs">清理引用</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue'
// 异步组件相关
const showAsyncComponent = ref(false)
const asyncRef = useTemplateRef('asyncRef')
const asyncStatus = ref('未加载')
const loadAsyncComponent = () => {
showAsyncComponent.value = true
asyncStatus.value = '组件加载中...'
// 模拟异步加载
setTimeout(() => {
asyncStatus.value = '组件已加载'
}, 1000)
}
const callAsyncMethod = () => {
// 安全调用异步组件方法
if (asyncRef.value && typeof asyncRef.value.someMethod === 'function') {
const result = asyncRef.value.someMethod()
asyncStatus.value = `方法调用结果: ${result}`
} else {
asyncStatus.value = '方法不可用'
}
}
// 响应式更新相关
const reactiveValue = ref('初始值')
const reactiveRef = useTemplateRef('reactiveRef')
const domValueRef = useTemplateRef('domValueRef')
const changeReactiveValue = () => {
reactiveValue.value = `更新值 ${Date.now()}`
}
const directDOMChange = () => {
if (reactiveRef.value) {
reactiveRef.value.value = `DOM 直接修改 ${Date.now()}`
// 注意:这不会更新 reactiveValue
}
}
const syncReactiveValue = () => {
if (reactiveRef.value) {
reactiveValue.value = reactiveRef.value.value
}
}
onMounted(() => {
// 更新 DOM 显示
if (domValueRef.value) {
domValueRef.value.textContent = reactiveValue.value
}
})
// 监听响应式值变化
import { watch } from 'vue'
watch(reactiveValue, (newVal) => {
if (domValueRef.value) {
domValueRef.value.textContent = newVal
}
})
// 内存泄漏防护
const counter = ref(3)
const incrementCounter = () => {
counter.value++
}
const decrementCounter = () => {
if (counter.value > 0) {
counter.value--
}
}
const cleanupRefs = () => {
console.log('清理引用')
// Vue 会自动管理引用的清理
// 这里只是演示概念
}
// 组件卸载时的清理
onUnmounted(() => {
console.log('组件卸载,清理资源')
// 清理定时器、事件监听器等
})
</script>
<style>
.pitfalls-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.demo-section h3 {
margin-top: 0;
color: #495057;
}
.async-demo, .reactivity-demo, .memory-demo {
margin: 15px 0;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
margin: 10px 0;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-input.small {
width: 200px;
display: inline-block;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 15px 0;
}
.call-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
margin: 5px;
}
.call-btn:hover:not(:disabled) {
background-color: #218838;
}
.call-btn:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.counter-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.counter-display {
margin: 15px 0;
max-height: 200px;
overflow-y: auto;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.counter-item {
margin: 8px 0;
padding: 8px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
margin: 5px;
}
button:hover {
background-color: #0056b3;
}
</style>
vue
<!-- AsyncComponent.vue -->
<template>
<div class="async-component">
<h4>异步加载的组件</h4>
<p>这是一个动态加载的组件</p>
<div class="async-content">
加载时间: {{ loadTime }}
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const loadTime = ref(new Date().toLocaleTimeString())
// 暴露方法给父组件调用
defineExpose({
someMethod() {
return `异步方法调用成功 - ${new Date().toLocaleTimeString()}`
},
getData() {
return {
message: '来自异步组件的数据',
timestamp: Date.now()
}
}
})
</script>
<style>
.async-component {
padding: 20px;
background-color: #d1ecf1;
border: 1px solid #bee5eb;
border-radius: 8px;
margin: 15px 0;
}
.async-component h4 {
margin-top: 0;
color: #0c5460;
}
.async-content {
font-style: italic;
color: #0c5460;
}
</style>
总结
useTemplateRef 的优势
- 类型安全:更好的 TypeScript 支持
- 明确性:更清晰的引用获取方式
- 一致性:与 Composition API 风格一致
使用建议
-
访问时机:
- 在
onMounted
后访问引用 - 使用
nextTick
确保 DOM 更新完成 - 条件渲染的元素要检查存在性
- 在
-
性能考虑:
- 避免频繁访问 DOM
- 及时清理事件监听器
- 合理使用防抖和节流
-
最佳实践:
- 优先使用 Vue 的响应式系统
- 只在必要时使用模板引用
- 做好错误处理和边界检查
记忆口诀:
- useTemplateRef:获取模板引用新方式
- ref="name":模板中标记引用名称
- useTemplateRef('name'):代码中获取引用
- onMounted:访问引用的最佳时机
- 谨慎使用:只在必要时操作 DOM
这样就能很好地掌握 Vue3 的模板引用功能了!