Vue 3 模板引用与$refs

1. 模板引用概述

模板引用(Template Refs)是Vue中用于直接访问DOM元素或组件实例的机制。在Vue 3中,模板引用可以通过ref函数结合模板中的ref属性来使用,它提供了一种安全、类型安全的方式来访问DOM元素和组件实例。

1.1 为什么需要模板引用

虽然Vue鼓励使用声明式编程,但在某些情况下,我们需要直接访问DOM元素或组件实例,例如:

  • 操作DOM元素,如获取元素尺寸、滚动位置等
  • 调用组件实例的方法
  • 集成第三方库,如Chart.js、D3.js等
  • 实现复杂的交互效果,如拖拽、动画等

1.2 Vue 3模板引用的特点

Vue 3的模板引用具有以下特点:

  • 组合式API优先:在组合式API中使用更灵活、更强大
  • 响应式支持:模板引用是响应式的,当引用的元素或组件发生变化时,引用会自动更新
  • 类型安全:在TypeScript环境下,支持类型推断和类型检查
  • 支持多个引用:可以在同一个组件中使用多个模板引用
  • 支持动态引用:可以根据条件动态创建模板引用

2. 模板引用的基本使用

2.1 基本语法

在组合式API中,模板引用的基本语法如下:

import { ref, onMounted } from 'vue'

export default {
  setup() {
    // 创建一个模板引用
    const inputRef = ref(null)
    
    onMounted(() => {
      // 在组件挂载后,可以访问DOM元素
      inputRef.value.focus()
    })
    
    return {
      inputRef // 将引用暴露给模板
    }
  }
}

在模板中,使用ref属性将DOM元素或组件实例绑定到模板引用:

<template>
  <input type="text" ref="inputRef" placeholder="Focus me!" />
</template>

2.2 访问DOM元素

使用模板引用可以直接访问DOM元素,并调用其方法或访问其属性:

import { ref, onMounted } from 'vue'

export default {
  setup() {
    const divRef = ref(null)
    const inputRef = ref(null)
    
    onMounted(() => {
      // 获取div元素的尺寸
      const { offsetWidth, offsetHeight } = divRef.value
      console.log(`Div尺寸: ${offsetWidth}x${offsetHeight}`)
      
      // 调用input元素的focus方法
      inputRef.value.focus()
      
      // 修改input元素的属性
      inputRef.value.placeholder = 'Focused!'
    })
    
    return {
      divRef,
      inputRef
    }
  }
}

在模板中:

<template>
  <div ref="divRef" style="width: 200px; height: 100px; background-color: #f0f0f0;">
    <p>Div Element</p>
  </div>
  <input type="text" ref="inputRef" placeholder="Focus me!" />
</template>

2.3 访问组件实例

使用模板引用也可以访问子组件的实例,并调用其方法或访问其属性:

// 子组件
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    
    const increment = () => {
      count.value++
    }
    
    return {
      count,
      increment // 暴露方法给父组件
    }
  }
}

// 父组件
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

export default {
  components: {
    ChildComponent
  },
  setup() {
    const childRef = ref(null)
    
    onMounted(() => {
      // 访问子组件的属性
      console.log('子组件count初始值:', childRef.value.count)
      
      // 调用子组件的方法
      childRef.value.increment()
      console.log('子组件count调用后:', childRef.value.count)
    })
    
    return {
      childRef
    }
  }
}

在模板中:

<template>
  <ChildComponent ref="childRef" />
</template>

3. 模板引用的高级使用

3.1 多个模板引用

可以在同一个组件中使用多个模板引用,每个引用对应不同的DOM元素或组件实例:

import { ref, onMounted } from 'vue'

export default {
  setup() {
    const headerRef = ref(null)
    const contentRef = ref(null)
    const footerRef = ref(null)
    
    onMounted(() => {
      // 访问多个DOM元素
      console.log('Header高度:', headerRef.value.offsetHeight)
      console.log('Content高度:', contentRef.value.offsetHeight)
      console.log('Footer高度:', footerRef.value.offsetHeight)
    })
    
    return {
      headerRef,
      contentRef,
      footerRef
    }
  }
}

在模板中:

<template>
  <header ref="headerRef">
    <h1>Page Header</h1>
  </header>
  <main ref="contentRef">
    <p>Page Content</p>
  </main>
  <footer ref="footerRef">
    <p>Page Footer</p>
  </footer>
</template>

3.2 动态模板引用

