响应式状态或响应性变量,是那些在修改时会引发UI渲染的状态,vue的渲染机制就是自动追踪这些响应式状态的变化并更新视图的,vue3相比vue2,从选项式API转换为了组合式API,响应式状态也从自动拦截转变为了显式声明,并引入了组合式函数,这样会带来一个问题,要理解响应性变量如何声明,以及响应性是如何在组件(component)、组合式函数(composables)、store、provide/inject、props 等不同上下文的转换和传递的,这是一个重要的关键知识,在使用vue构建复杂应用和项目时,我们不自觉地会构建复杂的结构,为了让解构复杂的vue更容易掌握,有必要澄清和理解响应性变量的声明、响应性的传递、转换与作用域控制。
背景
vue3相比 vue2,在兼容 vue2 选项式 API 的基础上,提出了组合式 API 及 显式声明的响应式变量,从原来的选项式心智模型,改为组合函数实现关注点分离的心智模型。由此响应式变量被我们大量地在复杂页面中使用,但很容易就会有响应性丢失,本文尽量会澄清这一个点。
TL;DR:
核心结论
Vue3 中几乎所有"响应性丢失"的问题,本质只有一个:响应式链(Reactive Chain)被打断了。
所谓"响应式链",指的是:
数据变化 → 被追踪 → 触发副作用(视图更新 / watch / computed)
一旦这条链中任意一环断掉,更新就不会发生。
常见的断链方式只有三种:
- 解构(从 Proxy 中取出值)
- 传值(把响应式数据变成普通值)
- 提前执行(在依赖收集之外访问数据)
响应式链(Reactive Chain)是什么?
Vue 的响应式本质是一条"数据驱动执行链":
csharp
Reactive Source(ref / reactive)
↓
被读取(getter 触发依赖收集)
↓
建立依赖关系(Dependency Tracking)
↓
触发副作用(Effect:render / watch / computed)
也可以理解为:谁用到了这个数据,Vue 就记住谁;数据一变,就让这些地方重新执行。
展开就是:
- 你定义响应式数据(ref / reactive)
- 在某个地方读取它(模板 / computed / watch)
- Vue 记录"谁依赖了它"
- 数据变化时,重新执行这些地方
"要理解响应式传递,需要先掌握这几个基础概念(ref 的容器本质、getter 的依赖收集机制、副作用在 Vue 中的定义),下面逐一展开"。
背景知识
ref 和 setup 的语义
vue3 中重要的语义解释:
- ref(
reference(引用)- 源于引用 vs 值):"把一个普通的 JavaScript 值,包装成一个可通过引用访问的响应式容器" - setup
-
- 基本语义:设置、装配、准备
- vue 中的具体语义:"在组件实例化之前,设置所有响应式状态、逻辑和依赖的地方",逻辑的容器和组装场所
-
-
- setup 是一个"组装车间":从不同 composables 导入功能,像搭积木。组合它们,建立关联。
- setup 本质是一个纯函数(大部分时候)
-
-
- 时间语义:在组件"出生前"进行的准备工作(beforeCreate 之前)
ref vs reactive
ref 和 reactive 的关键差异:
- 数据类型支持:ref 支持任意数据类型,reactive 仅支持对象、数组、集合类型
- 访问方式:ref 用.value, reactive则直接访问属性
- 重新赋值与解构
-
- ref 重新赋值响应性不影响;reactive 的变量本身不是响应式引用,重新赋值会替换代理对象,从而断开响应链
- ref 的
.value被解构时:若值为对象/数组(已被 reactive 包装),属性访问仍保持响应性;若值为基本类型,解构后即为静态值,丢失响应性。(本质原因是解构是值拷贝,基本类型本身是非引用传递的);reactive 解构属性会直接失去响应性
- 底层:ref 是一个响应式容器(.value访问)(RefImpl);reactive 是原始对象的 proxy(代理)。
vue3 中 getter 的语义
理解 vue3 中的 getter 表达了什么语义:
- 本质上是一个普通函数,无任何特殊语法或语言级支持,语义上被约定为"响应式读取函数"。
- 作为依赖收集入口,通过延迟取值,不会丢失访问 proxy 属性的响应性
scss
watch(props.a, cb) // ❌ props.a 已经执行得到 a 的值了,watch 无法记录谁改变了
watch(() => props.a, cb) // ✅ // 通过执行函数读取属性,触发 Proxy 的依赖收集
前端领域的副作用(effect(效应)/side effect)概念上的再澄清
理解"副作用"(effect(效应))的概念和含义:
- 从纯函数编程的角度,副作用是指:一个函数除了返回计算结果之外,还对外部环境产生了可观测的变化
- 在 UI 框架的语境下,具体指:手动操作 DOM、网络请求、定时器/监听器、订阅外部数据源、修改全局状态
- React 与 Vue
-
- 在 React 中,渲染模型是纯函数模型,副作用是渲染流程的副作用 (Render -> Effect),副作用被 useEffect 显式隔离,
useEffect的语义是:在渲染提交到 DOM 之后,同步外部系统。e ffect 词义专指副作用(side effect)。 - 在 Vue 中,心智模型是响应式数据驱动,副作用是数据状态的副作用(State Change -> Effect),通过 watch/watchEffect/computed(侧重响应式效果)/生命周期钩子(广义)实现,是响应式链条的自然延伸,effect 在 vue 中语义更宽,可以理解为任何由数据变化触发的"再执行"都叫 effect。effect 词义兼指响应式效果(reactive effect)+ 副作用。
- 在 React 中,渲染模型是纯函数模型,副作用是渲染流程的副作用 (Render -> Effect),副作用被 useEffect 显式隔离,
响应式变量的声明
不同粒度的响应式变量声明
- shallowRef 浅层响应式,只有.value 是响应式的;shallowReactive 对象、数组、集合类型-浅层响应式
- ref 深层响应式- 所有层级都是响应式的(ref.value 如果是对象,会被 reactive 包装);reactive 对象、数组、集合类型-深层响应式
- readonly - 只读响应式 ;shallowReadonly 浅层-只读响应式
- customRef 读写 ref 响应式变量时的自定义控制能力(同步或控制依赖收集和派发更新的时机)
💡注意:
- shallowRef适用于大型对象/ DOM 引用等不需要深层响应化的场景,避免 Vue 递归遍历带来的性能开销。
- customRef用于需要自定义依赖收集/触发时机的场景,如防抖输入、手动控制渲染频率。
ref与reactive 相互使用时的注意点
- ref 在 reactive 中作为一个对象属性时会自动解包("对象解包、数组不解包"的不对称性)
ini
const form = reactive({name: ref(''),age: ref(0)})
form.name = 'tom' // 直接赋值,不需要 .value
const count = ref(0)
const state = reactive({ count })
// 反直觉的点:
state.count === 0 // true,不是 RefImpl!
state.count = 1 // 修改的是 ref 的 .value,count.value 变成 1
count.value === 1 // true
// 但数组和集合类型不会解包!
const arr = reactive([ref(0)])
arr[0] === 0 // false,arr[0] 是 RefImpl
arr[0].value === 0 // true
- ref 中放 reactive ❌ 不解包(也不应该解包)
scss
const state = reactive({ a: 0 })
const r = ref(state)
// r.value 就是 reactive proxy,不会进一步解包
console.log(isReactive(r.value)) // true
console.log(r.value.a) // 0
// 赋值:替换整个对象
r.value = { a: 1 } // ✅ 触发 ref 更新
console.log(state.a) // 0 ← 还是 0!state 和 r.value 现在指向不同对象
// 或不赋值直接修改属性
r.value.a = 2 // ✅ 触发更新,且 state.a 也变成 2(因为引用相同)
- ref 中放嵌套 ref ❌ 不解包
kotlin
const inner = ref(0)
const outer = ref({ inner })
// 不会递归解包!outer.value.inner 仍然是 RefImpl
console.log(outer.value.inner) // RefImpl
console.log(outer.value.inner.value) // 0
// 修改
outer.value.inner.value = 1 // ✅ 触发 inner 的更新
outer.value.inner = ref(2) // ✅ 替换 inner ref 本身,outer 也触发
响应式变量转换工具
- unref 返回 ref 的内部值(.value的值),即解包 ref(得到原值即失去容器层的响应性)。
-
- 若 ref 包装的是对象,由于 Vue 会自动将其转为
reactive,unref后得到的是一个 reactive proxy,其属性变化仍具有响应性(值层的响应性依然被保留)。
- 若 ref 包装的是对象,由于 Vue 会自动将其转为
- toRef 从响应式对象中提取单个属性并将其转换为 ref (得到的 ref 并不是"新的响应式变量",而是一个"指向原属性的引用(代理 Ref)",本质上是创建了一个与源属性双向绑定的 ref,修改它会写回原对象)
- toRefs 将响应式对象的所有属性都转换为 ref,让 reactive 的所有属性在解构后依然可以保持响应性
- toRaw 将一个由
reactive或readonly创建的 Proxy 代理对象 还原为它最初的普通对象(剥离代理) - toValue(Vue 3.3+)将 MaybeRefOrGetter 统一规范化为值。 是 unref 的增强版:unref 只处理 ref,而 toValue 额外支持 getter 函数------ 若传入函数则执行并返回结果,若传入 ref 则返回 .value,若传入普通值则原样返回。
less
// 如何获取响应式变量的原始副本?
// 获取一个响应式变量的原始值,修改时虽然不会引起视图更新,但可能污染原始响应变量
const rawData = toRaw(unref(foo))
// 获取一个纯粹的响应式变量原始值的副本
// structuredClone 对于包含函数、Symbol、DOM 节点等的对象会抛错,
// 建议使用使用更安全的工具函数(如 lodash 的 cloneDeep)
const safeData1 = structuredClone(toRaw(unref(foo)));
// OR
const safeData2 = JSON.parse(JSON.stringify(toRaw(unref(foo))));
💡注意:
toRef(obj, key)在key不存在时,会创建一个可写的 ref,并且赋值时会自动在对象上创建该属性。toRefs对不存在的属性会返回ref(undefined),但不会自动追踪后续添加(除非你重新执行 toRefs)toValue本身不具备响应性,它只是"取当前值"的快照操作。 需要响应性时,要把 toValue 包在 computed 或 watchEffect 内部执行, 而不是在 setup 顶层直接调用。
响应式变量判断工具
- isRef 检查某个值是否为 ref
- isReactive 检查一个对象是否是由
reactive()或shallowReactive()创建的代理。 - isProxy 检查一个对象是否是由
reactive()、readonly()、shallowReactive()或shallowReadonly()创建的代理。 - isReadonly 检查传入的值是否为只读对象。
响应式派生值------computed
Derived State & Effects (响应式衍生)
- 它的值完全由其他响应式数据计算而来。
- 只有当它依赖的数据(依赖)发生变化时,它才会重新计算。(读取时惰性计算)
响应式副作用------watch/watchEffect
Reactive Side Effects
观察一个已有的响应式变量,当它变化时,执行一个副作用函数。
watch VS watchEffect
watch默认是惰性的(创建时不执行,等依赖变化后执行),且能拿到oldValue;watchEffect会立即执行(在创建时会立即执行一次,用于收集依赖。)且不需要显式声明依赖。
watch 与 reactive
- watch 的值直接传 reactive 对象,默认深度监听 (性能一般)
- watch 的值传 getter(reactive),仅 reactive 对象本身修改时触发
- watch 的值 getter 具体属性时,精确监听 (性能最好)
javascript
const obj = reactive({ nested: { count: 0 } })
// 情况 A:直接传 reactive 对象
watch(obj, (newVal, oldVal) => {
console.log('changed')
})
obj.nested.count++ // 会触发!默认深度监听
obj.nested = { count: 99 } // 也会触发
// 情况 B:传 getter
watch(() => obj, (newVal, oldVal) => {
console.log('changed')
})
obj.nested.count++ // 不会触发!getter 返回的是对象引用,引用没变
obj.nested = { count: 99 } // 也不会触发,除非 obj 本身被替换
// 情况 C:传具体属性的 getter
watch(() => obj.nested.count, (newVal, oldVal) => {
console.log('count changed:', newVal, oldVal)
})
obj.nested.count++ // 会触发,精确监听
- 提前执行导致无法监听
javascript
const obj = reactive({ name: 'hello' })
// ❌ 情况 D:值在传入 watch 前就取值了
watch(obj.name, (newVal) => {
console.log('changed:', newVal)
})
obj.name = 'world' // 不会触发!因为 watch 接收到的是一个静态字符串 'hello'
// 正确写法:
watch(() => obj.name, (newVal) => {
console.log('changed:', newVal)
})
props中的场景
- props 本身是 reactive,访问属性不需要
.value。 - 解构props 会失去响应性(类似于解构 reactive(proxy)),如果需要解构属性依然保持响应性,需要使用 toRefs将 props 所有属性包装为 ref 后进行解构。
-
- Vue 3.5 已经引入了"响应式 Props 解构"。以前解构
const { count } = props会丢失响应性,但在 Vue 3.5 中,通过编译器优化,解构后的变量依然具有响应性。(仅在<script setup>中生效,且依赖编译器支持)
- Vue 3.5 已经引入了"响应式 Props 解构"。以前解构
- 将 props 属性赋值给普通变量,该变量也会失去响应性(响应链就会断裂)
- 子组件内基于属性的计算值使用 computed 可以保持同步更新
scss
// ❌ 提前执行:props.userId 在 setup 顶层取值,此时是静态快照
const id = props.userId
useUserData(id)
// ✅ 保留响应式源
useUserData(toRef(props, 'userId'))
双向绑定
在组件上下文的转换中,defineModel (vue3.4+ )是目前处理父子组件双向绑定的"标准答案",它极大地简化了响应性在 props 和 emit 之间的手动传递过程,
xml
<!-- 之前的双向绑定写法 -->
<!-- 子组件 Child.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function onInput(val) {
emit('update:modelValue', val)
}
</script>
<!-- defineModel 新写法 -->
<script setup>
const modelValue = defineModel() // 直接得到一个可双向绑定的 ref
const title = defineModel('title') // 得到一个双向绑定的 ref
</script>
<!-- 父组件 -->
<template>
<Child v-model="searchText" v-model:title='customTitle'/>
</template>
💡注意:
- defineModel 不建议使用对象初始化:
-
v-model的语义是"值绑定",但对象 ref 让子组件能深层修改父组件状态,且不触发emit事件,调试时很难追踪变化来源。defineModel的设计初衷是替代modelValue的样板代码,不是让你把整棵树塞进去。对象 ref 的深层响应性会让"谁修改了状态"变成黑盒,违背 Vue 的显式响应式哲学。- 深层修改不触发 emit
组合式函数中的工程建议:
- 参数支持普通值,也支持
ref/computed,即参数 支持 MaybeRef / MaybeRefOrGetter 类型
typescript
import type { MaybeRef } from 'vue' // Vue 3.3+ 内置
// 或自定义:type MaybeRef<T> = T | Ref<T>
function useDemo(value: MaybeRef<string>) {
const normalizedValue = computed(() => unref(value))
watch(normalizedValue, (val) => {
console.log(val)
})
}
组合式函数(composables)中的场景
- 将 props.a 传递给组合式函数作为参数时,此时参数为非响应式(proxy get 取到静态值),想要保持响应性应该使用 toRef 语法进行转换(或 computed),等价于传入了 ref
- 组合式函数的参数为响应式对象(reactive)时,内部解构该参数会发生响应性丢失,使用 ref 是属性变为响应性变量
- 在组合式函数嵌套组合式函数的场景中,为了确保响应式函数的响应性正常传递,不要传递.value ,不要提前 unref,不要直接传 props.xx, 应始终传递 ref 或 computed(响应式源)或 getter,
有关pinia 中的场景
- Pinia 的 store 本质就是一个 被 Vue
reactive包装的单例对象,解构会发生响应性丢失,尽量直接使用 fooStore.xx,如果需要可使用storeToRefs包装后进行解构(类似 toRefs),同时也支持单个属性的响应式转换(toRef);actions (方法)可以直接解构。 - 向组合式函数传递参数时,如果直接使用 fooStore.bar 同样会发生响应性丢失,应使用 getter 或 toRef
- 组合式函数的副作用会随组件销毁而自动清理,而 pinia 中的副作用必要时需要 effectScope 手动管理(全局单例,有内存泄露风险)
- Pinia 和组合式函数/组件的实践推荐:数据源放 pinia(pinia 不要写太多的副作用),副作用放组合式函数或组件(放 pinia 中有内存泄露风险、测试困难、状态难以追踪等问题)
有关provide/inject中的场景
provide/inject 是 Vue 提供的跨层级组件通信机制,本质是在祖先组件中向下"注入"数据,避免 props 逐层透传(prop drilling)。
核心原则:provide 响应式源,而不是值。
- provide 时传入 ref,而非.value; inject 一个 reactive(provide 的为 reactive)时,解构会失去响应式(同解构 reactive)
- 直接provide响应式数据,会造成数据流难以追踪。推荐的做法是:祖先 provide 只读版本(readonly),同时 provide 修改方法
effect 作用域 (effectScope)
副作用作用域 / effectScope
- 介绍
在 Vue 的响应式系统中,像 watch、watchEffect、computed 这种函数都会创建一个 Effect(副作用对象) 。这些副作用会被收集并监听数据的变化。
Effect 作用域 就是一个可以"捕获"在它内部创建的所有副作用的容器。当你销毁这个"容器"时,里面所有的 watch、computed 都会被一键停止。
- vue 为什么需要 effectScope
-
- 在 vue 组件内,当组件销毁时会自动清理这些 effect
- 但 vue 的响应式系统可以脱离组件运行,比如组合式函数 和 store等,都可以在非组件上下文中使用,此时就需要 effectScope 来收集副作用,并可使用 stop 方法快速清理副作用。
- 可控制非组件上下文中的副作用生命周期,pinia 中,每个 Store 都运行在一个
effectScope中。
- 示例:
-
- pinia 中的副作用管理
javascript
// stores/user.ts
import { defineStore } from 'pinia'
import { effectScope, ref, watch, type EffectScope, onScopeDispose } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref('')
let scope: EffectScope | null = null
let refreshTimer: ReturnType<typeof setInterval> | null = null
function startWatchToken() {
if (scope?.active) return
// 清理残留
stopWatchToken()
scope = effectScope()
scope.run(() => {
// token 变化时自动刷新用户信息
watch(token, (newToken, oldToken) => {
if (!newToken) return
// 清理旧定时器,防止多个 token 并行刷新
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
// 立即刷新一次
refreshUserInfo(newToken)
// 每 30 秒自动刷新
refreshTimer = setInterval(() => {
refreshUserInfo(newToken)
}, 30000)
console.log('token changed:', newToken.slice(0, 10) + '...', 'from:', oldToken?.slice(0, 10) + '...')
}, { immediate: true })
// 核心:scope 销毁时自动清理定时器
onScopeDispose(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
})
}
function stopWatchToken() {
scope?.stop()
scope = null
// 注意:refreshTimer 在 onScopeDispose 中已清理,这里不需要重复处理
}
async function refreshUserInfo(token: string) {
// 实际刷新逻辑...
}
return {
token,
startWatchToken,
stopWatchToken
}
})
b. 非组件上下文中组合式函数
typescript
// services/polling.ts
import {
effectScope, ref, watchEffect, type EffectScope,
onScopeDispose, type Ref
} from 'vue'
export interface PollingService {
count: Ref<number>
start: () => void
stop: () => void
isRunning: Ref<boolean>
}
export function createPollingService(interval = 1000): PollingService {
const count = ref(0)
const isRunning = ref(false)
let scope: EffectScope | null = null
function start() {
// 防御:防止重复启动
if (scope?.active) return
// 清理可能存在的残留状态
stop()
scope = effectScope()
try {
scope.run(() => {
// 定时器在 scope 内部创建,确保和副作用生命周期绑定
const timer = setInterval(() => {
count.value++
}, interval)
// 核心修复:用 onScopeDispose 清理定时器,而不是依赖外部 stop 手动清理
onScopeDispose(() => {
clearInterval(timer)
isRunning.value = false
})
watchEffect(() => {
console.log('current count:', count.value)
})
isRunning.value = true
})
} catch (err) {
scope.stop()
scope = null
throw err
}
}
function stop() {
// 安全停止:即使 scope 不存在或已停止,也不会报错
if (scope?.active) {
scope.stop() // 这会触发内部所有的 onScopeDispose
}
scope = null
// 注意:isRunning 在 onScopeDispose 中已设为 false,这里不需要重复设置
}
return {
count,
isRunning,
start,
stop
}
}
// 使用示例
const polling = createPollingService(2000)
polling.start()
// 2秒后
polling.stop() // 所有副作用 + 定时器一次性清理
// 异常安全:如果 start 时抛错,不会泄漏定时器
小结
理解 Vue3 响应性,其实只需要记住一句话:不要让数据离开"响应式链"。
工程上可以归纳为三条规则:
- 不要解构 reactive / props(除非 toRefs)
- 不要传递值(传 ref / getter)
- 不要提前执行(保持访问发生在 effect 中)