侦听器

什么是侦听器

它之所以叫侦听器,是因为它可以侦听一个或多个响应式数据源数据,并再数据源变化时调用所给的回调函数,就是你传给watch侦听器一个响应式变量,然后当这个变量变化时,自动触发一个你定义的函数,就像一个人被监控了一样,只要这个人一动,摄像头就会报警

这段话来自[Vue3 - watch 侦听器(保姆级详细使用教程)][https://blog.csdn.net/weixin_44166849/article/details/133339827]来讲一下是什么意思

  • 侦听一个或多个响应式数据源
    • 响应式数据源:就是 Vue3 中用refreactive声明的变量 / 对象(比如const sum = ref(1)const obj = reactive({name: 'test'})),这类数据的特点是 “变化能被 Vue 感知到”;
    • 侦听:相当于给这些响应式数据装了 “监控器”,watch会持续关注这些数据的状态,只要数据有变动,监控器就能立刻察觉。
  • 数据源变化时调用所给的回调函数
    • 你给watch绑定一个 “处理函数”(回调函数),这个函数不会主动执行,只有当被监控的数据源发生改变时,Vue 才会自动触发这个函数;
    • 比如你监听sum = ref(1),点击按钮让sum++(变成 2),此时watch就会立刻执行你写的回调函数,完成你想要的操作(比如打印日志、发请求、更新 DOM 等)。

这算是复习了

简单说:watch就是给响应式数据装 “报警器”,数据一变,报警器就触发你预设的 “应对动作”(回调函数)。

来一个对应上述内容的最基本的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<!-- 点击按钮,让sum从1变成2、3、4...(对应“人一动”) -->
<button @click="sum++">增加数值</button>
</template>

<script setup lang="ts">
import { watch, ref } from 'vue'
// 1. 响应式数据源(对应“被监控的人”)
const sum = ref(1);

// 2. watch侦听器(对应“摄像头”)
watch(
sum, // 要监控的数据源
(New, Old) => { // 回调函数(对应“摄像头报警”的动作)
console.log(`新值:${New} ——— 老值:${Old}`);
}
)
</script>
  • 页面初始化时,sum=1,此时回调函数不会执行(因为数据没变化,“人没动”);
  • 点击按钮,sum变成 2(“人动了”),watch立刻触发回调函数,控制台打印新值:2 ——— 老值:1(“摄像头报警”);
  • 再点一次,sum=3,回调函数再次触发,打印新值:3 ——— 老值:2

实际上

  • 监听器可以不止监听一个数据源,可以同时监听多个响应式数据(比如同时监听sumage),只要其中一个变,回调函数就触发;
  • 不是所有数据都能被监听,必须是ref/reactive声明的响应式数据,普通变量(比如let sum = 1)变化时,watch感知不到(相当于 “没装摄像头的人,动了也没人知道”);
  • 惰性特性,默认情况下,watch不会在页面初始化时执行回调函数,只有数据变化才执行(摄像头不会主动报警,只有人动了才报);如果想初始化就执行,需要加{ immediate: true }配置。

为什么需要watch??

比如你做一个 “搜索框”:用户输入关键词(响应式变量keyword变化),需要自动触发 “发送请求获取搜索结果” 的操作 —— 这时候用watch监听keyword,就能在用户输入时自动执行请求函数,不用手动写点击事件,这就是watch的核心价值:响应数据变化,自动执行副作用操作(发请求、改 DOM、打印日志等)。

Vue3中的侦听器—watch

在 Vue3 中,watch特性进行了一些改变和优化。与 computed 不同,watch通常用于监听数据的变化,并执行一些副作用,例如 发送网络请求、更新DOM等。

Watch的基本用法如下

1
watch(source, callback, options?)

其中,source表示要监听的数据,可以是一个响应式的数据对象、一个计算属性或一个方法的返回值;callback表示当数据发生变化时要执行的回调函数;options表示 watch 的一些配置选项,例如immediatedeepflush等。

其中配置选项代表如下:

  • immediate:立即执行

    默认情况下,watch在初始化时不会执行回调,只有数据变化时才执行。设置immediate: true可以让回调在初始化时立即执行一次。

  • deep:深度监听

    当监听的是对象或数组时,默认情况下watch不会监听内部属性的变化(浅监听)。设置deep: true可以开启深度监听。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <template>
    <div>
    <p>嵌套对象: {{ nestedObj.info.name }}</p>
    <button @click="nestedObj.info.name = 'Vue3'">修改嵌套属性</button>
    </div>
    </template>

    <script setup>
    import { reactive, watch } from 'vue'

    const nestedObj = reactive({
    info: {
    name: 'JavaScript',
    version: 'ES6'
    }
    })

    // 监听嵌套对象的属性(需要deep)
    watch(nestedObj, (newVal) => {
    console.log('嵌套对象变化:', newVal)
    }, {
    deep: true // 深度监听
    })

    // 或者直接监听嵌套属性(不需要deep)
    // watch(() => nestedObj.info.name, (newName) => {
    // console.log('名称变化:', newName)
    // })
    </script>
  • flush:回调执行时机

    控制回调函数的执行时机,可选值:

    • 'pre'(默认):在 DOM 更新前执行,也就是在数据变化之前执行回调函数
    • 'post':在 DOM 更新后执行,也就是在数据变化之后执行回调函数,但是需要等待所有依赖项都更新后才执行
    • 'sync':同步执行

那么如何使用 watch 监听不同类型的数据源

  • 监听单个ref数据源

    这是最基础的用法,监听用ref声明的响应式变量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <template>
    <div>
    <p>计数器: {{ count }}</p>
    <button @click="count++">增加</button>
    </div>
    </template>

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

    const count = ref(0)

    // 监听单个ref数据
    watch(count, (newValue, oldValue) => {
    console.log(`计数器从 ${oldValue} 变为 ${newValue}`)
    // 这里可以执行副作用操作,比如发送请求、更新DOM等
    })
    </script>
  • 监听多个数据源

    可以用数组同时监听多个响应式数据,数组内任何一个数据变化都会触发回调。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <template>
    <div>
    <p>计数器: {{ count }}</p>
    <p>用户名: {{ name }}</p>
    <button @click="count++">增加计数</button>
    <button @click="name = 'Vue3'">修改名称</button>
    </div>
    </template>

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

    const count = ref(0)
    const name = ref('JavaScript')

    // 监听多个数据源
    watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
    console.log(`计数变化: ${oldCount} -> ${newCount}`)
    console.log(`名称变化: ${oldName} -> ${newName}`)
    })
    </script>
  • 监听reactive对象

    监听用reactive声明的响应式对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    <template>
    <div>
    <p>用户信息: {{ user.name }} - {{ user.age }}</p>
    <button @click="user.age++">增加年龄</button>
    <button @click="user.name = 'Tom'">修改名字</button>
    </div>
    </template>

    <script setup>
    import { reactive, watch } from 'vue'

    const user = reactive({
    name: 'Jack',
    age: 20
    })

    // 监听整个reactive对象
    watch(user, (newUser, oldUser) => {
    console.log('用户信息发生变化:', newUser)
    // 注意:reactive对象的oldValue和newValue指向同一个对象
    })
    </script>
  • 监听对象的某个属性

    如果只想监听对象的特定属性,需要用函数返回该属性(否则会监听整个对象)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <template>
    <div>
    <p>用户信息: {{ user.name }} - {{ user.age }}</p>
    <button @click="user.age++">增加年龄</button>
    <button @click="user.name = 'Tom'">修改名字</button>
    </div>
    </template>

    <script setup>
    import { reactive, watch } from 'vue'

    const user = reactive({
    name: 'Jack',
    age: 20
    })

    // 只监听age属性
    watch(() => user.age, (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
    })
    </script>

