Vue 基础体系 · 第 4/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪

Vue 的响应式系统要解决的问题可以形式化为:

当状态 S 发生变化时,自动找到依赖 S 的副作用 E,并在合适的时机重新执行 E

这里的“副作用”包括:

  • 组件渲染函数:状态变化后更新 DOM;
  • computed 的派生值:依赖变化后失效;
  • watch 回调:监听到变化后执行逻辑;
  • watchEffect:重新执行读取过响应式状态的函数。

refreactive 负责让数据具备可追踪的读写行为,computed 负责缓存派生数据,watch 负责监听变化并执行副作用。它们都建立在同一套“依赖收集与触发”机制上。


一、先建立模型:什么是依赖追踪

设有一个状态对象:

const state = {
  count: 0
}

以及一个副作用函数:

function render() {
  console.log(state.count)
}

如果希望 state.count++ 后自动重新执行 render,系统至少需要完成四件事:

  1. 执行 render
  2. 发现执行期间读取了 state.count
  3. 建立 state.count -> render 的依赖关系;
  4. state.count 被修改时,找到并重新执行 render

可以用一个依赖图表示:

flowchart LR
    A["state.count"] --> B["render effect"]
    A --> C["computed effect"]
    A --> D["watch callback"]

    E["state.count = 1"] --> F["trigger"]
    F --> B
    F --> C
    F --> D

1. track:读取时收集依赖

当响应式对象的属性被读取时,Vue 会执行类似下面的逻辑:

track(target, key)

其中:

  • target 是被读取的对象;
  • key 是属性名;
  • 当前正在执行的副作用函数会被记录下来。

概念上可以把依赖表写成:

WeakMap<object, Map<PropertyKey, Set<ReactiveEffect>>>

对应关系是:

target
  └── key
       └── 依赖该属性的 effect 集合

例如:

state
  └── count
       └── renderEffect

2. trigger:修改时触发依赖

当响应式属性被修改时,Vue 会执行类似:

trigger(target, key)

它会找到依赖表中与 targetkey 对应的 effect,然后根据 effect 的调度策略执行、缓存失效或加入更新队列。

3. 正在执行的 effect 才能被收集

依赖收集不是“扫描整个函数源码”,而是运行时收集。

下面的读取会被收集:

const state = reactive({ count: 0 })

watchEffect(() => {
  console.log(state.count)
})

执行 watchEffect 的函数时,state.count 被读取,因此建立依赖。

而下面的代码不会收集 state.count

const value = state.count

watchEffect(() => {
  console.log(value)
})

state.count 的读取发生在 watchEffect 开始之前。后面的 effect 只读取普通变量 value,Vue 无法知道这个普通变量原来来自哪个响应式属性。

这也是响应式依赖追踪的基本条件:

依赖关系=effect 执行期间发生的响应式读取\text{依赖关系} = \text{effect 执行期间发生的响应式读取}

不是所有访问都能被追踪,只有在响应式上下文中发生的访问才会进入依赖图。


二、effect、调度器与更新时机

可以把一个响应式副作用抽象成:

type Effect = () => void

但 Vue 实际上还需要记录:

  • 当前正在执行的 effect;
  • effect 读取过哪些依赖;
  • effect 是否已经在执行;
  • effect 是否有 scheduler;
  • effect 是否应该立即执行或排队执行。

1. 为什么需要 scheduler

如果连续修改多个状态:

state.firstName = 'Ada'
state.lastName = 'Lovelace'

Vue 通常不会同步执行两次组件更新,而是把相关更新放入队列,在当前同步代码结束后批量处理。这样可以避免同一轮代码中重复计算和重复更新 DOM。

可以简化为:

同步修改状态
    ↓
trigger 找到相关 effect
    ↓
scheduler 将 effect 放入队列
    ↓
当前同步任务结束
    ↓
批量执行队列中的 effect
    ↓
更新 DOM

