响应式系统:ref / reactive / computed / watch

一句话理解响应式

数据变,依赖它的东西自动跟着变

Java 世界里最接近的东西:

Vue 3 响应式的内部实现是 ES6 Proxy——对象被 reactive() 包一层 Proxy,拦截所有属性读写。读的时候把当前正在执行的"副作用函数"记下来,写的时候把这些副作用重新跑一遍。

这就是整个响应式系统的心脏。下面四个 API,其实都是这个机制的不同封装。


1. ref:基本类型的响应式

import { ref } from 'vue'

const count = ref(0)       // 0 是初始值
const name = ref('张三')
const user = ref({ id: 1, name: '张三' })  // 对象也行,但通常用 reactive

// 访问 / 修改要用 .value
console.log(count.value)   // 0
count.value++              // 触发更新

为什么要 .value

因为 JS 的基本类型(number / string / boolean)是值传递,不像对象有"引用"。Vue 没法在"一个数字"上挂 Proxy,只能包一层对象 { value: 0 },再给这个对象挂响应式。

模板里自动解包,不用写 .value

<template>
  <p>{{ count }}</p>       <!-- 不用 count.value -->
  <button @click="count++">+1</button>  <!-- 也不用 -->
</template>

Java 对照

// ref 等价于一个"可观察的包装盒"
class Ref<T> {
    private T value;
    private List<Runnable> listeners = new ArrayList<>();

    public T getValue() {
        RunningEffect.current().ifPresent(e -> listeners.add(e));
        return value;
    }
    public void setValue(T v) {
        this.value = v;
        listeners.forEach(Runnable::run);
    }
}

这个 Ref<T> 就是 ref 的本质——带监听器的 getter/setter 盒子


2. reactive:对象/数组的响应式

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: '张三', age: 18 },
  tags: ['vue', 'java'],
})

// 直接读写,不用 .value
state.count++
state.user.name = '李四'
state.tags.push('typescript')

Java 对照:像给一个 Map<String, Object> 挂了 PropertyChangeSupport,所有 put / get 都被拦截。

ref vs reactive 怎么选?

场景
基本类型(number / string / boolean) ref
单个对象或数组 refreactive 都行
一组相关状态打包 reactive
需要整体替换 refreactive 整体替换会丢响应式)

项目里通用建议优先用 ref。理由:

  1. .value 让你一眼看出"这是响应式数据",而 reactive 看起来就是普通对象,读代码时容易混
  2. ref 可以存任何类型,心智负担小
  3. reactive 有坑:解构会丢响应式
const state = reactive({ count: 0 })
const { count } = state   // ❌ count 变成普通 number,失去响应式

解决办法是 toRefs(state),但一来就得用 toRefs 说明 reactive 本身不顺手。项目里基本全用 ref,除非真的是"一组强相关的字段"才用 reactive


3. computed:派生值,依赖变了才重算

import { ref, computed } from 'vue'

const firstName = ref('三')
const lastName = ref('张')

// 只读的 computed
const fullName = computed(() => lastName.value + firstName.value)

console.log(fullName.value)  // '张三'
firstName.value = '四'
console.log(fullName.value)  // '张四'(自动重算)

// 可写的 computed(少用)
const editableFullName = computed({
  get: () => lastName.value + firstName.value,
  set: (v) => {
    lastName.value = v[0]
    firstName.value = v.slice(1)
  },
})

核心特性

Java 对照

// Guava 的 Suppliers.memoize + Observable 的组合
Supplier<String> fullName = Suppliers.memoize(() -> lastName + firstName);
// 区别:Vue 的 computed 会在依赖变化时自动失效缓存,而 memoize 永远返回第一次的值

或者更贴切:Excel 单元格公式=A1+B1 这个公式,A1 / B1 改了自动重算,没人读的时候不算。

项目里的实例

src/pages/HomePage.vue 里:

const filteredNotes = computed(() => {
  const q = searchQuery.value.trim().toLowerCase()
  return allNotes.filter((n) => {
    if (selectedCategory.value !== '全部' && n.category !== selectedCategory.value) {
      return false
    }
    if (!q) return true
    return n.title.toLowerCase().includes(q) /* ... */
  })
})

searchQueryselectedCategory 任一变化时,filteredNotes 自动重算;两者都没变时,访问 filteredNotes.value 直接拿缓存。

现场感受 computed 的缓存

把无关 ref 的按钮反复点几下,看 method 一直在跑、computed 不变:

<script setup>
import { ref, computed } from 'vue'

const text = ref('hello')
const tick = ref(0)