停止监听

在 Vue2 中是没有停止监听的功能,Vue3 之所以提供了停止监听的方法,这是因为持续监听会占用内部资源,很可能咱们某个变量都不需要后续持续监听了,但又没有办法关掉。

setup中,watch返回一个停止函数,调用它可以停止监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

// 获取停止函数
const stopWatch = watch(count, (newValue) => {
console.log('计数变化:', newValue)

// 计数达到5时停止监听
if (newValue >= 5) {
stopWatch()
console.log('停止监听')
}
})

// 模拟计数增加
setInterval(() => {
count.value++
}, 1000)
</script>

使用watchEffect进行监听

watchEffect是 Vue3 提供的响应式副作用监听,它的核心特点是自动追踪依赖,不需要手动指定监听源,比watch更简洁,是watch的简化版本

  • 自动依赖收集watchEffect会立即执行一次回调函数,在执行过程中自动追踪用到的响应式数据,当这些数据变化时,回调会重新执行。
  • 惰性 vs 立即执行watchEffect默认是立即执行的(相当于watchimmediate: true),而watch默认是惰性的。
  • 不区分新旧值watchEffect的回调没有新旧值参数,只能获取当前最新值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div>
<p>计数: {{ count }}</p>
<p>双倍计数: {{ doubleCount }}</p>
<button @click="count++">增加</button>
</div>
</template>

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

const count = ref(0)
const doubleCount = ref(0)

// 自动追踪count的变化
watchEffect(() => {
doubleCount.value = count.value * 2
console.log(`当前count值: ${count.value}`)
})
</script>
  • 初始化时,watchEffect会立即执行,doubleCount被赋值为 0;
  • 点击按钮修改count时,回调自动触发,更新doubleCount

