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 会自动将其转为 reactive,unref 后得到的是一个 reactive proxy,其属性变化仍具有响应性(值层的响应性依然被保留)。
  1. toRef 从响应式对象中提取单个属性并将其转换为 ref (得到的 ref 并不是"新的响应式变量",而是一个"指向原属性的引用(代理 Ref)",本质上是创建了一个与源属性双向绑定的 ref,修改它会写回原对象)
  2. toRefs 将响应式对象的所有属性都转换为 ref,让 reactive 的所有属性在解构后依然可以保持响应性
  3. toRaw 将一个由 reactive 或 readonly 创建的 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+ )是目前处理父子组件双向绑定的"标准答案",它极大地简化了响应性在 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>

💡注意:

  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 的响应式系统中,像 watch、watchEffect、computed 这种函数都会创建一个 Effect(副作用对象) 。这些副作用会被收集并监听数据的变化。

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

  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 中)
相关推荐
福兮说几秒前
URL 编码的七个坑:c++ 传到后端变空格、%25 套娃、截断 emoji 直接报错
开发语言·前端·javascript·node.js·url
吴声子夜歌6 分钟前
Nginx应用与运维——Nginx Web服务应用实战(Python网站的搭建)
运维·前端·nginx
小的时候可菜了24 分钟前
JS 异步的本质:从单线程到 thenable(一条进化链)
开发语言·前端·javascript
liangshanbo121530 分钟前
Webpack vs Vite 底层原理高频追问的问题
前端·webpack·node.js
web打印社区1 小时前
Windows 网页静默打印设置步骤:客户端、防火墙与联调清单
开发语言·前端·javascript·chrome·pdf·ecmascript
恋猫de小郭1 小时前
KMP 又改了编译流程, Separate Compilation 禁止了 `commonMain` 的依赖穿透
android·前端·flutter
吴声子夜歌1 小时前
Nginx应用与运维——Nginx Web服务应用实战(静态文件服务器的搭建)
运维·前端·nginx
y = xⁿ1 小时前
关于Agent工程落地
前端·人工智能·python
web打印社区2 小时前
JS 静默打印怎么做:纯前端为什么不行,以及最小可跑通写法
开发语言·前端·javascript·websocket·网络协议·http·pdf
web打印社区2 小时前
HTML5 静默打印:前端页面怎么调用打印机且尽量不弹窗
前端·javascript·vue.js·pdf·html·html5