因此,下面的代码不能简单理解为“赋值后 DOM 立刻更新”:

state.count++

console.log(document.querySelector('#count')?.textContent)

如果依赖该状态的 DOM 更新尚未执行,读取到的可能仍是旧内容。需要使用:

import { nextTick } from 'vue'

state.count++
await nextTick()
console.log(document.querySelector('#count')?.textContent)

nextTick 等待的是 Vue 已经安排的更新批次,而不是等待任意异步任务。

2. effect 嵌套与依赖清理

computedwatch 可能在一个 effect 中读取另一个响应式值,因此存在嵌套执行:

const count = ref(1)

const doubled = computed(() => count.value * 2)

watchEffect(() => {
  console.log(doubled.value)
})

外层 effect 读取 doubled.value,而 computed 内部又读取 count.value。Vue 需要正确切换当前 active effect,否则可能把外层 effect 错误地登记到 count 的内部执行过程,或在嵌套结束后丢失上下文。

当 effect 条件性读取不同属性时,还需要清理旧依赖:

const state = reactive({
  enabled: true,
  a: 1,
  b: 2
})

watchEffect(() => {
  console.log(state.enabled ? state.a : state.b)
})

第一次执行依赖:

enabled、a

如果之后 enabled = false,重新执行后应该变为依赖:

enabled、b

如果不清理旧依赖,后续修改 a 仍可能错误地触发这个 effect。


三、reactive:通过 Proxy 代理对象

reactive 接收对象、数组、Map、Set 等可代理值,返回一个响应式代理对象。

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: {
    name: 'Ada'
  }
})

state.count++
state.user.name = 'Grace'

在现代浏览器中,Vue 3 主要通过 JavaScript Proxy 拦截属性读取和修改:

读取 state.count
    ↓
Proxy.get
    ↓
track(state, "count")

修改 state.count
    ↓
Proxy.set
    ↓
trigger(state, "count")

1. reactive 的深层响应式

普通对象嵌套在 reactive 对象中时,嵌套对象也会在访问时被转换为响应式代理:

const state = reactive({
  user: {
    profile: {
      name: 'Ada'
    }
  }
})

state.user.profile.name = 'Grace'

state.user.profile.name 的修改可以触发依赖它的组件或 effect。

这不意味着 Vue 会在创建时递归复制整个对象。实际转换通常具有惰性特征:访问嵌套对象时再返回对应代理。

2. reactive 返回的是代理,不是原对象

const raw = { count: 0 }
const proxy = reactive(raw)

console.log(proxy === raw) // false

因此不要混用原对象和代理对象:

const raw = { count: 0 }
const state = reactive(raw)

raw.count++ // 直接修改原对象,绕过代理,不能可靠触发响应式更新

应该始终通过代理访问和修改:

state.count++

如果需要从代理获取原对象,可以使用 toRaw,但它主要用于特定边界场景,例如与不兼容代理对象的第三方库交互,不应作为日常修改入口。

3. reactive 的限制

不能直接代理原始值

下面的代码无法工作:

const count = reactive(0)

reactive 需要对象类型。原始值应使用 ref

const count = ref(0)
count.value++

不能通过重新赋值替换整个响应式引用

let state = reactive({ count: 0 })

state = reactive({ count: 1 })

这里的变量 state 被换成了另一个代理,原来依赖第一个代理的 effect 不会自动转移到新对象。

如果状态需要整体替换,应使用 ref

const state = ref({ count: 0 })

state.value = { count: 1 }

解构会丢失属性访问代理

const state = reactive({
  count: 0
})

const { count } = state

解构后,count 是一个普通变量。后续读取它不再经过 state 的 Proxy,因此失去对 state.count 的响应式连接。

可以使用 toRefs 保留连接:

import { reactive, toRefs } from 'vue'

const state = reactive({
  count: 0,
  name: 'Ada'
})

const { count, name } = toRefs(state)