watchEffect也返回一个停止函数,调用后可停止监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(0)

const stop = watchEffect(() => {
console.log(count.value)
if (count.value >= 5) {
stop() // 计数≥5时停止监听
console.log('监听已停止')
}
})
</script>

而且watchEffect能够清除副作用,回调函数可接收一个onInvalidate函数,用于清除过期的副作用(如取消网络请求、清除定时器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup>
import { ref, watchEffect } from 'vue'

const keyword = ref('')

watchEffect((onInvalidate) => {
// 模拟网络请求
const timer = setTimeout(() => {
console.log(`搜索: ${keyword.value}`)
}, 1000)

// 清除副作用:当keyword变化或监听停止时,取消上一次的定时器
onInvalidate(() => clearTimeout(timer))
})
</script>
  • 每次keyword变化时,上一次的定时器会被清除,避免无效请求堆积。

同样,watchEffect支持配置选项,通过flush选项控制执行时机,同watch

  • flush: 'pre'(默认):DOM 更新前执行;
  • flush: 'post':DOM 更新后执行(可获取更新后的 DOM);
  • flush: 'sync':同步执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div ref="el">{{ count }}</div>
</template>

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

const count = ref(0)
const el = ref(null)

// DOM更新后执行,能获取最新DOM内容
watchEffect(() => {
console.log('DOM内容:', el.value?.textContent) // 输出最新count值
}, { flush: 'post' })
</script>

使用 computed 替代 watch

在有些情况下,如果我们只是想监听一个值的变化,并在变化时执行一些操作,我们可以使用computed代替watch

computed可以自动缓存计算的结果,只有在它的依赖项变化时才会重新计算,因此可以提高一定的性能。

  • computed:专注于根据依赖计算新值,有缓存,适合 “推导值” 场景;
  • watch:专注于数据变化时执行副作用(如发请求、改 DOM),无缓存,适合 “响应动作” 场景。

常见的computed替代watch的适用场景如下:

  • 当你只是想根据一个 / 多个数据推导新值,而非执行异步或复杂副作用时,computed更简洁、性能更好。

    1
    2
    3
    4
    5
    6
    7
    <script setup>
    import { ref, computed } from 'vue'

    const count = ref(0)
    // computed自动推导值,有缓存,代码更简洁
    const doubleCount = computed(() => count.value * 2)
    </script>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <script setup>
    import { ref, watch } from 'vue'

    const count = ref(0)
    const doubleCount = ref(0)

    // 用watch做简单计算,冗余且无缓存
    watch(count, (newVal) => {
    doubleCount.value = newVal * 2
    }, { immediate: true })
    </script>
    • computed有缓存:只要count不变,多次访问doubleCount.value不会重新计算;
    • watch无缓存:每次触发都会执行回调,若只是计算值会浪费性能。

但是也有computed无法替代watch的场景

  • 当需要执行异步操作复杂副作用时,必须用watch(或watchEffect):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <script setup>
    import { ref, watch } from 'vue'

    const keyword = ref('')

    // 搜索关键词变化时发请求(异步操作),只能用watch
    watch(keyword, async (newVal) => {
    if (newVal) {
    // const res = await fetch(`/api/search?keyword=${newVal}`)
    // console.log(res.data)
    console.log(`发送搜索请求: ${newVal}`)
    }
    }, { immediate: true, debounce: 500 }) // 防抖优化
    </script>
    • computed必须同步返回值,无法处理异步逻辑;
    • watch适合处理这类需要 “响应变化并执行动作” 的场景。

模板引用

什么是模板引用

虽然 Vue 提倡声明式开发(不直接操作 DOM),但在实际项目中,我们总会遇到不得不直接访问 DOM 或子组件实例的场景。

在 Vue 中,我们通常通过改变数据(State)来让页面自动更新。但在以下场景中,数据驱动无法满足需求,我们需要直接操作 DOM:

  • 管理焦点:页面加载后自动聚焦到输入框。
  • 媒体播放:控制 <video><audio>play() / pause()
  • 集成第三方库:初始化 ECharts、D3.js、Mapbox 等需要挂载到具体 DOM 节点上的库。
  • 获取元素尺寸/位置:做动画或拖拽时,需要 getBoundingClientRect()

像这样的常见的还有很多,也就是说,模板引用是 Vue 提供的一种方式,让你能在代码中直接获取到页面上的 DOM 元素或组件实例

模板引用就是 Vue 留给我们的 “后门”,让我们在必要时能优雅地操作 DOM / 组件,而不是完全禁止这种操作。

可以把模板引用想象成

  • 你家里有很多家具(DOM 元素),你给沙发贴了个标签写着 “mySofa”,给电视贴了 “myTV”;
  • 之后你想搬动沙发或打开电视时,直接喊标签名就能找到对应的家具,不用满屋找。

在 Vue 里,模板引用就是给 DOM 元素 / 组件贴的 “标签”,让你能在代码里精准找到并操作它们

如何使用模板引用

如何使用模板引用呢?

  • 第一步:在模板中的 DOM 元素 / 组件上添加 ref 属性(贴标签);
  • 第二步:在代码中用 ref() 函数创建同名的响应式变量,Vue 会自动把 DOM 元素 / 组件实例赋值给这个变量。

所以,我们就可以这样操作DOM元素,写一个聚焦的输入框为例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<!-- 给输入框贴标签:ref="inputRef" -->
<input type="text" ref="inputRef" placeholder="自动聚焦的输入框">
</template>

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

// 创建同名变量,用于接收DOM元素
const inputRef = ref(null)

// 组件挂载完成后(DOM已渲染),才能获取到元素
onMounted(() => {
// 通过inputRef.value就能拿到输入框的DOM元素,调用focus()方法聚焦
inputRef.value.focus()
})
</script>
  • 页面加载后,输入框会自动获得焦点,这就是通过模板引用直接操作 DOM 的效果;
  • 注意:必须在onMounted钩子中操作(DOM 渲染完成后),否则inputRef.valuenull

如何获取组件实例?

如果ref加在子组件上,能获取到子组件的实例(方便调用子组件的方法):

1
2
3
4
5
6
7
8
9
10
<!-- 子组件 Child.vue-->
<script setup>
// 用defineExpose暴露组件内部的方法/数据(否则父组件拿不到)
defineExpose({
sayHello() {
console.log('我是子组件的方法!')
},
message: '子组件的消息'
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 父组件 Child.vue-->
<template>
<!-- 给子组件贴标签:ref="childRef" -->
<Child ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</template>

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

const childRef = ref(null)

const callChildMethod = () => {
// 获取子组件实例,调用暴露的方法
childRef.value.sayHello()
// 获取子组件暴露的数据
console.log(childRef.value.message) // 输出:子组件的消息
}
</script>

Vue 3.5 引入了 useTemplateRef,这使得引用的定义更加语义化,且与数据 ref 区分开来。

例如,点击按钮复制文本框内容就可以这么写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup>
import { useTemplateRef } from 'vue'

// 1. 定义引用变量,参数 'my-input' 必须和模板里的 ref 属性值一致
const inputRef = useTemplateRef('my-input')

const copyText = () => {
// 2. 访问 inputRef.value (注意要加 .value)
// 务必使用可选链 ?. 防止元素未挂载时报错
inputRef.value?.select()
document.execCommand('copy')
alert('内容已复制')
}
</script>

<template>
<div>
<!-- 3. 在模板中绑定 ref -->
<input ref="my-input" value="Hello Vue 3.5" />
<button @click="copyText">复制</button>
</div>
</template>

像我们上面的例子,我们都是建一个与模板 ref 同名的响应式变量,并初始化为 null。使用useTemplateRef就可以直接创建模板引用的变量

生命周期与安全访问

模板引用在组件挂载(Mount)前是 null。

所以你不能在<script setup> 的顶层直接访问它,因为那时 DOM 还没生成,才到创建这步

1
2
3
4
5
6
7
8
9
10
11
12
import { onMounted, watchEffect } from 'vue'

onMounted(() => {
console.log(myDiv.value) // 此时 DOM 已存在,可以访问
})

// 或者使用 watchEffect 监听变化
watchEffect(() => {
if (myDiv.value) {
console.log('DOM 出现了/更新了', myDiv.value)
}
})

组件上的 Ref

当 ref 用在组件标签上时,你拿到的是组件实例

什么意思?也就是,当把ref属性加在子组件标签上时,父组件通过这个ref拿到的不是 DOM 元素,而是子组件的实例对象

而且用<script setup>写的子组件,内部的方法、变量默认是 “藏起来” 的(私有),父组件就算拿到子组件实例,也看不到这些内容。

所以这时候就可以使用defineExpose,这个在前面说了,它就是把子组件想让父组件访问的内容暴露出去,这样父组件才能调用这些子组件中被暴露的内容。

例如,父组件控制子组件的弹窗打开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!--子组件 (ChildModal.vue)-->
<script setup>
import { ref } from 'vue'

// 1. 子组件内部的状态:控制弹窗显示/隐藏(默认私有,父组件看不到)
const isVisible = ref(false)

// 2. 子组件内部的方法:打开弹窗(默认私有)
const open = () => {
isVisible.value = true
console.log('弹窗已打开')
}

// 3. 子组件内部的方法:关闭弹窗(默认私有)
const close = () => {
isVisible.value = false
}

// 重点:显式暴露 open 方法给父组件
defineExpose({
open,
close // 如果父组件不需要 close,可以不暴露
})
</script>

<template>
<div v-if="isVisible" class="modal">我是弹窗</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!--父组件 (Parent.vue)-->
<script setup>
import { useTemplateRef } from 'vue'
import ChildModal from './ChildModal.vue'

// 获取子组件实例
const modalRef = useTemplateRef('modal')

// 父组件的方法:点击按钮时调用
const handleOpen = () => {
// 调用子组件暴露出来的 open 方法
modalRef.value?.open()
}
</script>

<template>
<!-- 点击按钮触发父组件的handleOpen方法 -->
<button @click="handleOpen">打开子弹窗</button>
<!-- 子组件标签上的ref="modal",和父组件的modalRef对应 -->
<ChildModal ref="modal" />
</template>

v-for 中的模板引用

当 ref 用在循环里时,引用的值会变成一个数组

注意,数组中元素的顺序不保证与源数据数组的顺序一致(通常取决于更新的时机)。

例如,我们经常需要制作图片画廊的入场动画作为头图展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import gsap from 'gsap' // 假设我们用 GSAP 做动画

const list = [1, 2, 3, 4, 5]

// 3.5+ 写法,itemsRef.value 将是一个数组
const itemsRef = useTemplateRef('items')

onMounted(() => {
if (itemsRef.value) {
// 遍历 DOM 数组进行操作
gsap.from(itemsRef.value, {
y: 100,
opacity: 0,
stagger: 0.1 // 错峰动画
})
}
})
</script>

<template>
<ul>
<!-- ref 会自动把每个 li 收集到数组中 -->
<li v-for="item in list" :key="item" ref="items">
Item {{ item }}
</li>
</ul>
</template>

函数模板引用

函数模板引用是什么?

普通的模板引用是绑定一个字符串(如ref="input"),Vue 会自动帮你把对应的 DOM 元素赋值给同名的ref变量;

函数模板引用是绑定一个函数(如:ref="(el) => { ... }"),当 DOM 元素被创建或销毁时,Vue 会自动调用这个函数,并把当前 DOM 元素(el)作为参数传给你

Vue 在 DOM 元素 “出生” 或 “消失” 时,主动通知你,并把元素本身交给你处理,你可以自定义这个存储或操作的逻辑。(使用模板引用中的函数)

例如,我们使用模板函数引用,解决上面的在v-for循环中,如果用普通字符串ref,会的遇到两个问题:

  1. 无法区分元素:循环生成的多个元素会共享同一个ref名称,最终只能拿到最后一个元素;
  2. 顺序不可靠:如果数组顺序变化(如排序、筛选),ref对应的 DOM 元素顺序也会乱,无法通过索引准确找到目标元素。

而函数模板引用可以让你用数据的唯一标识(如 id)来关联 DOM 元素,彻底解决这些问题

准备数据和存储容器

1
2
3
4
5
6
7
const list = [
{ id: 101, text: 'A' },
{ id: 102, text: 'B' }
]

// 使用 Map 存储:Key 是数据的唯一ID,Value 是对应的DOM元素
const itemMap = new Map()

定义函数模板引用的处理函数

1
2
3
4
5
6
7
8
9
const setItemRef = (el, id) => {
if (el) {
// el存在(DOM元素被创建/挂载):把id和el存入Map
itemMap.set(id, el)
} else {
// el为null(DOM元素被销毁/卸载):从Map中删除对应的id
itemMap.delete(id)
}
}
  • 这个函数会被 Vue 自动调用,参数规则:
    • 当 DOM 元素被渲染到页面时,el是当前 DOM 元素,id是我们传入的项的 id;
    • 当 DOM 元素被移除时(如数组项被删除),el会是null,此时需要清理 Map 中的无效数据。

模板中绑定函数模板引用

1
2
3
4
5
6
7
<li v-for="item in list" :key="item.id">
<!-- 绑定函数引用:每次渲染时调用setItemRef,传入当前元素el和item.id -->
<span :ref="(el) => setItemRef(el, item.id)">
{{ item.text }}
</span>
<button @click="scrollToItem(item.id)">滚动到我</button>
</li>

使用存储的 DOM 元素(滚动到指定项)

1
2
3
4
5
6
const scrollToItem = (id) => {
// 通过id从Map中取出对应的DOM元素
const targetEl = itemMap.get(id)
// 如果找到元素,调用scrollIntoView实现平滑滚动
targetEl?.scrollIntoView({ behavior: 'smooth' })
}
  • 点击按钮时,传入对应项的id,从itemMap中取出 DOM 元素,调用scrollIntoView方法实现滚动,无论数组顺序如何变化,都能精准找到目标元素。

那么整个的执行流程就是这样的

  1. 页面初始化渲染:
    • 循环list生成两个<span>元素;
    • Vue 为每个<span>调用:ref绑定的函数,把el<span>DOM)和item.id(101/102)传给setItemRef
    • itemMap中存入101 → <span>A</span>102 → <span>B</span>
  2. 点击 “滚动到我” 按钮:
    • 调用scrollToItem(item.id),传入对应 id(如 101);
    • itemMap中取出 id 为 101 的<span>元素;
    • 调用scrollIntoView,页面滚动到该元素位置。
  3. 如果数组变化(如删除 id=102 的项):
    • 对应的<span>被销毁,Vue 会再次调用函数,传入el=nullid=102
    • setItemRef执行itemMap.delete(102),清理无效数据。

混入

什么是混入

混入(Mixins)是 Vue 中一种代码复用机制,本质是一个包含组件选项(如datamethods生命周期钩子等)的对象,它可以包含任何组件选项。当组件使用混入对象时,混入对象的所有选项会被 “合并” 到组件自身的选项中,从而实现逻辑复用。这意味着组件可以接收混入对象的方法、生命周期钩子、计算属性等。

也就是说,在 Vue 3 中,混入(Mixins)是一种将一组组件选项合并到目标组件中的技术。通过混入,开发者可以在多个组件之间共享重复的逻辑、方法和数据,从而提高代码的可复用性和可维护性。

在 Vue 3 中,混入(Mixins)仍然是一种用于代码复用的模式,但它并不是 Vue 3 官方推荐的最佳实践,特别是在引入了 Composition API(也称hooks)之后。混入在 Vue 2 中被广泛使用,但在 Vue 3 中,官方更推荐使用组合式 API(Composition API)来实现类似的功能,因为组合式 API 提供了更好的逻辑关注点分离和可维护性。

使用混入对象

首先创建一个混入对象,包含需要复用的选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mixins/logMixin.js
export const logMixin = {
data() {
return {
logCount: 0
}
},
methods: {
printLog(message) {
console.log(`[Log ${++this.logCount}]: ${message}`)
}
},
mounted() {
this.printLog('组件已挂载')
}
}

在组件中通过mixins选项引入混入对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<button @click="printLog('按钮被点击')">打印日志</button>
</div>
</template>

<script>
import { logMixin } from './mixins/logMixin.js'

export default {
mixins: [logMixin], // 引入混入(可传入多个,数组形式)
mounted() {
this.printLog('组件自身的挂载钩子')
}
}
</script>
  • 组件挂载时,先执行混入的mounted(打印[Log 1]: 组件已挂载),再执行组件自身的mounted(打印[Log 2]: 组件自身的挂载钩子);
  • 点击按钮时,调用混入对象logMixinprintLog方法,打印[Log 3]: 按钮被点击

Vue3中代替混入的Hook实现

到底说, Vue 3 的官方文档和社区都鼓励使用 Composition API 来替代 mixins,因为 Composition API 提供了更好的代码组织和复用能力。所以混入只是一个了解性的内容。

我们都知道 Vue3 中有很多钩子函数

Composition API 主要包括以下几种类型的 hooks:

  1. 逻辑钩子 (Logical Hooks):
    • setup():是 Composition API 的入口点,用于定义和组织组件的逻辑。
    • onBeforeMount(), onMounted(), onBeforeUpdate(), onUpdated(), onBeforeUnmount(), onUnmounted():这些生命周期钩子允许你在组件的不同生命周期阶段运行代码。
    • onActivated(), onDeactivated():用于keep-alive组件的激活/非激活状态。
    • onErrorCaptured():用于捕获组件树中的错误。
  2. 响应式数据钩子 (Reactive Data Hooks)
    • ref():创建一个响应式的引用对象。
    • reactive():为对象创建响应式的副本。
    • computed():创建计算属性。
    • watch(),watchEffect():自动收集响应式数据的依赖关系并执行副作用。
  3. 上下文钩子(Context Hooks):
    • useContext():访问组件树中的上下文。
  4. 其他钩子
    • useProvide(), useInject():提供和注入服务,类似于Vue的provide/inject机制。
    • useRouter(), useRoute():在Vue Router中使用,分别获取路由器实例和当前路由对象。
    • useStore():在Vuex中使用,获取store实例

钩子也是可以自定义的,代替混入的形式

我们来封装一个useRequest钩子,用于处理 API 请求、加载状态、错误处理等通用逻辑,替代混入的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 封装通用的请求逻辑Hook
import { ref } from 'vue'

export function useRequest(requestFunction) {
// 响应式状态:数据、加载中、错误
const data = ref(null)
const loading = ref(false)
const error = ref(null)

// 执行请求的方法
const execute = async (...args) => {
try {
loading.value = true // 请求开始,设置加载状态
error.value = null // 清空之前的错误
const result = await requestFunction(...args) // 执行传入的请求函数
data.value = result // 存储请求结果
return result // 返回结果,支持链式调用
} catch (err) {
error.value = err // 捕获并存储错误
throw err // 抛出错误,让调用方可以处理
} finally {
loading.value = false // 请求结束,关闭加载状态
}
}

// 返回需要暴露给组件的状态和方法
return {
data,
loading,
error,
execute
}
}

组件中通过import { useRequest } from './hooks/useRequest.js'明确引入,一眼就能知道dataloading等状态来自哪里,而混入的逻辑是 “隐式” 的。

然后组件中使用这个 Hook:UserList.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<template>
<div>
<!-- 加载状态显示 -->
<div v-if="loading">加载中...</div>

<!-- 错误提示 -->
<div v-else-if="error" class="error">
请求失败:{{ error.message }}
</div>

<!-- 数据展示 -->
<div v-else-if="data">
<h3>用户列表</h3>
<ul>
<li v-for="user in data" :key="user.id">
{{ user.name }} ({{ user.email }})
</li>
</ul>
</div>

<!-- 触发请求的按钮 -->
<button @click="fetchUsers" :disabled="loading">
{{ loading ? '加载中...' : '获取用户列表' }}
</button>
</div>
</template>

<script setup>
import { useRequest } from './hooks/useRequest.js'

// 模拟API请求函数
const fetchUsersApi = async () => {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1000))

// 模拟成功响应(可注释掉这行,打开下面的错误模拟)
return [
{ id: 1, name: '张三', email: 'zhangsan@example.com' },
{ id: 2, name: '李四', email: 'lisi@example.com' }
]

// 模拟请求错误
// throw new Error('网络异常,请重试')
}

