Vue3 响应性:跨上下文的传递、转换与作用域控制

响应式状态或响应性变量,是那些在修改时会引发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)

一旦这条链中任意一环断掉,更新就不会发生。

常见的断链方式只有三种:

  1. 解构(从 Proxy 中取出值)
  2. 传值(把响应式数据变成普通值)
  3. 提前执行(在依赖收集之外访问数据)

响应式链(Reactive Chain)是什么?

Vue 的响应式本质是一条"数据驱动执行链":

csharp 复制代码
Reactive Source(ref / reactive)
        ↓
被读取(getter 触发依赖收集)
        ↓
建立依赖关系(Dependency Tracking)
        ↓
触发副作用(Effect:render / watch / computed)

也可以理解为:谁用到了这个数据,Vue 就记住谁;数据一变,就让这些地方重新执行。

展开就是:

  1. 你定义响应式数据(ref / reactive)
  2. 在某个地方读取它(模板 / computed / watch)
  3. Vue 记录"谁依赖了它"
  4. 数据变化时,重新执行这些地方

"要理解响应式传递,需要先掌握这几个基础概念(ref 的容器本质、getter 的依赖收集机制、副作用在 Vue 中的定义),下面逐一展开"。

背景知识

ref 和 setup 的语义

vue3 中重要的语义解释:

  1. ref(reference(引用)- 源于引用 vs 值):"把一个普通的 JavaScript 值,包装成一个可通过引用访问的响应式容器"
  2. setup
    1. 基本语义:设置、装配、准备
    2. vue 中的具体语义:"在组件实例化之前,设置所有响应式状态、逻辑和依赖的地方",逻辑的容器和组装场所
      1. setup 是一个"组装车间":从不同 composables 导入功能,像搭积木。组合它们,建立关联。
      2. setup 本质是一个纯函数(大部分时候)
    1. 时间语义:在组件"出生前"进行的准备工作(beforeCreate 之前)

ref vs reactive

ref 和 reactive 的关键差异:

  1. 数据类型支持:ref 支持任意数据类型,reactive 仅支持对象、数组、集合类型
  2. 访问方式:ref 用.value, reactive则直接访问属性
  3. 重新赋值与解构
    1. ref 重新赋值响应性不影响;reactive 的变量本身不是响应式引用,重新赋值会替换代理对象,从而断开响应链
    2. ref 的 .value 被解构时:若值为对象/数组(已被 reactive 包装),属性访问仍保持响应性;若值为基本类型,解构后即为静态值,丢失响应性。(本质原因是解构是值拷贝,基本类型本身是非引用传递的);reactive 解构属性会直接失去响应性
  1. 底层:ref 是一个响应式容器(.value访问)(RefImpl);reactive 是原始对象的 proxy(代理)。

vue3 中 getter 的语义

理解 vue3 中的 getter 表达了什么语义:

  1. 本质上是一个普通函数,无任何特殊语法或语言级支持,语义上被约定为"响应式读取函数"。
  2. 作为依赖收集入口,通过延迟取值,不会丢失访问 proxy 属性的响应性
scss 复制代码
watch(props.a, cb) // ❌ props.a 已经执行得到 a 的值了,watch 无法记录谁改变了
watch(() => props.a, cb) // ✅ // 通过执行函数读取属性,触发 Proxy 的依赖收集

前端领域的副作用(effect(效应)/side effect)概念上的再澄清

理解"副作用"(effect(效应))的概念和含义:

  1. 从纯函数编程的角度,副作用是指:一个函数除了返回计算结果之外,还对外部环境产生了可观测的变化
  2. 在 UI 框架的语境下,具体指:手动操作 DOM、网络请求、定时器/监听器、订阅外部数据源、修改全局状态
  3. React 与 Vue
    1. 在 React 中,渲染模型是纯函数模型,副作用是渲染流程的副作用 (Render -> Effect),副作用被 useEffect 显式隔离,useEffect 的语义是:在渲染提交到 DOM 之后,同步外部系统。e ffect 词义专指副作用(side effect)。
    2. 在 Vue 中,心智模型是响应式数据驱动,副作用是数据状态的副作用(State Change -> Effect),通过 watch/watchEffect/computed(侧重响应式效果)/生命周期钩子(广义)实现,是响应式链条的自然延伸,effect 在 vue 中语义更宽,可以理解为任何由数据变化触发的"再执行"都叫 effect。effect 词义兼指响应式效果(reactive effect)+ 副作用。

响应式变量的声明