count.value++

toRefs(state) 会为每个属性创建一个 ref-like 对象,其读写仍然转发到 state 的对应属性。

如果只需要一个属性,可以使用:

import { toRef } from 'vue'

const count = toRef(state, 'count')

四、ref:为值提供稳定的 .value 容器

ref 的核心是把一个值放进具有响应式访问器的容器中:

import { ref } from 'vue'

const count = ref(0)

console.log(count.value) // 0
count.value++

概念上可以近似理解为:

const count = {
  get value() {
    track(count, 'value')
    return internalValue
  },
  set value(nextValue) {
    internalValue = nextValue
    trigger(count, 'value')
  }
}

这不是 Vue 的完整源码,只用于说明为什么必须读写 .value

1. 模板中的自动解包

在模板中,顶层 ref 通常会自动解包:

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">
    {{ count }}
  </button>
</template>

模板中不需要写 count.value

但在普通 JavaScript 或 TypeScript 代码中仍然需要:

count.value++

自动解包不是所有嵌套位置都成立。例如,ref 放入数组后通常仍需显式访问 .value

const list = [ref(1)]

console.log(list[0].value)

2. ref 包裹对象

ref 不只适用于原始值:

const user = ref({
  name: 'Ada',
  age: 36
})

user.value.name = 'Grace'

对于对象值,普通 ref 会使其内部对象具备深层响应式能力。整体替换也会触发更新:

user.value = {
  name: 'Marie',
  age: 28
}

这使 ref 适合表示“可被整体替换的状态”。

3. shallowReftriggerRef

当外部库对象很大、不可代理,或者只希望替换整体对象时,可以使用 shallowRef

import { shallowRef } from 'vue'

const chart = shallowRef<unknown>(null)

chart.value = createChart()

shallowRef 只追踪 .value 的替换,不会自动把内部对象转为深层响应式代理:

chart.value = someChart
// 会触发依赖

// 如果直接修改 chart.value 内部属性,通常不会触发依赖

如果确实需要在原地修改后手动通知:

import { triggerRef } from 'vue'

chart.value.someOption = 'new'
triggerRef(chart)

这类 API 适合明确控制更新边界的场景,不应因为“少写一点响应式转换”而随意使用,否则容易出现界面不更新。

4. refreactive 的选择本质

可以用状态的替换方式理解二者:

const count = ref(0)
count.value = 10

这里响应式连接依赖的是 count.value

const state = reactive({ count: 0 })
state.count = 10

这里响应式连接依赖的是代理对象的 count 属性。

经验上:

  • 原始值使用 ref
  • 需要整体替换的对象使用 ref
  • 多个相关字段需要作为一个对象管理时可使用 reactive
  • reactive 解构字段时,用 toRefstoRef 保留响应式连接。

这不是硬性规范,而是由二者的依赖边界决定的。


五、computed:带缓存和失效机制的派生状态

computed 用于表示由其他状态计算得到的值:

import { computed, ref } from 'vue'

const price = ref(100)
const quantity = ref(2)

const total = computed(() => {
  return price.value * quantity.value
})

读取:

console.log(total.value) // 200

修改依赖:

price.value = 120

下一次读取 total.value 时会得到 240

1. computed 的核心过程

computed 内部可以看作一个特殊的 lazy effect:

首次读取 computed.value
    ↓
发现尚未计算
    ↓
执行 getter
    ↓
getter 读取 price.value、quantity.value
    ↓
收集 computed 对这些依赖的关系
    ↓
缓存结果 200

再次读取 computed.value
    ↓
依赖未变化
    ↓
直接返回缓存 200

price.value = 120
    ↓
触发 computed 内部 effect
    ↓
标记为 dirty
    ↓
下一次读取时重新计算