// 使用自定义Hook,传入请求函数
const { data, loading, error, execute: fetchUsers } = useRequest(fetchUsersApi)
</script>

<style scoped>
.error {
color: red;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

钩子肯定是可以被复用的,这样就替代了混入的形式,另一个组件复用同一个 Hook:ArticleDetail.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
<div>
<div v-if="loading">加载文章中...</div>
<div v-else-if="error" class="error">{{ error.message }}</div>
<div v-else-if="data">
<h1>{{ data.title }}</h1>
<p>{{ data.content }}</p>
</div>

<button @click="() => fetchArticle(1)">加载文章1</button>
<button @click="() => fetchArticle(2)">加载文章2</button>
</div>
</template>

<script setup>
import { useRequest } from './hooks/useRequest.js'

// 模拟获取文章的API
const fetchArticleApi = async (articleId) => {
await new Promise(resolve => setTimeout(resolve, 800))

if (articleId === 1) {
return { id: 1, title: 'Vue3自定义Hook详解', content: 'Hook是Composition API的核心...' }
} else {
return { id: 2, title: '前端性能优化技巧', content: '防抖节流、懒加载...' }
}
}

// 复用useRequest Hook,处理文章请求
const { data, loading, error, execute: fetchArticle } = useRequest(fetchArticleApi)
</script>

第二个组件中fetchArticle(2)可以传递参数给请求函数,混入很难做到这种灵活的参数控制。

而且很明显,如果一个组件需要同时处理多个请求,只需多次调用 Hook 并解构不同名称:

1
2
const { execute: fetchUsers } = useRequest(fetchUsersApi)
const { execute: fetchArticles } = useRequest(fetchArticlesApi)

而混入如果有同名状态 / 方法,会直接冲突覆盖。

hook 是比 混入 要好很多的

响应式工具

什么是响应式工具

ref()reactive()

这俩前面都说过了,但是我没说这俩是一种响应式工具罢了,但是一直以来其实也是当成一个类似工具的方法来用

ref() - 创建值类型响应式数据,ref用于将基本数据类型(如数字、字符串、布尔值)包装成响应式对象,也可以用于包装对象(但内部会自动用reactive处理)。

reactive用于将对象 / 数组转换为响应式代理对象,适合处理复杂数据结构。

复习一下

computed()

computed用于根据响应式数据推导新值,具有缓存特性(依赖不变时不会重新计算)。

这个大家也比较熟悉了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script setup>
import { ref, computed } from 'vue'

const count = ref(0)

// 只读计算属性
const doubleCount = computed(() => count.value * 2)

// 可写计算属性
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
const [first, last] = value.split(' ')
firstName.value = first
lastName.value = last
}
})