let computedRuns = 0
let methodRuns = 0

const upper = computed(() => {
  computedRuns++
  return text.value.toUpperCase()
})
function upperMethod() {
  methodRuns++
  return text.value.toUpperCase()
}
</script>

<template>
  <div class="box">
    <input v-model="text" />
    <p>computed: {{ upper }} · 跑了 {{ computedRuns }} 次</p>
    <p>method: {{ upperMethod() }} · 跑了 {{ methodRuns }} 次</p>
    <button @click="tick++">改无关 ref({{ tick }})</button>
  </div>
</template>

<style scoped>
.box { padding: 20px; font-family: system-ui; line-height: 1.7; }
input { padding: 4px 8px; }
button { margin-top: 4px; padding: 4px 12px; }
</style>

4. watch:副作用式的监听

computed有返回值的派生,watch无返回值的副作用(发请求、存 localStorage、打日志等)。

import { ref, watch } from 'vue'

const query = ref('')

// 监听单个 ref
watch(query, (newValue, oldValue) => {
  console.log(`搜索词从 "${oldValue}" 变为 "${newValue}"`)
  // 典型场景:发 API 请求
  fetch('/api/search?q=' + newValue)
})

// 监听多个
watch([query, page], ([newQ, newP], [oldQ, oldP]) => {
  // ...
})

// 监听对象的某个属性(用 getter)
watch(
  () => user.name,
  (newName) => { /* ... */ }
)

// 深度监听
watch(user, (newUser) => { /* ... */ }, { deep: true })

// 立即执行一次
watch(query, callback, { immediate: true })

Java 对照

// Spring 的 @EventListener 或 PropertyChangeListener
propertyChangeSupport.addPropertyChangeListener("query", evt -> {
    System.out.println("query 变了: " + evt.getNewValue());
});

watchEffect:更省事的 watch

import { ref, watchEffect } from 'vue'

const query = ref('')
const page = ref(1)

// 自动收集依赖,不用手写监听列表
watchEffect(() => {
  console.log('search:', query.value, page.value)
  fetch(`/api/search?q=${query.value}&page=${page.value}`)
})

watch 的区别:

选择原则


5. computed vs watch 怎么选?

这是 Vue 新手最常问的问题。一句话判断:

能用 computed 就别用 watch

场景
根据 A 算出 B(有返回值) computed
A 变了去做别的事(无返回值) watch
派生值,且只读 computed
要发请求 / 改 DOM / 调 API watch

反例(滥用 watch):

// ❌ 用 watch 维护派生状态
const fullName = ref('')
watch([firstName, lastName], () => {
  fullName.value = lastName.value + firstName.value
})

// ✅ 用 computed 更简洁
const fullName = computed(() => lastName.value + firstName.value)

computed 自带缓存,watch 不带。能用 computed 一定用 computed


6. 响应式陷阱速查

陷阱 1:解构 reactive 丢响应式

const state = reactive({ count: 0 })
const { count } = state
count++              // ❌ 不触发更新(count 是快照)

解法toRefs(state)toRef(state, 'count')。或者一开始就用 ref

陷阱 2:在普通对象上用 ref 却忘了 .value

const count = ref(0)
count + 1            // ❌ 实际是 [object Object] + 1
count.value + 1      // ✅

ESLint 插件 eslint-plugin-vue 能帮你检查大部分此类错误。

陷阱 3:异步里的响应式更新

const data = ref(null)
onMounted(async () => {
  data.value = await fetch('/api').then(r => r.json())
  // 模板里 data.value.name 可能此时还是 null,要用 v-if="data" 守卫
})

Java 后端的"数据肯定在 Controller 拿到后才返回"在前端不成立。异步加载的数据永远要 v-if 守护

陷阱 4:直接替换 reactive 对象

let state = reactive({ count: 0 })
state = reactive({ count: 10 })   // ❌ 模板里还是显示 0

reactive 返回的是 Proxy,模板绑定的是那个 Proxy 的引用。你把变量指向新 Proxy,模板不知道。要么改属性,要么用 ref 包起来整体替换


7. 原理浅析:为什么要 Proxy

Vue 2 用 Object.defineProperty,只能拦截已存在的属性,新增属性要 Vue.set。Vue 3 换成 Proxy 后:

const raw = { count: 0 }
const state = new Proxy(raw, {
  get(target, key) {
    track(target, key)          // 记录"谁在读"
    return target[key]
  },
  set(target, key, value) {
    target[key] = value
    trigger(target, key)         // 通知所有"读过 key 的人"重跑
    return true
  },
})