因此,computed 的重要特征是:

  1. 惰性:没有被读取时,getter 通常不会执行;
  2. 缓存:依赖不变时,多次读取返回缓存结果;
  3. 失效:依赖变化时,缓存标记为过期;
  4. 可继续被依赖:组件或另一个 effect 读取 computed.value 时,会依赖这个 computed。

可以用公式表示:

C=f(S1,S2,,Sn)C = f(S_1, S_2, \ldots, S_n)

其中:

  • SiS_i 是响应式源;
  • ff 是 computed getter;
  • CC 是派生值。

当且仅当某个 SiS_i 发生了会影响读取结果的响应式变化时,C 才需要重新计算。

2. 完整示例

<script setup lang="ts">
import { computed, ref } from 'vue'

const price = ref(10)
const quantity = ref(2)

const total = computed(() => {
  console.log('计算 total')
  return price.value * quantity.value
})

function increaseQuantity() {
  quantity.value++
}
</script>

<template>
  <p>单价:{{ price }}</p>
  <p>数量:{{ quantity }}</p>
  <p>总价:{{ total }}</p>
  <button @click="increaseQuantity">增加数量</button>
</template>

初次渲染时输出:

计算 total

如果同一轮渲染中多次读取 total,getter 不会因每次读取而重复执行。点击按钮后,quantity 变化,total 被标记为失效;下一次模板更新读取它时重新计算。

3. computed getter 应保持派生性质

不推荐在 getter 中修改响应式状态:

const total = computed(() => {
  count.value++ // 错误方向:读取派生值时修改源状态
  return count.value
})

这会造成难以推导的依赖关系,甚至产生递归更新。getter 应尽量是从输入状态得到输出状态的纯计算。

如果需要响应式副作用,例如请求、日志、写入外部系统,应使用 watchwatchEffect

4. 可写 computed

默认 computed 只有 getter:

const fullName = computed(() => `${firstName.value} ${lastName.value}`)

写入:

fullName.value = 'Grace Hopper'

会触发运行时警告,因为没有 setter。

需要双向映射时,可以显式提供 getset

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value: string) {
    const [first, ...rest] = value.trim().split(/\s+/)
    firstName.value = first ?? ''
    lastName.value = rest.join(' ')
  }
})

这里的“可写”不是让 computed 自己存储状态,而是把写入转发给源状态。


六、watch:显式监听源,并在变化后执行回调

watchcomputed 的区别在于:

  • computed 返回一个派生值;
  • watch 不负责返回派生值,而是执行副作用;
  • computed 主要关心“结果是什么”;
  • watch 主要关心“变化后做什么”。

1. 监听 ref

import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (newValue, oldValue) => {
  console.log('count changed:', oldValue, '=>', newValue)
})

count.value = 1

在默认调度时,回调会被安排到 Vue 的更新调度流程中,而不是简单理解为赋值语句中的同步调用。

2. 监听 getter

const state = reactive({
  count: 0,
  enabled: true
})

watch(
  () => state.count,
  (newValue, oldValue) => {
    console.log('count changed:', oldValue, '=>', newValue)
  }
)

getter 的作用是明确指定监听源。只有 getter 执行过程中读取的响应式属性会成为依赖。

下面的监听只关心 state.count,不会因为 state.enabled 修改而触发。

3. 监听多个源

const firstName = ref('Ada')
const lastName = ref('Lovelace')

watch(
  [firstName, lastName],
  ([newFirst, newLast], [oldFirst, oldLast]) => {
    console.log({
      newName: `${newFirst} ${newLast}`,
      oldName: `${oldFirst} ${oldLast}`
    })
  }
)

新旧值数组的顺序与监听源数组一致。

4. 直接监听 reactive 对象

const state = reactive({
  user: {
    name: 'Ada'
  }
})

watch(state, (newState, oldState) => {
  console.log('state changed')
})

state.user.name = 'Grace'

直接把 reactive 对象作为源时,Vue 会进行深层监听。需要注意:对于对象内部的原地修改,newStateoldState 可能是同一个代理对象,因为对象没有被整体替换:

console.log(newState === oldState) // 可能为 true

这并不表示没有发生变化,而是表示 Vue 没有为每次深层修改创建一个完整的旧对象快照。

如果使用 getter 返回对象:

watch(
  () => state.user,
  callback
)

默认情况下,只有 state.user 被替换时才会触发;如果要监听其内部变化,需要显式指定深度:

watch(
  () => state.user,
  callback,
  { deep: true }
)

深层监听需要遍历对象图,嵌套数据很大时可能产生明显成本。它解决的是“监听对象内部变化”,不是免费获得结构化旧值。

5. immediate

默认情况下,watch 只在源发生变化后调用回调:

watch(source, callback)

如果希望创建监听器时立即执行一次:

watch(
  () => props.id,
  async (id) => {
    await loadDetail(id)
  },
  { immediate: true }
)

这适合“初始加载一次,之后变化时继续加载”的场景。

6. watch 的时间选项

watchflush 决定回调进入更新流程的时机:

watch(source, callback, { flush: 'post' })

常见值:

  • 'pre':默认选项,回调通常在组件自身 DOM 更新前执行;
  • 'post':组件 DOM 更新后执行,适合读取更新后的 DOM;
  • 'sync':同步触发,不进入批处理队列。

例如需要读取组件更新后的元素高度:

import { nextTick, ref, watch } from 'vue'

const expanded = ref(false)
const panel = ref<HTMLElement | null>(null)

watch(
  expanded,
  () => {
    const height = panel.value?.getBoundingClientRect().height
    console.log('更新后的高度:', height)
  },
  { flush: 'post' }
)

flush: 'sync' 会失去批处理优势。对数组或循环中的连续修改使用同步监听,可能导致回调执行次数过多,应明确确认其必要性。


七、watchEffectwatch 的依赖方式不同

watchEffect 会立即执行函数,并自动收集函数执行期间读取的响应式依赖:

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  console.log('当前 count:', count.value)
})

执行过程:

创建 watchEffect
    ↓
立即执行回调
    ↓
读取 count.value
    ↓
建立 count -> effect 的依赖
    ↓
count 改变后重新执行

相比之下,watch 显式指定源:

watch(count, (newValue, oldValue) => {
  console.log(newValue, oldValue)
})

两者的主要区别:

特性 watch watchEffect
是否立即执行 默认否,可用 immediate
监听源 显式指定 自动收集
是否提供新旧值 提供 不提供
适合场景 精确控制源、比较新旧值 副作用自然依赖多个状态
依赖范围 明确 由回调执行路径决定

watchEffect 的自动性也带来边界:如果回调中有条件分支,实际依赖会随每次执行而改变;如果异步函数在第一个 await 之后才读取响应式数据,该读取通常不会被当前同步依赖收集阶段追踪。

例如:

watchEffect(async () => {
  console.log(userId.value) // 会在第一次同步执行阶段读取
  await Promise.resolve()
  console.log(theme.value) // 不应依赖异步边界后的自动追踪
})

对于异步逻辑,通常应在 await 前读取关键依赖,或者改用显式 watch


八、异步 watch:处理竞态、取消和失效

异步监听最容易出现的问题不是“是否触发”,而是请求返回顺序不确定。

假设:

用户先选择 A,发起请求 A
用户随后选择 B,发起请求 B
请求 B 先返回
请求 A 后返回

如果不处理,旧请求 A 可能覆盖新请求 B 的结果。

1. 使用清理函数取消旧请求

<script setup lang="ts">
import { ref, watch } from 'vue'

interface User {
  id: number
  name: string
}

const userId = ref(1)
const user = ref<User | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)

