十一、侦听器

watch()

const question = ref('')
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) { /* 请求数据 */ }
})

侦听数据源类型:

const x = ref(0)
const y = ref(0)
const obj = reactive({ count: 0 })

watch(x, (newX) => { })                           // 单个 ref
watch(() => x.value + y.value, (sum) => { })       // getter 函数
watch([x, () => y.value], ([newX, newY]) => { })   // 多个源数组
watch(() => obj.count, (count) => { })             // reactive 对象属性(必须用 getter)
watch(obj, callback)                                // reactive 对象(隐式深层侦听)

不能直接侦听 reactive 对象的属性 watch(obj.count, ...),因为参数值是一个数字。

常用选项

watch(source, callback, { immediate: true })  // 创建时立即执行
watch(source, callback, { once: true })       // 只触发一次 (Vue 3.4+)
watch(() => state.obj, handler, { deep: true }) // 显式深层侦听
  • 传入 reactive 对象时隐式深层侦听,但 newValue === oldValue(同一引用)
  • 深层侦听对大型数据结构开销大,Vue 3.5+ 可指定 deep: 数字 限制遍历深度

watchEffect()

自动追踪回调内访问的所有响应式依赖,创建时立即执行:

watchEffect(async () => {
  const response = await fetch(`https://api.example.com/item/${id.value}`)
  data.value = await response.json()
})

watchEffect 仅追踪同步执行期间访问的依赖。第一个 await 之后的属性不会被追踪。

watch vs watchEffect