Vue 内部维护一张依赖图:target → key → 一组 effecteffect 就是"计算属性 / watch 回调 / 组件渲染函数"这些需要重跑的东西。

看懂这一点,Vue 响应式再也不神秘。模板渲染、computedwatch 全是 effect,区别只是 Vue 给它们包了不同的 API。


8. 原理深挖:activeEffect 栈 = "谁在读"的核心

上面的 track(target, key) 怎么知道"谁在读"?答案是一个全局变量 activeEffect

let activeEffect = undefined

class ReactiveEffect {
  constructor(fn) { this.fn = fn }

  run() {
    activeEffect = this        // ★ 跑之前把自己设为当前 effect
    try {
      return this.fn()         // 函数里所有 get 都会调 track,把 this 收进依赖
    } finally {
      activeEffect = undefined // ★ 跑完清掉
    }
  }
}

function track(target, key) {
  if (!activeEffect) return
  // 把 activeEffect 加进 target.key 的依赖集合
  depsMap.get(target).get(key).add(activeEffect)
}

为什么 setup 里同步写的 watch / computed 能自动收依赖——执行 effect.run() 时把自己挂到 activeEffect,函数里碰到 proxy.foo 就触发 get → track(target, 'foo'),依赖关系建立。

栈式管理(嵌套 effect)

const stack = []

function setActiveEffect(e) {
  stack.push(activeEffect)
  activeEffect = e
}
function unsetActiveEffect() {
  activeEffect = stack.pop()
}

为什么要栈?effect 会嵌套——比如组件 render 里调了一个 computed,computed 内部又依赖另一个 ref。如果不栈式管理,computed 跑完把 activeEffect 清成 undefined,回到 render 时再访问 ref 就收不到依赖了。

Vue 3.4 之前用 effectStack 数组,3.4 之后做了优化用的是闭包 prevActiveEffect,但思路是一样的栈结构。


9. 原理深挖:targetMap 的三层结构

Vue 内部的依赖图长这样(伪 TypeScript):

type Dep = Set<ReactiveEffect>           // 第三层:谁依赖了这个 key
type KeyToDepMap = Map<any, Dep>         // 第二层:key → Dep
type TargetMap = WeakMap<object, KeyToDepMap>   // 第一层:对象 → 第二层

const targetMap: TargetMap = new WeakMap()

实际数据:

WeakMap {
  state对象A => Map {
    'count' => Set { renderEffectA, computedEffectB },
    'name'  => Set { renderEffectA }
  },
  state对象B => Map {
    'list'  => Set { renderEffectC }
  }
}

为什么外层用 WeakMap:键是被代理的对象,弱引用——对象被 GC 时整个依赖关系自动清理,无需手动 dispose。Java 里类似 WeakHashMap 的玩法。

trigger 时的查找

function trigger(target, key) {
  const depsMap = targetMap.get(target)    // 第一层
  if (!depsMap) return                     // 这个对象从来没被读过
  const dep = depsMap.get(key)             // 第二层
  if (!dep) return                         // 这个 key 没被读过
  for (const effect of dep) {              // 第三层
    if (effect.scheduler) effect.scheduler()  // 有调度器就走调度器
    else effect.run()
  }
}

关键洞察:依赖收集是懒的——只有真的被读过的属性才在依赖图里。reactive({ a:1, b:2, c:3 }) 但代码只用了 .a,那 b / c 改了完全不触发任何 effect。这是 Vue 3 性能远优于 Vue 2 的原因之一。


10. 原理深挖:RefImpl = 给基本类型挂上 Proxy 的等价物

第 1 节说 ref 包了一层对象。具体长这样(简化版 Vue 源码):

class RefImpl {
  constructor(value) {
    this._rawValue = value
    this._value = isObject(value) ? reactive(value) : value
    this.dep = new Set()      // 这个 ref 自己的 dep
  }

  get value() {
    trackRefValue(this)        // 收集依赖:activeEffect → this.dep
    return this._value
  }

  set value(newVal) {
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = isObject(newVal) ? reactive(newVal) : newVal
      triggerRefValue(this)    // 触发:跑 this.dep 里所有 effect
    }
  }
}

function ref(v) { return new RefImpl(v) }

几个关键点

  1. .value 是 getter/setter——所以读写都能拦截。这就是为什么必须写 .value:访问 ref 对象本身没拦截能力,访问 .value 才进 getter
  2. 传入对象时自动包 reactive——ref({ a: 1 }) 内部 _valuereactive({ a: 1 }),所以 r.value.a 也是响应式
  3. 比较用 hasChanged——本质是 Object.is,相同值赋值不触发更新(避免无意义重渲染)
  4. dep 直接挂在 ref 实例上——不像 reactive 要走 targetMap 三层,因为 ref 只有一个 key(value)