watch(
  userId,
  async (id, _oldId, onCleanup) => {
    const controller = new AbortController()

    onCleanup(() => {
      controller.abort()
    })

    loading.value = true
    error.value = null

    try {
      const response = await fetch(`/api/users/${id}`, {
        signal: controller.signal
      })

      if (!response.ok) {
        throw new Error(`请求失败:${response.status}`)
      }

      user.value = await response.json() as User
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        return
      }

      error.value = err instanceof Error
        ? err
        : new Error('未知错误')
    } finally {
      if (!controller.signal.aborted) {
        loading.value = false
      }
    }
  },
  { immediate: true }
)
</script>

每次监听源变化时,Vue 会先调用上一次回调注册的清理函数,再执行新的回调。于是:

id = 1
    └── 请求 A

id = 2
    ├── 清理请求 A
    └── 请求 B

onCleanup 是处理 watcher 失效工作的关键,例如:

  • 取消 fetch
  • 清除定时器;
  • 移除事件监听;
  • 断开 WebSocket;
  • 取消第三方库订阅。

Vue 3.5 提供了 onWatcherCleanup 等相关能力,但它有版本和调用时机要求;使用第三个参数传入的 onCleanup 兼容性更广,适合一般代码。

2. 仅使用“请求序号”也可以避免覆盖

如果请求库不支持取消,可以增加序号:

let requestVersion = 0

watch(userId, async (id) => {
  const currentVersion = ++requestVersion
  const result = await loadUser(id)

  if (currentVersion !== requestVersion) {
    return
  }

  user.value = result
})

这里不是取消网络请求,而是丢弃过期结果。生产代码中可以同时使用取消和序号:取消减少资源浪费,序号保证即使取消失败也不会覆盖新数据。


九、组件生命周期与 effect 的作用域

setup<script setup> 中创建的 computedwatchwatchEffect,会被绑定到当前组件的作用域。组件卸载时,相关 watcher 会自动停止。

<script setup lang="ts">
import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  console.log(count.value)
})
</script>

当组件卸载后,这个 effect 不应继续响应 count 的变化。

但如果在组件外部、异步回调中或自定义全局模块中创建 watcher,就不能假设它会自动随某个组件停止:

let stop: (() => void) | undefined

setTimeout(() => {
  stop = watchEffect(() => {
    // 这个 watcher 的创建时机已脱离普通 setup 执行过程
  })
}, 0)

应保存停止函数,并在适当的生命周期或业务边界调用:

const stop = watchEffect(() => {
  // ...
})

stop()

对于组合式函数,可以返回停止函数,或者使用组件作用域管理其内部资源。这样状态、监听器和副作用的生命周期才是一致的。


十、一个端到端示例:搜索、派生状态和异步监听

下面的组件同时展示:

  • ref 保存输入和请求状态;
  • computed 计算是否可以提交;
  • watch 监听搜索关键词;
  • 清理旧请求;
  • 模板中的 ref 自动解包;
  • 错误和加载状态。
<script setup lang="ts">
import { computed, ref, watch } from 'vue'

interface SearchResult {
  id: number
  title: string
}

const keyword = ref('')
const results = ref<SearchResult[]>([])
const loading = ref(false)
const errorMessage = ref('')

const canSearch = computed(() => {
  return keyword.value.trim().length >= 2
})