可以根据条件动态创建模板引用,例如在v-for循环中:

import { ref, onMounted } from 'vue'

export default {
  setup() {
    // 创建一个数组来存储多个引用
    const itemRefs = ref([])
    
    const items = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']
    
    onMounted(() => {
      // 访问第一个item的DOM元素
      itemRefs.value[0]?.style.backgroundColor = 'red'
    })
    
    const highlightItem = (index) => {
      // 重置所有item的背景色
      itemRefs.value.forEach((item) => {
        if (item) {
          item.style.backgroundColor = ''
        }
      })
      
      // 高亮指定的item
      if (itemRefs.value[index]) {
        itemRefs.value[index].style.backgroundColor = 'yellow'
      }
    }
    
    return {
      items,
      itemRefs,
      highlightItem
    }
  }
}

在模板中:

<template>
  <div>
    <div 
      v-for="(item, index) in items" 
      :key="index"
      :ref="el => itemRefs[index] = el"
      @click="highlightItem(index)"
      style="padding: 10px; margin: 5px; border: 1px solid #ccc; cursor: pointer;"
    >
      {{ item }}
    </div>
  </div>
</template>

3.3 组件卸载时的处理

当组件卸载时,模板引用会自动设置为null,这可以避免内存泄漏:

import { ref, onMounted, onUnmounted } from 'vue'

export default {
  setup() {
    const timerRef = ref(null)
    const divRef = ref(null)
    
    onMounted(() => {
      // 启动定时器,每隔1秒更新div的内容
      timerRef.value = setInterval(() => {
        if (divRef.value) {
          divRef.value.textContent = `Current time: ${new Date().toLocaleTimeString()}`
        }
      }, 1000)
    })
    
    onUnmounted(() => {
      // 清理定时器
      if (timerRef.value) {
        clearInterval(timerRef.value)
      }
    })
    
    return {
      divRef
    }
  }
}

4. 模板引用与TypeScript

在TypeScript环境下,模板引用支持类型推断和类型检查,可以提供更好的开发体验和类型安全。

4.1 基本类型支持

import { ref, onMounted } from 'vue'

export default {
  setup() {
    // 指定模板引用的类型为HTMLInputElement
    const inputRef = ref<HTMLInputElement | null>(null)
    
    onMounted(() => {
      // 类型安全的访问
      inputRef.value?.focus()
      inputRef.value?.select()
    })
    
    return {
      inputRef
    }
  }
}

4.2 组件类型支持

import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

export default {
  components: {
    ChildComponent
  },
  setup() {
    // 指定模板引用的类型为ChildComponent
    const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
    
    onMounted(() => {
      // 类型安全的访问组件属性和方法
      if (childRef.value) {
        console.log(childRef.value.count)
        childRef.value.increment()
      }
    })
    
    return {
      childRef
    }
  }
}

4.3 动态模板引用的类型支持

import { ref, onMounted } from 'vue'

export default {
  setup() {
    // 指定动态模板引用的类型为HTMLDivElement数组
    const itemRefs = ref<HTMLDivElement[]>([])
    
    const items = ['Item 1', 'Item 2', 'Item 3']
    
    onMounted(() => {
      // 类型安全的访问数组中的元素
      itemRefs.value[0]?.style.backgroundColor = 'red'
    })
    
    return {
      items,
      itemRefs
    }
  }
}

5. 模板引用的最佳实践

5.1 只在必要时使用

虽然模板引用很强大,但也不要过度使用,否则会导致以下问题:

  • 违背声明式编程原则:直接操作DOM会使代码更难维护和测试
  • 耦合度高:组件与DOM元素或子组件的实现细节耦合在一起
  • 性能问题:频繁操作DOM可能会导致性能问题

建议只在以下情况下使用模板引用:

  • 操作DOM元素,如获取元素尺寸、滚动位置等
  • 调用组件实例的方法
  • 集成第三方库
  • 实现复杂的交互效果

5.2 在onMounted钩子中访问

模板引用只有在组件挂载后才能访问,因此建议在onMounted钩子中访问模板引用:

// 好的做法:在onMounted钩子中访问
onMounted(() => {
  inputRef.value?.focus()
})

// 不好的做法:在setup函数中直接访问
// inputRef.value?.focus() // 这里inputRef.value是null

5.3 检查引用是否存在

在访问模板引用之前,总是检查引用是否存在,以避免空指针异常:

// 好的做法:检查引用是否存在
if (inputRef.value) {
  inputRef.value.focus()
}

// 好的做法:使用可选链操作符
inputRef.value?.focus()

// 不好的做法:直接访问,可能导致空指针异常
// inputRef.value.focus() // 如果inputRef.value是null,会报错

5.4 清理资源

在组件卸载前,清理使用模板引用创建的资源,如事件监听器、定时器等:

import { ref, onMounted, onUnmounted } from 'vue'

export default {
  setup() {
    const divRef = ref(null)
    let resizeObserver = null
    
    onMounted(() => {
      // 创建ResizeObserver
      resizeObserver = new ResizeObserver((entries) => {
        const entry = entries[0]
        console.log('Div尺寸变化:', entry.contentRect.width, entry.contentRect.height)
      })
      
      // 观察div元素
      if (divRef.value) {
        resizeObserver.observe(divRef.value)
      }
    })
    
    onUnmounted(() => {
      // 清理ResizeObserver
      if (resizeObserver) {
        resizeObserver.disconnect()
      }
    })
    
    return {
      divRef
    }
  }
}

5.5 结合组合式函数使用

可以将模板引用相关的逻辑封装到组合式函数中,提高代码复用性:

// useResizeObserver.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useResizeObserver() {
  const elementRef = ref(null)
  const size = ref({ width: 0, height: 0 })
  let resizeObserver = null
  
  onMounted(() => {
    resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0]
      size.value = {
        width: entry.contentRect.width,
        height: entry.contentRect.height
      }
    })
    
    if (elementRef.value) {
      resizeObserver.observe(elementRef.value)
    }
  })
  
  onUnmounted(() => {
    if (resizeObserver) {
      resizeObserver.disconnect()
    }
  })
  
  return {
    elementRef,
    size
  }
}

// 在组件中使用
import { useResizeObserver } from './useResizeObserver'

export default {
  setup() {
    const { elementRef, size } = useResizeObserver()
    
    return {
      elementRef,
      size
    }
  }
}

在模板中:

<template>
  <div ref="elementRef" style="width: 200px; height: 100px; background-color: #f0f0f0;">
    <p>Width: {{ size.width }}px</p>
    <p>Height: {{ size.height }}px</p>
  </div>
</template>

6. 模板引用与选项式API的对比

特性 组合式API 选项式API
语法 使用ref函数和模板ref属性 使用this.$refs对象
响应式 响应式,自动更新 非响应式,需要手动更新
类型安全 支持TypeScript类型推断和检查 类型安全较差
动态引用 支持,使用箭头函数 不支持,只能使用字符串
访问时机 只能在组件挂载后访问 可以在mounted钩子或之后访问
多个引用 支持,使用数组或对象 支持,使用对象

7. 模板引用的性能优化

7.1 避免在渲染函数中使用模板引用

避免在渲染函数中使用模板引用,因为这会导致每次渲染都创建新的引用:

// 好的做法:在setup函数中创建模板引用
const divRef = ref(null)

// 不好的做法:在渲染函数中创建模板引用
return () => h('div', { ref: el => divRef.value = el }, 'Content')

7.2 只在必要时更新引用

避免频繁更新模板引用,因为这会导致不必要的性能开销:

// 好的做法:只在元素变化时更新引用
:ref="el => itemRefs[index] = el"

// 不好的做法:每次渲染都更新引用
:ref="el => {
  // 复杂的逻辑,每次渲染都执行
  itemRefs[index] = el
  console.log('Ref updated')
}"

7.3 使用shallowRef

对于不需要响应式的模板引用,可以使用shallowRef来提高性能:

// 好的做法:使用shallowRef
const inputRef = shallowRef(null)

// 不好的做法:使用ref
const inputRef = ref(null)

8. 模板引用的高级应用

8.1 集成第三方库

使用模板引用集成第三方库,如Chart.js:

import { ref, onMounted, onUnmounted } from 'vue'
import Chart from 'chart.js/auto'