模板自动解包

// 编译模板时遇到顶层 ref,自动加 .value
{{ count }} 编译成 {{ _ctx.count }}
// 而 setup 返回 ref 时 Vue 给它套了个 Proxy 解包包装

所以模板里不写 .value 是编译期 + 运行时配合的结果,不是黑魔法。


11. 原理深挖:computed 的 lazy + dirty 双标志位

computed 自带"惰性 + 缓存 + 依赖失效自动重算",靠两个标志位实现:

class ComputedRefImpl {
  constructor(getter) {
    this._dirty = true                    // 脏标志位:要不要重算
    this._value = undefined
    this.effect = new ReactiveEffect(getter, () => {
      // ★ 调度器:依赖变化时不立即重算,只是把 dirty 标 true
      if (!this._dirty) {
        this._dirty = true
        triggerRefValue(this)             // 通知"读 computed 的人"重跑
      }
    })
  }

  get value() {
    trackRefValue(this)
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()     // 只在脏了的时候重新跑 getter
    }
    return this._value
  }
}

完整流程(以 fullName = computed(() => first.value + last.value) 为例):

1. 创建 computed → _dirty = true
2. 模板访问 fullName.value
   → 进 getter
   → _dirty 为 true,跑 getter,结果存 _value
   → _dirty = false
3. 模板再次访问 fullName.value
   → _dirty 是 false,直接返回 _value(缓存命中)
4. 改 first.value = 'X'
   → 触发 first 的 dep(包含 fullName 的 effect)
   → 跑 fullName.effect 的调度器
   → 调度器只设 _dirty = true,不重算
   → 通过 triggerRefValue 通知"依赖 fullName 的 effect"(比如组件 render)
5. 组件 render 重跑,访问 fullName.value
   → _dirty 是 true,重新跑 getter

这就是"惰性 + 缓存"的实现——依赖变了只标脏不重算,等真的有人读才算。如果 computed 没人读(如你用 v-if 把它从模板里隐藏),即使依赖一直变也永远不会跑 getter

Java 对照:和 Guava 的 Suppliers.memoize 思路像,但 memoize 没失效机制,结果永久缓存。Vue 的 computed = memoize + 自动 invalidate。


12. 原理深挖:watch 的 scheduler 实现

watch 也是基于 ReactiveEffect,区别在 scheduler 不一样:

function watch(source, callback, options = {}) {
  let oldValue

  const job = () => {
    const newValue = effect.run()
    callback(newValue, oldValue)
    oldValue = newValue
  }

  // 调度器:选择什么时机跑 job
  const scheduler =
    options.flush === 'sync'  ? job :
    options.flush === 'post'  ? () => queuePostFlushCb(job) :
                                () => queueJob(job)   // 默认 'pre',下个微任务

  const effect = new ReactiveEffect(
    () => isRef(source) ? source.value : source(),
    scheduler
  )

  oldValue = effect.run()    // 收集依赖 + 拿初始值
}

flush 三个选项的区别

flush 时机 用途
'pre'(默认) 下个微任务,组件 render 之前 改数据后的常规副作用
'post' 组件 render 完成、DOM 更新后 需要读最新 DOM 时(量尺寸)
'sync' 同步立即触发 几乎不用,会失去批量更新优势

为什么默认 'pre':同一个 tick 内多次改值只触发一次回调(去重队列),但比组件渲染早一步——这样 watch 回调里再次改其他响应式数据,也能合并到同一次渲染。

调度队列的去重queueJob 的核心):

const queue = []
let isFlushing = false

function queueJob(job) {
  if (!queue.includes(job)) queue.push(job)   // 去重
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(flushJobs)         // ★ 微任务里执行
  }
}

function flushJobs() {
  queue.sort((a, b) => a.id - b.id)           // 父组件先于子组件(id 自增)
  for (const job of queue) job()
  queue.length = 0
  isFlushing = false
}

这就是 nextTick 的本质——nextTick(fn) 等价于 Promise.resolve().then(fn),正好排在 flushJobs 之后跑。所以"改数据 → nextTick 拿到新 DOM" 是因为 nextTick 排在 flush 队列之后。


13. 原理深挖:reactive 的几个 Proxy 拦截器

reactive(obj) 实际套了一组 handler,不止 get/set:

const mutableHandlers = {
  get(target, key, receiver) {
    track(target, key)
    const res = Reflect.get(target, key, receiver)
    if (isRef(res)) return res.value          // ★ 自动解包嵌套 ref
    if (isObject(res)) return reactive(res)   // ★ 深响应式(懒,访问时才包)
    return res
  },
  set(target, key, value) { ... trigger() ... },
  has(target, key) { track(target, key, 'has'); ... },     // in 操作符
  deleteProperty(target, key) { ... trigger() ... },
  ownKeys(target) { track(target, ITERATE_KEY); ... }       // for...in / Object.keys
}

关键点

  1. 深响应式是懒的——reactive({ a: { b: 1 } }) 时不立刻递归包 a,只有访问 state.a 才实时包一层 reactive。这避免了对未访问字段的预先开销
  2. Reflect 而不是 target[key]——为了正确处理 getter 里的 this 指向,让 receiver(Proxy)成为 this,使 getter 内访问别的字段也能被 track
  3. shallowReactive 用另一组 handler——只拦第一层,深层不再 reactive。适合大对象或第三方实例(ECharts、Monaco)
  4. collection handler 单独一套——Map/Set/WeakMap 有自己的方法(.get / .set / .has),需要专门拦截这些方法而不是属性访问

为什么 reactive 数组改 length 也响应:Proxy 拦截 set 时连 length 都拦得到,Vue 2 的 defineProperty 拦不到数组下标和 length——这是 Vue 3 的明显胜出。


14. 原理深挖:响应式 ↔ 渲染的完整流程

把所有原理串起来——以"点一下按钮 count 加一"为例:

0. 组件挂载
   ├─ setup() 跑一次,setupState = { count: ref(0), inc: fn }
   ├─ render() 包成一个 ReactiveEffect.run()
   │  ├─ activeEffect = render effect
   │  ├─ 跑渲染函数
   │  │  └─ 访问 count.value
   │  │     └─ trackRefValue → count.dep.add(render effect)
   │  ├─ 拿到 vnode 树
   │  └─ patch 到 DOM
   └─ activeEffect = undefined

1. 用户点按钮,触发 onClick
2. inc() 跑:count.value++
   ├─ 进 RefImpl.set value
   ├─ _rawValue 从 0 改成 1
   └─ triggerRefValue(this)
      └─ 遍历 count.dep
         └─ 对 render effect 调 scheduler
            └─ queueJob(updateComponent)   // 不立即跑,进队列

3. 同步代码跑完,进微任务阶段
4. flushJobs 启动
   ├─ 对 queue 排序(父先子后)
   └─ 跑每个 job
      └─ updateComponent
         ├─ 重跑 render effect → 新 vnode 树
         ├─ patch(oldVNode, newVNode)
         │  └─ 按 patchFlag 找到动态文本节点
         │     └─ el.textContent = '1'   // 真 DOM 更新
         └─ 触发 onUpdated 钩子

5. nextTick(fn) 注册的 fn 在这之后跑——能拿到更新后的 DOM

理解了这条流程,所有响应式相关的"为什么"都能自答


15. 现场观察:手写一个最小响应式

把上面所有原理压成 50 行可运行代码,丢进 Console 跑:

let activeEffect

function effect(fn) {
  const e = () => { activeEffect = e; fn(); activeEffect = null }
  e()
}

const targetMap = new WeakMap()

function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, depsMap = new Map())
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, dep = new Set())
  dep.add(activeEffect)
}

function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key)
  if (dep) dep.forEach(e => e())
}

function reactive(obj) {
  return new Proxy(obj, {
    get(t, k) { track(t, k); return t[k] },
    set(t, k, v) { t[k] = v; trigger(t, k); return true }
  })
}

// 试试看
const state = reactive({ count: 0 })
effect(() => console.log('count is', state.count))   // 立刻打印 "count is 0"
state.count++   // "count is 1"
state.count++   // "count is 2"

这就是 Vue 响应式系统的全部核心——剩下的都是工程化优化(调度队列、深响应、ref 包装、computed 的脏标记、effectScope 自动清理)。


小练习:在 in4vue 里观察

  1. 打开 src/pages/HomePage.vue,指出哪些是 ref、哪些是 computed,它们为什么这样选
  2. 在搜索框加个 watch(searchQuery, (v) => console.log('搜索:', v)),体会 watchcomputed 的不同
  3. searchQuery 改成 reactive({ value: '' }),模板里也改成 searchQuery.value,体验 refreactive 的写法区别(然后改回来,这个场景就是 ref 更合适)
  4. 打开 Vue Devtools 的 Components 面板,修改 selectedCategory 的值,观察页面实时响应——这就是响应式的真正面貌

延伸阅读