watch(
  keyword,
  async (newKeyword, _oldKeyword, onCleanup) => {
    const query = newKeyword.trim()

    if (query.length < 2) {
      results.value = []
      errorMessage.value = ''
      loading.value = false
      return
    }

    const controller = new AbortController()
    onCleanup(() => controller.abort())

    loading.value = true
    errorMessage.value = ''

    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`,
        { signal: controller.signal }
      )

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      results.value = await response.json() as SearchResult[]
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') {
        return
      }

      errorMessage.value = error instanceof Error
        ? error.message
        : '搜索失败'
    } finally {
      if (!controller.signal.aborted) {
        loading.value = false
      }
    }
  },
  {
    flush: 'pre'
  }
)
</script>

<template>
  <section>
    <input
      v-model="keyword"
      placeholder="至少输入两个字符"
    />

    <p v-if="!canSearch">请输入至少两个字符</p>
    <p v-else-if="loading">加载中……</p>
    <p v-else-if="errorMessage">错误:{{ errorMessage }}</p>

    <ul v-else>
      <li v-for="item in results" :key="item.id">
        {{ item.title }}
      </li>
    </ul>
  </section>
</template>

运行时的因果关系如下:

输入框修改 keyword
    ↓
keyword.value 触发
    ├── computed canSearch 失效
    └── watch 回调进入调度流程
            ↓
        取消旧请求
            ↓
        发起新请求
            ↓
        results.value 更新
            ↓
        组件重新渲染

这里不应使用 computed 发起请求,因为请求是副作用,不是可缓存的纯派生值。canSearch 适合 computed,而网络请求适合 watch


十一、常见错误及其诊断方式

1. 忘记 .value

错误:

const count = ref(0)

console.log(count)      // 打印 Ref 对象
count++                 // TypeScript 报错或逻辑错误

正确:

console.log(count.value)
count.value++

排查时先确认代码运行位置:模板顶层表达式可能自动解包,普通 TypeScript 代码不会。

2. 解构 reactive 后页面不更新

错误:

const state = reactive({ count: 0 })
const { count } = state

setTimeout(() => {
  state.count++
}, 1000)

模板或 effect 使用的是普通变量 count,它不会随着 state.count 变化。

修复:

const { count } = toRefs(state)

或直接使用:

state.count

3. 把普通变量作为 watch 源

错误:

const state = reactive({ count: 0 })

watch(state.count, () => {
  // 错误:传入的是当前数字,而不是响应式源
})

正确:

watch(
  () => state.count,
  () => {
    // ...
  }
)

watch 需要 ref、reactive 对象、getter,或这些源组成的数组;普通的当前值已经丢失了来源信息。

4. 依赖只在非响应式路径读取

错误:

const rawCount = state.count

watchEffect(() => {
  console.log(rawCount)
})

effect 没有读取 state.count,因此不会建立依赖。

诊断方法是把读取放回响应式上下文:

watchEffect(() => {
  console.log(state.count)
})

5. 深层对象触发次数过多

错误倾向:

watch(
  () => hugeForm,
  callback,
  { deep: true }
)

大型表单、编辑器文档或复杂树结构使用深层监听时,需要遍历大量嵌套数据,并且回调拿不到自动生成的深拷贝旧值。

更精确的做法是监听业务真正关心的字段:

watch(
  () => [form.name, form.email, form.address.city],
  callback
)

如果需要比较完整快照,应在业务层明确创建快照,并承担相应的复制成本,而不是误以为 watch 会免费保存历史对象。

6. 在 watch 中更新自身依赖造成循环

例如:

const keyword = ref('')

watch(keyword, (value) => {
  keyword.value = value.trim()
})

当赋值结果与原值不同时,可能再次触发 watcher;复杂情况下会形成循环更新。

可以先判断:

watch(keyword, (value) => {
  const normalized = value.trim()

  if (normalized !== value) {
    keyword.value = normalized
  }
})

更清晰的方式通常是把规范化放在输入事件、计算属性 setter 或明确的数据转换边界中。


十二、依赖追踪与性能边界

响应式系统的成本主要来自三个阶段:

  1. 代理访问成本:读取和修改需要经过 Proxy 或 ref 访问器;
  2. 依赖收集成本:effect 执行时记录读取过的属性;
  3. 更新执行成本:状态变化后运行受影响的 effect,并可能触发组件更新。

这不意味着“响应式一定慢”,而是说明更新成本取决于依赖图的精细程度和副作用规模。

1. 让依赖更精确

下面的 effect 读取了整个对象中的多个字段:

watchEffect(() => {
  sendAnalytics({
    name: form.name,
    email: form.email,
    address: form.address
  })
})

只要这些字段任何一个变化,effect 都会重新执行。如果分析上只关心 name,应显式缩小依赖:

watch(
  () => form.name,
  (name) => {
    sendAnalytics({ name })
  }
)

依赖越精确,更新边界越容易推导。

2. 对大对象使用 shallow API 需要配套更新策略

const data = shallowRef<LargeData | null>(null)

这可以避免深层代理,但内部修改不会自动触发更新。使用它意味着业务必须采用整体替换:

data.value = {
  ...data.value!,
  items: newItems
}

或者在确认修改完成后调用 triggerRef。如果团队成员不知道这一约束,shallowRef 会表现为“偶尔不更新”。

3. 计算状态不要用 watch 复制

不推荐:

const total = ref(0)

watch(
  [price, quantity],
  () => {
    total.value = price.value * quantity.value
  }
)

这引入了一个额外可变状态,并且存在更新时序问题。派生值应优先写成:

const total = computed(() => price.value * quantity.value)

只有当计算过程包含副作用或需要异步过程时,才使用 watch


十三、规范保证、实现细节与经验判断

需要区分三类结论。

Vue API 的规范行为

以下属于公开 API 语义:

  • ref 通过 .value 暴露响应式值;
  • reactive 返回响应式代理;
  • computed 提供只读或带 setter 的派生 ref;
  • watch 监听指定源并执行回调;
  • watchEffect 自动收集同步执行阶段读取的依赖;
  • watcher 支持停止和清理回调;
  • flushimmediatedeep 等选项改变监听行为。

常见实现机制

以下是理解原理的实现模型,不应视为稳定的内部源码接口:

  • 使用 WeakMap -> Map -> Set 保存依赖;
  • 使用 active effect 收集当前依赖;
  • computed 使用 dirty 标记实现惰性缓存;
  • Vue 使用 scheduler 合并更新;
  • 依赖变化时清理旧依赖。

具体内部类名、字段名和优化策略可能随 Vue 版本变化。

工程经验

以下属于取舍而非 API 强制要求:

  • 原始值和可整体替换的对象通常使用 ref
  • 多字段状态可使用 reactive
  • 派生状态优先使用 computed
  • 网络请求、订阅和 DOM 操作使用 watch
  • 深层监听大型对象前先确认真实依赖范围;
  • 异步 watcher 必须考虑旧结果覆盖新结果;
  • 组件外创建的 watcher 必须有明确的停止边界。

十四、用一组问题快速判断 API

可以按下面的因果关系选择:

问题一:这是源状态,还是派生状态?

const price = ref(10)
const quantity = ref(2)

这是源状态。

const total = computed(() => price.value * quantity.value)

这是派生状态。

问题二:需要得到一个值,还是执行一个动作?

需要得到值:

const fullName = computed(() => `${first.value} ${last.value}`)

需要执行动作:

watch(userId, id => loadUser(id))

问题三:监听源是否明确?

源明确:

watch(() => route.params.id, loadDetail)

副作用自然依赖多个同步读取:

watchEffect(() => {
  document.title = `${user.value.name} - ${count.value}`
})

问题四:对象是局部修改,还是整体替换?

局部修改:

const form = reactive({ name: '', email: '' })
form.name = 'Ada'

整体替换:

const form = ref({ name: '', email: '' })
form.value = { name: 'Ada', email: 'ada@example.com' }

响应式 API 的差异最终都可以回到同一个问题:

依赖关系挂在什么位置,以及状态变化时哪个位置会被触发?

reactive 把依赖挂在代理对象的属性上,ref 把依赖挂在容器的 .value 上,computed 把多个源依赖封装成一个可缓存的派生节点,watch 则在依赖变化后连接到外部副作用。理解这条依赖链,就能解释大多数“为什么更新了”“为什么没更新”“为什么执行太多次”和“为什么旧请求覆盖新结果”的问题。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。