const firstName = ref('Jack')
const lastName = ref('Ma')
</script>

<template>
<div>
<p>{{ count }} × 2 = {{ doubleCount }}</p>
<p>{{ fullName }}</p>
<button @click="count++">增加</button>
<button @click="fullName = 'Tom Li'">修改全名</button>
</div>
</template>
  • 缓存结果,依赖不变时复用计算值;
  • 支持只读和可写两种模式;
  • 适合处理数据转换 / 组合场景。

readonly()

它用于创建只读响应式数据,防止数据被修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
import { reactive, readonly } from 'vue'

const original = reactive({ count: 0 })
const copy = readonly(original)

// 尝试修改只读数据会报错(开发环境)
const tryUpdate = () => {
copy.count++ // 警告:Set operation on key "count" failed: target is readonly.
original.count++ // 原数据可修改,只读代理会同步更新
}
</script>

<template>
<div>
<p>原始数据:{{ original.count }}</p>
<p>只读数据:{{ copy.count }}</p>
<button @click="tryUpdate">尝试修改</button>
</div>
</template>
  • 只读代理会跟随原数据的变化而更新;
  • 适合保护数据不被意外修改(如传入子组件的 props)。

shallowRef() & shallowReactive()

这两个响应式工具是浅响应式的

ref/reactive的深度响应式不同,这两个工具只监听第一层属性的变化,适合处理大型数据结构(优化性能)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup>
import { shallowRef, shallowReactive } from 'vue'