export default {
  setup() {
    const canvasRef = ref(null)
    let chart = null
    
    onMounted(() => {
      // 创建Chart实例
      if (canvasRef.value) {
        chart = new Chart(canvasRef.value, {
          type: 'bar',
          data: {
            labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
            datasets: [{
              label: '# of Votes',
              data: [12, 19, 3, 5, 2, 3],
              backgroundColor: [
                'rgba(255, 99, 132, 0.2)',
                'rgba(54, 162, 235, 0.2)',
                'rgba(255, 206, 86, 0.2)',
                'rgba(75, 192, 192, 0.2)',
                'rgba(153, 102, 255, 0.2)',
                'rgba(255, 159, 64, 0.2)'
              ],
              borderColor: [
                'rgba(255, 99, 132, 1)',
                'rgba(54, 162, 235, 1)',
                'rgba(255, 206, 86, 1)',
                'rgba(75, 192, 192, 1)',
                'rgba(153, 102, 255, 1)',
                'rgba(255, 159, 64, 1)'
              ],
              borderWidth: 1
            }]
          },
          options: {
            scales: {
              y: {
                beginAtZero: true
              }
            }
          }
        })
      }
    })
    
    onUnmounted(() => {
      // 销毁Chart实例
      if (chart) {
        chart.destroy()
      }
    })
    
    return {
      canvasRef
    }
  }
}

在模板中:

<template>
  <canvas ref="canvasRef" width="400" height="400"></canvas>
</template>

8.2 实现拖拽功能

使用模板引用实现拖拽功能:

import { ref, onMounted, onUnmounted } from 'vue'

export default {
  setup() {
    const dragRef = ref(null)
    let isDragging = false
    let startX = 0
    let startY = 0
    let initialX = 0
    let initialY = 0
    
    const handleMouseDown = (e) => {
      isDragging = true
      startX = e.clientX
      startY = e.clientY
      initialX = dragRef.value.offsetLeft
      initialY = dragRef.value.offsetTop
      
      document.addEventListener('mousemove', handleMouseMove)
      document.addEventListener('mouseup', handleMouseUp)
    }
    
    const handleMouseMove = (e) => {
      if (!isDragging) return
      
      const dx = e.clientX - startX
      const dy = e.clientY - startY
      
      dragRef.value.style.left = `${initialX + dx}px`
      dragRef.value.style.top = `${initialY + dy}px`
    }
    
    const handleMouseUp = () => {
      isDragging = false
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
    }
    
    onMounted(() => {
      // 设置初始样式
      if (dragRef.value) {
        dragRef.value.style.position = 'absolute'
        dragRef.value.style.left = '100px'
        dragRef.value.style.top = '100px'
      }
    })
    
    onUnmounted(() => {
      // 清理事件监听器
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
    })
    
    return {
      dragRef,
      handleMouseDown
    }
  }
}

在模板中:

<template>
  <div 
    ref="dragRef"
    @mousedown="handleMouseDown"
    style="width: 100px; height: 100px; background-color: red; cursor: move;"
  >
    Drag me!
  </div>
</template>

9. 实战练习

练习1:使用模板引用获取元素尺寸

创建一个组件,使用模板引用获取DOM元素的尺寸,并在页面上显示:

  • 创建一个div元素,设置固定的宽度和高度
  • 使用模板引用获取该div元素的实际尺寸
  • 在页面上显示元素的宽度和高度

练习2:使用模板引用调用组件方法

创建一个父组件和一个子组件,父组件使用模板引用调用子组件的方法:

  • 子组件包含一个计数器和一个increment方法
  • 父组件使用模板引用获取子组件实例
  • 父组件调用子组件的increment方法,并显示子组件的计数器值

练习3:使用模板引用集成第三方库

使用模板引用集成第三方库,如D3.js,创建一个简单的可视化图表:

  • 创建一个div元素作为D3.js的容器
  • 使用模板引用获取该div元素
  • 在onMounted钩子中创建D3.js图表
  • 在onUnmounted钩子中清理D3.js资源

10. 总结

模板引用是Vue 3中用于直接访问DOM元素或组件实例的强大机制,它具有以下特点:

  • 组合式API优先,使用灵活、强大
  • 响应式支持,自动更新
  • 类型安全,支持TypeScript类型推断和检查
  • 支持多个引用和动态引用
  • 可以集成第三方库,实现复杂的交互效果

掌握模板引用的使用方法和最佳实践,是编写可靠、高效Vue组件的关键。通过合理使用模板引用,可以在声明式编程的基础上,灵活地操作DOM元素和组件实例,实现复杂的功能和交互效果。

11. 扩展阅读

通过本集的学习,我们深入理解了Vue 3中模板引用与$refs的使用方法和最佳实践。在下一集中,我们将学习自定义指令开发,这是Vue中用于扩展HTML元素功能的重要机制。

« 上一篇 provide与inject依赖注入 下一篇 » 自定义指令开发