特性 watch watchEffect
依赖追踪 显式声明数据源 自动追踪回调内所有依赖
懒执行 是(除非 immediate 否(创建时立即执行)
访问旧值 可以 不可以

副作用清理

// Vue 3.5+
import { watch, onWatcherCleanup } from 'vue'
watch(id, (newId) => {
  const controller = new AbortController()
  fetch(`/api/${newId}`, { signal: controller.signal })
  onWatcherCleanup(() => controller.abort())
})

// 旧写法(仍可用)
watch(id, (newId, oldId, onCleanup) => {
  onCleanup(() => { /* 清理 */ })
})

回调触发时机

watch(source, callback, { flush: 'post' })  // DOM 更新后触发
watchPostEffect(() => { })                    // flush: 'post' 的别名
watchSyncEffect(() => { })                    // flush: 'sync' 同步触发

默认时机:父组件更新后、组件自身 DOM 更新前。

停止侦听器

const unwatch = watchEffect(() => { /* ... */ })
unwatch()  // 手动停止

setup() 中同步创建的侦听器会在组件卸载时自动停止。异步创建的(如 setTimeout 内)不会自动停止。


十二、模板引用

访问 DOM 元素

推荐方式 (Vue 3.5+):

<script setup>
import { useTemplateRef, onMounted } from 'vue'
const input = useTemplateRef('my-input')
onMounted(() => { input.value.focus() })
</script>
<template>
  <input ref="my-input" />
</template>

旧方式 (3.5 以下):

<script setup>
import { ref, onMounted } from 'vue'
const input = ref(null)
onMounted(() => { input.value.focus() })
</script>
<template>
  <input ref="input" />
</template>
  • 模板引用仅在挂载后可用,挂载前为 null
  • 使用 watchEffect 处理可能为 null 的情况

组件上的 ref

<script setup> 组件默认是私有的,父组件无法访问子组件内部。需用 defineExpose 显式暴露:

<!-- 子组件 Child.vue -->
<script setup>
import { ref } from 'vue'
const a = 1
const b = ref(2)
defineExpose({ a, b })  // 必须在任何 await 之前调用
</script>
<!-- 父组件 -->
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Child from './Child.vue'
const child = useTemplateRef('child')
onMounted(() => { console.log(child.value) })  // { a: 1, b: Ref(2) }
</script>
<template>
  <Child ref="child" />
</template>

v-for 中的模板引用 (Vue 3.5+)

<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'
const list = ref([/* ... */])
const itemRefs = useTemplateRef('items')
onMounted(() => console.log(itemRefs.value))
</script>
<template>
  <ul>
    <li v-for="item in list" ref="items">{{ item }}</li>
  </ul>
</template>

v-for ref 数组不保证与源数组顺序一致。

函数模板引用

<input :ref="(el) => { /* 将 el 赋值给变量 */ }">

元素卸载时参数为 null。使用动态 :ref 绑定(非静态 ref)。


十三、生命周期钩子

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

onMounted(() => {
  console.log('组件已挂载')   // 适合:访问 DOM、发起初始化请求
})
onUpdated(() => {
  console.log('组件已更新')
})
onUnmounted(() => {
  console.log('组件已卸载')  // 适合:清除定时器、取消订阅
})
</script>

完整生命周期顺序:

setuponBeforeMountonMountedonBeforeUpdateonUpdatedonBeforeUnmountonUnmounted

重要规则: 生命周期钩子必须同步注册。异步注册(如 setTimeout 内)不会生效,因为当前组件实例已丢失。只要调用栈是同步且源于 setup(),在外部函数中调用 onMounted() 是允许的。


十四、组件基础

组件允许我们将 UI 划分为独立的、可重用的部分。组件组织为层层嵌套的树状结构

定义组件

使用单文件组件 .vue

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
  <button @click="count++">You clicked me {{ count }} times.</button>
</template>

不使用构建步骤时,以包含选项的 JS 对象定义:

export default {
  data() { return { count: 0 } },
  template: `<button @click="count++">{{ count }}</button>`
}

使用组件

<script setup> 中导入的组件直接可用,无需注册:

<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>
<template>
  <h1>Here is a child component!</h1>
  <ButtonCounter />
</template>

选项式 API 需在 components 选项中注册。

命名规范:

  • SFC 中推荐 PascalCase<ButtonCounter />

  • DOM 内模板必须用 kebab-case<button-counter></button-counter>

  • 每使用一次组件就创建一个新实例,各实例维护各自的状态

  • 全局注册:app.component('ButtonCounter', ButtonCounter) — 任何组件中可直接使用

传递 Props

Props 是父组件向子组件传递数据的机制:

<!-- 子组件 BlogPost.vue -->
<script setup>
defineProps(['title'])
</script>
<template>
  <h4>{{ title }}</h4>
</template>
<!-- 父组件 -->
<BlogPost v-for="post in posts" :key="post.id" :title="post.title" />
  • defineProps 是编译宏,仅 <script setup> 可用,无需导入
  • 可接收返回值:const props = defineProps(['title'])

监听事件

子组件通过 emit 向父组件通信:

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['enlarge-text'])
</script>
<template>
  <button @click="emit('enlarge-text')">Enlarge text</button>
</template>
<!-- 父组件 -->
<BlogPost @enlarge-text="postFontSize += 0.1" />
  • 声明事件可让 Vue 避免将其作为原生事件应用到子组件根元素

通过插槽分配内容

使用 <slot> 作为占位符,父组件传递的内容渲染在此处:

<!-- AlertBox.vue -->
<template>
  <div class="alert-box">
    <strong>This is an Error for Demo Purposes</strong>
    <slot />
  </div>
</template>
<!-- 使用 -->
<AlertBox>
  Something bad happened.
</AlertBox>

动态组件

使用 <component :is=""> 切换组件:

<component :is="currentTab"></component>

:is 的值可以是注册的组件名字符串或导入的组件对象。切换时被替换的组件会被卸载,可用 <KeepAlive> 保持存活状态。

DOM 内模板解析注意事项

仅适用于直接在 DOM 中编写模板(SFC 不受限):

注意事项 说明
大小写 PascalCase 组件名、camelCase prop/事件名 → 必须转 kebab-case
闭合标签 必须显式写关闭标签 <my-component></my-component>,不能自闭合
元素位置 <ul> 只能放 <li> 等。解决方案:<tr is="vue:blog-post-row">(注意 vue: 前缀)

更多推荐