// shallowRef:只监听.value的替换,不监听内部属性
const shallowUser = shallowRef({ name: 'Jack', age: 20 })
const updateShallowRef = () => {
shallowUser.value.name = 'Tom' // 不会触发更新
shallowUser.value = { name: 'Tom', age: 20 } // 会触发更新
}

// shallowReactive:只监听第一层属性,不监听嵌套属性
const shallowObj = shallowReactive({
info: { count: 0 }
})
const updateShallowReactive = () => {
shallowObj.info.count++ // 不会触发更新
shallowObj.info = { count: 1 } // 会触发更新
}
</script>

这种在大型不可变数据,像后端返回的海量分页或者海量列表的时候作为快速加载很有用

triggerRef()

浅响应式可能会带来没法及时更新或者响应的问题,triggerRef()它用于用于强制触发shallowRef的更新,即使只修改了内部属性。

1
2
3
4
5
6
7
8
9
10
<script setup>
import { shallowRef, triggerRef } from 'vue'

const shallowUser = shallowRef({ name: 'Jack', age: 20 })

const updateInternal = () => {
shallowUser.value.name = 'Tom' // 不触发更新
triggerRef(shallowUser) // 手动触发更新
}
</script>

toRef() & toRefs()

保留响应式引用工具

  • toRef:为响应式对象的某个属性创建 ref,保留响应式关联;
  • toRefs:将响应式对象的所有属性转换为 ref,常用于解构响应式对象。

