一、watch(侦听器)
1. 核心原理
- Vue2 :为每个被监听表达式创建一个
Watcher实例,读取表达式时触发依赖收集,表达式返回的值变化时,调度Watcher执行回调。 - Vue3 :底层依赖
effect和作用域,支持侦听ref、reactive、getter 及多源数组,支持副作用清理。
2. Vue2 Options API 中的 watch
参数(对象形式):
javascript
watch: {
source: {
handler(newVal, oldVal) { /* ... */ },
deep: true, // 深度监听,递归遍历所有子属性
immediate: true, // 立即以当前值执行一次回调
flush: 'pre' // (Vue3 有) 回调的刷新时机,Vue2 无
}
}
使用场景:表单监听、路由参数变化请求数据、需要旧值对比的业务。
代码示例:
javascript
export default {
data() {
return {
keyword: '',
form: { name: '', age: 0 }
}
},
watch: {
keyword(newVal, oldVal) {
// 搜索防抖
this.search(newVal)
},
'form.name': {
handler(newVal, oldVal) { /* 监听嵌套属性 */ },
deep: false // 字符串形式不需要 deep,直接指定路径
},
form: {
handler(newVal, oldVal) {
// 监听整个对象,需要 deep:true 才能检测内部变化
},
deep: true,
immediate: true
}
}
}
Vue2 坑点:
deep: true会对对象的所有层级递归添加观察者,大对象性能极差。- 无法监听到对象属性的增加/删除 ,除非使用
Vue.set/delete或直接替换对象。 - 数组索引直接赋值
arr[0] = val不会触发侦听器(需要用splice或Vue.set)。
3. Vue3 Composition API 中的 watch()
类型签名:
typescript
watch(
source: Ref | Reactive | (() => any) | Array<...>,
callback: (newVal, oldVal, onCleanup) => void,
options?: { deep?: boolean, immediate?: boolean, flush?: 'pre' | 'post' | 'sync' }
): StopHandle
参数拆解:
- source :可以是
ref、reactive对象、一个 getter 函数,或多源数组。 - callback :
newVal/oldVal:当 source 是reactive对象时,新旧值指向同一个代理对象,无法区分。onCleanup:注册副作用清理函数,在下一次回调触发或侦听器停止时执行。
- options :
deep:默认false,但对reactive对象会隐式强制深度监听 ,无法关闭。侦听ref或 getter 返回的基本类型,需手动deep: true才会深度。immediate:立即执行回调。flush:控制回调刷新时机。'pre'(默认):组件更新前执行,此时 DOM 未更新。'post':组件更新后执行,能访问到最新 DOM(等同watchPostEffect)。'sync':同步触发(极少用)。
- 返回值:调用可停止侦听的函数。
核心使用场景与代码:
(1) 侦听 ref 基本类型
vue
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal, oldVal) => {
// 搜索逻辑,oldVal 是变化前的值
console.log(`从 ${oldVal} 到 ${newVal}`)
})
</script>
(2) 侦听 reactive 对象
vue
<script setup>
import { reactive, watch } from 'vue'
const form = reactive({ name: '', age: 0 })
watch(form, (newVal, oldVal) => {
// 注意:newVal === oldVal (同一个代理)
// 只能拿到新值,oldVal 无效
console.log('对象内部变化', newVal)
})
</script>
坑点 :watch(reactiveObj, callback) 会自动深侦听,且无法关闭。若你只想监听 form.name,建议使用 getter:watch(() => form.name, (newName) => {})
(3) 侦听 getter 函数
vue
<script setup>
import { reactive, watch } from 'vue'
const state = reactive({ user: { name: 'Alice' } })
watch(
() => state.user.name,
(newName, oldName) => {
console.log('name changed', oldName, '->', newName)
}
)
// 如果需要深度侦听 getter 返回的对象,需 deep:true
watch(
() => state.user,
(newUser, oldUser) => {
// deep: true 使内部属性变化也触发
},
{ deep: true }
)
</script>
(4) 多源侦听
vue
<script setup>
import { ref, reactive, watch } from 'vue'
const a = ref(1)
const b = ref(2)
watch([a, b], ([newA, newB], [oldA, oldB]) => {
// 任一变化都会触发
})
</script>
(5) 副作用清理(onCleanup)
典型场景:防抖请求、忽略过期请求。
vue
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, async (newVal, oldVal, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true }) // 注册清理函数
const res = await fetch(`/api/search?q=${newVal}`)
if (!cancelled) {
// 只有未过期才更新
result.value = res.data
}
})
</script>
当 keyword 快速变化时,上一个异步请求的 onCleanup 会被执行,从而忽略过期结果,避免竞态问题。
注意事项与坑点:
- reactive 对象的 watch :回调中
oldValue无法使用;可以用watch(() => ({...state}), ...)浅拷贝一份来获取旧值,但这会创建新对象,小心性能。 - 直接侦听 reactive 属性的问题 :
watch(state.count, ...)是无效的,因为state.count解构了原始值;必须用 getter() => state.count。 flush: 'post'的价值 :需要在侦听器内操作 DOM 或获取元素尺寸时,设置为'post',否则可能拿到更新前的 DOM。- 侦听数组 :如果数组是
reactive的,直接watch(array, callback)自动深侦听,能监听到push等;但如果是ref包裹的数组,需deep: true或使用 getter() => [...arr.value](浅拷贝)。 - 停止侦听 :组合式 API 中异步创建的侦听器要手动停止,否则会导致内存泄漏;在
setup作用域内,组件卸载会自动停止,但如果在setTimeout中创建则不会。
二、watchEffect(Vue3 新增)
类型签名
typescript
watchEffect(
effect: (onCleanup) => void,
options?: { flush?: 'pre' | 'post' | 'sync' }
): StopHandle
核心特点
- 立即执行一次回调,并自动追踪回调内用到的所有响应式依赖。
- 依赖变化时,重新执行回调,无需手动指定源。
- 不能获取旧值,更适合"执行副作用"的场景。
参数与场景
onCleanup:同样支持副作用清理。flush:控制刷新时机,同watch。- 使用场景 :
- 需要根据多个响应式状态执行副作用,且不关心旧值。
- 动态同步 DOM 状态(如标题随数据变化:
document.title = state.title)。 - 连接第三方库(如调试日志)。
代码示例
vue
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('Vue')
const stop = watchEffect((onCleanup) => {
// 自动追踪 count 和 name
console.log(`count: ${count.value}, name: ${name.value}`)
onCleanup(() => {
// 清理,例如取消上一次操作
})
})
// 停止:stop()
</script>
watchEffect 与 watch 的核心差异
| 对比项 | watch | watchEffect |
|---|---|---|
| 追踪方式 | 显式指定源 | 自动追踪回调中的响应式依赖 |
| 立即执行 | 默认不执行,需 immediate: true |
默认立即执行 |
| 旧值 | 可获取 | 无法获取 |
| 适用场景 | 需要比较新旧值、精确控制监听的场景 | 多个依赖驱动一个副作用,或连接外部库 |
| 副作用清理 | 支持 onCleanup |
支持 onCleanup |
| 初始化时机 | immediate: true 时在创建时立即执行 |
立即执行 |
坑点:
watchEffect必须在setup或生命周期钩子中同步调用,不能在异步回调里创建 (除非配合effectScope),否则无法自动销毁。- 注意不要产生无意的循环:修改了内部依赖的数据 → 触发重新执行 → 再次修改 → 死循环。
三、computed(计算属性)
原理
Vue2/3 原理类似:内部创建一个惰性 Watcher/effect,标记 lazy: true。当依赖变化时,标记为脏数据,在下次读取时才重新计算,并缓存结果。
Vue2 Options 写法
javascript
computed: {
fullName() {
return this.firstName + ' ' + this.lastName
},
// 带 setter
fullName2: {
get() { return this.firstName + ' ' + this.lastName },
set(val) {
const names = val.split(' ')
this.firstName = names[0]
this.lastName = names[1]
}
}
}
Vue3 Composition API
vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// 只读计算
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// 可写计算
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => {
[firstName.value, lastName.value] = val.split(' ')
}
})
</script>
参数说明
- 第一种:传入一个 getter 函数,返回只读的
ComputedRef。 - 第二种:传入
{ get, set }对象,返回可写的ComputedRef。
使用场景
- 模板中需要从多个原始数据衍生出展示数据的场景(列表过滤、排序、合计)。
- 多个地方复用同一逻辑,避免重复计算。
- 依赖的数据可能不会经常变化,需要缓存机制。
注意事项与坑点
- 计算属性必须有返回值,且依赖必须是响应式数据,否则不会自动更新。
- 不要在 getter 中产生副作用 (如修改其他状态、异步请求),这会导致不可预测的行为。如果确实需要,改用
watch。 - 可写计算属性的 setter:修改时会触发,必须正确处理依赖数据。
- 依赖追踪深度 :计算属性只追踪同步访问的响应式属性,如果 getter 中使用了
try...catch或条件分支,未访问到的分支不会被追踪。 - 性能陷阱 :如果计算属性返回一个新对象(如数组 filter),每次依赖变化都会返回新引用,可能导致子组件不必要的重新渲染。在 Vue3 中使用
v-memo或子组件memo优化。
四、ref / reactive / 相关 API
1. ref 与 reactive 核心对比
| API | 使用方式 | 内部原理 | 模板解包 | 替换整个对象 |
|---|---|---|---|---|
ref |
包装基本类型或任意值,.value 访问 |
class RefImpl,持有 _value,用 getter/setter 触发依赖追踪 |
模板中自动解包,直接写变量名 | ref.value = newVal,仍保持响应式 |
reactive |
传入对象,返回 Proxy 代理 | Proxy 深度代理 |
模板中直接使用属性(代理本身不解包) | 直接 obj = newObj 会丢失响应性,必须用 Object.assign 或重新包装 |
代码示例:
vue
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0) // 基本类型
const arr = ref([1,2,3]) // 数组也可用 ref
const state = reactive({
user: { name: 'Alice' },
list: []
})
// 模板中
// {{ count }} 自动解包
// {{ state.user.name }} 正常访问
</script>
坑点解析:
-
ref自动解包的边界 :仅在模板渲染上下文和reactive对象内部属性中会自动解包。在普通对象或数组里,ref不会自动解包:javascriptconst a = ref(1) const obj = { a } // obj.a 是 RefImpl,不是 1,需要 .value -
解构
reactive对象会丢失响应式 :javascriptconst { user } = state // user 是一个普通对象,不再响应式 // 解决:使用 toRefs const { user } = toRefs(state) // user 变成 ref,保持响应式 -
reactive不能代理基本类型,只能处理对象/数组。 -
reactive根对象不可替换 :javascriptlet state = reactive({ count: 1 }) state = reactive({ count: 2 }) // 新的响应式对象,原引用丢失,视图不更新 // 正确:用 Object.assign(state, { count: 2 }) -
ref可以完全替代reactive吗? 可以,但大对象使用ref每次需要.value,模板内深层次访问会显得啰嗦。两者配合使用最佳。
2. toRef / toRefs
作用 :解构 reactive 对象时保持单个属性的响应式。
toRef(obj, key):为源对象上的某个属性创建一个 ref,与源对象保持同步(修改 ref 会影响源对象)。toRefs(obj):将对象所有属性转换为 ref 的普通对象,常用于返回组合式函数中的响应式状态。
vue
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 1, name: 'Vue' })
// 解构且保持响应式
const { count, name } = toRefs(state)
// count 现在是 Ref,在模板中 {{ count }} 自动解包
// 如果用 toRef 关联单个属性
const countRef = toRef(state, 'count')
</script>
坑点:
toRef创建的是一个"引用",修改countRef.value会直接修改源对象,但创建的新 ref 不会保持双向绑定?其实是双向的。- 如果源对象中该属性不存在,
toRef也会创建一个 ref,但设置值不会影响源对象(因为属性不存在),要用时确保属性已存在。
3. shallowRef / shallowReactive
原理 :只对 .value 的引用本身或根层属性做响应式处理,不进行深层递归代理。
用途:处理大型只读数据(如接口返回的巨型列表),避免深度响应式带来的性能开销。
javascript
import { shallowRef } from 'vue'
const bigData = shallowRef([...]) // 内部数组元素不是响应式
// 更新:必须通过替换整个 .value 触发更新
bigData.value = newData
// 如果只需修改内部某个元素,需做浅拷贝后整体替换
bigData.value = [
...bigData.value.slice(0, idx),
newItem,
...bigData.value.slice(idx + 1)
]
注意:
shallowRef与ref不能混用。如果在shallowRef内部包含ref元素,不会自动解包,视图中的.value不会消失。shallowReactive只监视第一层属性,深层属性变更不会触发更新。
4. readonly
用于保护数据不被修改,创建只读代理。Vue3 中常用于 provide 向下传递数据时,防止子组件直接修改。
javascript
import { reactive, readonly } from 'vue'
const state = reactive({ count: 0 })
const readOnlyState = readonly(state)
// readOnlyState.count++ 会警告并阻止
五、nextTick
原理
将回调推迟到下次 DOM 更新循环之后执行。Vue 内部使用微任务(Promise)队列实现,当数据改变后,视图更新是异步的,nextTick 保证回调在视图更新后执行。
使用场景
- 在修改数据后立即获取更新后的 DOM 尺寸。
- 与第三方库(如 Swiper)同步,需要 DOM 已存在时初始化。
代码示例
vue
<script setup>
import { ref, nextTick } from 'vue'
const msg = ref('Hello')
const pRef = ref(null)
function updateAndGetHeight() {
msg.value = 'Updated text'
// 此时 DOM 尚未更新
nextTick(() => {
console.log(pRef.value.offsetHeight) // 获取新高度
})
}
</script>
注意事项
nextTick可以返回 Promise,支持await nextTick(),这比回调更方便。- Vue2 中
nextTick实现会尝试降级到setTimeout(宏任务),Vue3 统一用 Promise(微任务)。 - 不要在
nextTick中修改会引起新一轮 DOM 更新的数据,这可能形成"更新-等待-再更新"的循环,虽然不阻塞,但浪费性能。
六、provide / inject
Vue2 与 Vue3 核心差异
- Vue2:
provide可传递普通对象或Vue.observable()包装的对象实现响应式。默认是非响应式的。 - Vue3:传递
ref、reactive对象即可自然响应式,并且推荐结合readonly防止子组件直接修改。
参数解析
provide(key, value):父组件提供数据。inject(key, defaultValue?):子组件注入。defaultValue可以是默认值或工厂函数。
使用场景
- 祖先组件向深层后代组件传递数据,避免层层
props传递。 - 封装组件库时,传递全局配置(主题、语言)。
- 替代 Vue2 中的 EventBus(可配合事件通信)。
Vue3 代码示例
vue
<!-- 祖父组件 -->
<script setup>
import { ref, provide, readonly } from 'vue'
const theme = ref('light')
provide('theme', readonly(theme)) // 只读,避免子组件篡改
// 如果需要允许修改,可以提供修改方法
provide('setTheme', (val) => { theme.value = val })
</script>
<!-- 孙子组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数为默认值
const setTheme = inject('setTheme', () => {})
// 使用:theme.value, setTheme('dark')
</script>
注意事项与坑点
- 响应式 :Vue3 中直接传递
ref或reactive即可,Vue2 必须用Vue.observable或传递对象里包含响应式数据。 - 可修改性 :如果允许子组件修改,建议通过提供方法(如
updateXxx)进行,而不是把ref直接暴露,便于集中控制和验证。 - 默认值 :
inject的默认值仅在祖先没有提供该 key 时生效;如果祖先显式提供了undefined,则不会使用默认值。 - Symbol 作为 key:推荐使用 Symbol 避免命名冲突,尤其在组件库中。
- provide 在
setup中同步调用,不能异步提供。
七、模板 ref 与 defineExpose(Vue3)
模板 ref
用于获取子组件实例或 DOM 元素。
vue
<template>
<input ref="inputRef" />
<ChildComponent ref="childRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './Child.vue'
const inputRef = ref(null)
const childRef = ref(null)
onMounted(() => {
inputRef.value.focus()
// childRef.value.xxx 只能访问子组件通过 defineExpose 暴露的内容
})
</script>
defineExpose
Vue3 的 <script setup> 组件默认是封闭的,不会暴露任何内部属性给模板 ref。需要主动暴露:
vue
<!-- Child.vue -->
<script setup>
import { ref, defineExpose } from 'vue'
const message = ref('hello')
const doSomething = () => { /* ... */ }
defineExpose({
message, // 暴露后父组件可通过 ref 访问
doSomething
})
</script>
坑点:
- Vue2 的
$refs可以访问子组件所有数据和方法,耦合度高;Vue3 默认安全,如果不写defineExpose,父组件拿不到任何东西。 - 模板 ref 在初次渲染时可能为 null,需要在
onMounted或后续更新中访问。 - 当
ref用在v-for生成的元素上时,绑定的 ref 会是一个数组(Vue3.2.25 以后),需要特别处理。
八、总结:高频面试中 API 的"灵魂拷问"
watchvswatchEffect:区别、何时用哪个?------上文已对比。computed能实现watch的功能吗? ------ 不能,computed 应无副作用且必须返回值。- 为什么 Vue3 的
ref在模板中自动解包,在reactive对象里也自动解包,但在普通对象里不行? ------ Vue 的响应式系统通过编译和代理规则实现解包,普通对象并非响应式代理,没有 getter/setter 辅助解包。 - 如果有一个巨大的数据对象只展示不修改,如何避免性能问题? ------ 使用
shallowRef或Object.freeze冻结数据,减少响应式拦截。 provide/inject如何在 Vue2 中实现响应式? ------ 传递Vue.observable(obj)或传递一个具有响应式属性的对象实例。