不同粒度的响应式变量声明

  1. shallowRef 浅层响应式,只有.value 是响应式的;shallowReactive 对象、数组、集合类型-浅层响应式
  2. ref 深层响应式- 所有层级都是响应式的(ref.value 如果是对象,会被 reactive 包装);reactive 对象、数组、集合类型-深层响应式
  3. readonly - 只读响应式 ;shallowReadonly 浅层-只读响应式
  4. customRef 读写 ref 响应式变量时的自定义控制能力(同步或控制依赖收集和派发更新的时机)

💡注意:

  1. shallowRef适用于大型对象/ DOM 引用等不需要深层响应化的场景,避免 Vue 递归遍历带来的性能开销。
  2. customRef用于需要自定义依赖收集/触发时机的场景,如防抖输入、手动控制渲染频率。

ref与reactive 相互使用时的注意点

  1. 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
  1. 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(因为引用相同)
  1. 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 也触发

响应式变量转换工具

  1. unref 返回 ref 的内部值(.value的值),即解包 ref(得到原值即失去容器层的响应性)。
    1. 若 ref 包装的是对象,由于 Vue 会自动将其转为 reactiveunref 后得到的是一个 reactive proxy,其属性变化仍具有响应性(值层的响应性依然被保留)。
  1. toRef 从响应式对象中提取单个属性并将其转换为 ref (得到的 ref 并不是"新的响应式变量",而是一个"指向原属性的引用(代理 Ref)",本质上是创建了一个与源属性双向绑定的 ref,修改它会写回原对象)
  2. toRefs 将响应式对象的所有属性都转换为 ref,让 reactive 的所有属性在解构后依然可以保持响应性
  3. toRaw 将一个由 reactivereadonly 创建的 Proxy 代理对象 还原为它最初的普通对象(剥离代理)
  4. 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))));

💡注意:

  1. toRef(obj, key)key 不存在时,会创建一个可写的 ref,并且赋值时会自动在对象上创建该属性。
  2. toRefs 对不存在的属性会返回 ref(undefined),但不会自动追踪后续添加(除非你重新执行 toRefs)
  3. toValue 本身不具备响应性,它只是"取当前值"的快照操作。 需要响应性时,要把 toValue 包在 computed 或 watchEffect 内部执行, 而不是在 setup 顶层直接调用。

响应式变量判断工具

  1. isRef 检查某个值是否为 ref
  2. isReactive 检查一个对象是否是由 reactive()shallowReactive() 创建的代理。
  3. isProxy 检查一个对象是否是由 reactive()readonly()shallowReactive()shallowReadonly() 创建的代理。
  4. isReadonly 检查传入的值是否为只读对象。

响应式派生值------computed

Derived State & Effects (响应式衍生)

  1. 它的值完全由其他响应式数据计算而来。
  2. 只有当它依赖的数据(依赖)发生变化时,它才会重新计算。(读取时惰性计算)

响应式副作用------watch/watchEffect

Reactive Side Effects

观察一个已有的响应式变量,当它变化时,执行一个副作用函数。

watch VS watchEffect

  1. watch 默认是惰性的(创建时不执行,等依赖变化后执行),且能拿到 oldValue
  2. watchEffect 会立即执行(在创建时会立即执行一次,用于收集依赖。)且不需要显式声明依赖。

watch 与 reactive

  1. watch 的值直接传 reactive 对象,默认深度监听 (性能一般)
  2. watch 的值传 getter(reactive),仅 reactive 对象本身修改时触发
  3. 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++         // 会触发,精确监听
  1. 提前执行导致无法监听
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中的场景

  1. props 本身是 reactive,访问属性不需要 .value
  2. 解构props 会失去响应性(类似于解构 reactive(proxy)),如果需要解构属性依然保持响应性,需要使用 toRefs将 props 所有属性包装为 ref 后进行解构。
    1. Vue 3.5 已经引入了"响应式 Props 解构"。以前解构 const { count } = props 会丢失响应性,但在 Vue 3.5 中,通过编译器优化,解构后的变量依然具有响应性。(仅在 <script setup> 中生效,且依赖编译器支持)
  1. 将 props 属性赋值给普通变量,该变量也会失去响应性(响应链就会断裂)
  2. 子组件内基于属性的计算值使用 computed 可以保持同步更新
scss 复制代码
// ❌ 提前执行:props.userId 在 setup 顶层取值,此时是静态快照
const id = props.userId
useUserData(id)

// ✅ 保留响应式源
useUserData(toRef(props, 'userId'))

双向绑定

在组件上下文的转换中,defineModel (vue3.4+ )是目前处理父子组件双向绑定的"标准答案",它极大地简化了响应性在 propsemit 之间的手动传递过程,

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>

💡注意:

  1. defineModel 不建议使用对象初始化:
    1. v-model 的语义是"值绑定",但对象 ref 让子组件能深层修改父组件状态,且不触发 emit 事件,调试时很难追踪变化来源。
    2. defineModel 的设计初衷是替代 modelValue 的样板代码,不是让你把整棵树塞进去。对象 ref 的深层响应性会让"谁修改了状态"变成黑盒,违背 Vue 的显式响应式哲学。
    3. 深层修改不触发 emit