这个相对来说不太好明白

首先要明白:直接解构reactive创建的响应式对象,会导致解构出的属性失去响应式。因为直接解构reactive对象得到的是普通值,而且这些值是常量(const声明),根本无法修改,更别说触发响应式更新了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup>
import { reactive } from 'vue'

const user = reactive({ name: 'Jack', age: 20 })

// 直接解构:name和age变成普通变量,失去响应式!
const { name, age } = user

const update = () => {
// 修改普通变量,不会触发视图更新
name = 'Tom' // 报错:不能给常量赋值
age++ // 报错:不能给常量赋值
}
</script>

<template>
<div>
<p>{{ name }} - {{ age }}</p> <!-- 显示Jack - 20,但不会更新 -->
<button @click="update">更新</button>
</div>
</template>

toRefs()的作用就是把reactive对象的每个属性都转换成独立的 ref,这样解构后每个属性依然是响应式的。

toRef()就是reactive对象中的单个属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script setup>
import { reactive, toRef, toRefs } from 'vue'

const user = reactive({ name: 'Jack', age: 20 })

// toRef:单个属性转ref
const nameRef = toRef(user, 'name')

// toRefs:所有属性转ref
const { name, age } = toRefs(user)

// 修改ref会同步到原对象
const update = () => {
nameRef.value = 'Tom'
age.value++
}
</script>

<template>
<div>
<p>{{ nameRef }} - {{ name }} - {{ age }}</p>
<button @click="update">更新</button>
</div>
</template>
  • 转换后的 ref 与原对象保持响应式关联;
  • 适合解构响应式对象时保留响应式(避免解构后失去响应式)。

原理:toRef()/toRefs()是 “引用” 不是 “拷贝”

  • 可以把它们理解为:给原响应式对象的属性 “贴了一个标签”,通过标签操作属性,本质还是在操作原对象。
    • 拷贝:比如const name = user.name,是把值复制一份,和原对象无关;
    • 引用:比如const nameRef = toRef(user, 'name'),是指向原对象的name属性,和原对象共享数据。

unref()

这个工具自动解包 ref

unref本身也是一个语法糖:如果参数是 ref,返回.value;否则返回参数本身。

1
2
3
4
5
6
7
import { ref, unref } from 'vue'

const count = ref(0)
const num = 10

console.log(unref(count)) // 0(等价于count.value)
console.log(unref(num)) // 10(直接返回原数)