组合式函数中的工程建议:

  1. 参数支持普通值,也支持 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)中的场景

  1. 将 props.a 传递给组合式函数作为参数时,此时参数为非响应式(proxy get 取到静态值),想要保持响应性应该使用 toRef 语法进行转换(或 computed),等价于传入了 ref
  2. 组合式函数的参数为响应式对象(reactive)时,内部解构该参数会发生响应性丢失,使用 ref 是属性变为响应性变量
  3. 在组合式函数嵌套组合式函数的场景中,为了确保响应式函数的响应性正常传递,不要传递.value ,不要提前 unref,不要直接传 props.xx, 应始终传递 ref 或 computed(响应式源)或 getter,

有关pinia 中的场景

  1. Pinia 的 store 本质就是一个 被 Vue reactive 包装的单例对象,解构会发生响应性丢失,尽量直接使用 fooStore.xx,如果需要可使用storeToRefs包装后进行解构(类似 toRefs),同时也支持单个属性的响应式转换(toRef);actions (方法)可以直接解构。
  2. 向组合式函数传递参数时,如果直接使用 fooStore.bar 同样会发生响应性丢失,应使用 getter 或 toRef
  3. 组合式函数的副作用会随组件销毁而自动清理,而 pinia 中的副作用必要时需要 effectScope 手动管理(全局单例,有内存泄露风险)
  4. Pinia 和组合式函数/组件的实践推荐:数据源放 pinia(pinia 不要写太多的副作用),副作用放组合式函数或组件(放 pinia 中有内存泄露风险、测试困难、状态难以追踪等问题)

有关provide/inject中的场景

provide/inject 是 Vue 提供的跨层级组件通信机制,本质是在祖先组件中向下"注入"数据,避免 props 逐层透传(prop drilling)。

核心原则:provide 响应式源,而不是值。

  1. provide 时传入 ref,而非.value; inject 一个 reactive(provide 的为 reactive)时,解构会失去响应式(同解构 reactive)
  2. 直接provide响应式数据,会造成数据流难以追踪。推荐的做法是:祖先 provide 只读版本(readonly),同时 provide 修改方法

effect 作用域 (effectScope)

副作用作用域 / effectScope

  1. 介绍

在 Vue 的响应式系统中,像 watchwatchEffectcomputed 这种函数都会创建一个 Effect(副作用对象) 。这些副作用会被收集并监听数据的变化。

Effect 作用域 就是一个可以"捕获"在它内部创建的所有副作用的容器。当你销毁这个"容器"时,里面所有的 watchcomputed 都会被一键停止。

  1. vue 为什么需要 effectScope
    1. 在 vue 组件内,当组件销毁时会自动清理这些 effect
    2. 但 vue 的响应式系统可以脱离组件运行,比如组合式函数 和 store等,都可以在非组件上下文中使用,此时就需要 effectScope 来收集副作用,并可使用 stop 方法快速清理副作用。
    3. 可控制非组件上下文中的副作用生命周期,pinia 中,每个 Store 都运行在一个 effectScope 中。
  1. 示例:
    1. 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 响应性,其实只需要记住一句话:不要让数据离开"响应式链"。

工程上可以归纳为三条规则:

  1. 不要解构 reactive / props(除非 toRefs)
  2. 不要传递值(传 ref / getter)
  3. 不要提前执行(保持访问发生在 effect 中)
相关推荐
掘金安东尼1 小时前
开源小工具:掘金福利页「补签卡」按次数自动兑换(Chrome 扩展)
前端·开源
Mike_jia1 小时前
Sirius Scan:开源漏洞扫描利器,重塑企业安全防护体系
前端
知兀1 小时前
【前端】默认导出和命名导出区别
前端
XS0301062 小时前
Servlet+JQuery实现数据库数据渲染到前端页面
前端·servlet·jquery
van久2 小时前
Day27:菜单管理 + 动态路由(前端可直接用!)
前端·状态模式
恋猫de小郭2 小时前
DeepSeek V4 Flash 可以在 128GB 的 M3 Max 运行,还是 1M 上下文
前端·人工智能·ai编程
van久2 小时前
企业级后台管理系统(结合前 4 周全部内容)详细需求文档 + 前端模板适配
前端
Lsx_2 小时前
H5 嵌入微信 / 支付宝 / 抖音小程序 WebView:调用原生能力完整方案
前端·微信小程序·webview
Cobyte2 小时前
大模型 MCP 本质原理:从协议到代码实现
前端·aigc·ai编程