Skip to content

第 3 章 响应式系统设计哲学

本章要点

  • 响应式编程的本质:从命令式手动同步到声明式自动传播
  • Vue 响应式的三代实现:defineProperty → Proxy → Alien Signals
  • 细粒度 vs 粗粒度响应式:Vue 与 React 的根本分歧
  • 与 MobX、Solid Signals、Svelte Runes 的横向对比
  • "精确传播变化"——响应式系统的终极追求

假设你在管理一家咖啡店的库存。每天早上,你需要做三件事:

  1. 检查咖啡豆存量
  2. 根据存量计算今天能做多少杯咖啡
  3. 如果不够,给供应商打电话补货

用命令式编程的方式,代码大概是这样的:

typescript
let beans = 500           // 克
let cupsAvailable = Math.floor(beans / 15)
let needRestock = cupsAvailable < 20

// 第二天早上,有人用掉了一些豆子
beans = 200

// 糟了!cupsAvailable 和 needRestock 没有自动更新
console.log(cupsAvailable)  // 仍然是 33,实际应该是 13
console.log(needRestock)    // 仍然是 false,实际应该是 true

// 你必须手动重新计算
cupsAvailable = Math.floor(beans / 15)
needRestock = cupsAvailable < 20

看到问题了吗?当 beans 变化时,cupsAvailableneedRestock 不会自动更新。你必须手动重新计算。在这个简单例子中,手动同步还能应付。但在一个有数百个相互依赖的状态的前端应用中,手动同步就是噩梦的起点。

这就是响应式系统要解决的核心问题:让数据之间的依赖关系自动维护。

typescript
import { ref, computed } from 'vue'

const beans = ref(500)
const cupsAvailable = computed(() => Math.floor(beans.value / 15))
const needRestock = computed(() => cupsAvailable.value < 20)

console.log(cupsAvailable.value)  // 33
console.log(needRestock.value)    // false

beans.value = 200  // 修改源数据

console.log(cupsAvailable.value)  // 13 — 自动更新了!
console.log(needRestock.value)    // true — 自动更新了!

没有手动重新计算,没有 setState,没有 dispatch。数据变了,所有依赖它的计算自动保持一致。

这不是魔法。这是一套精心设计的因果传播系统。

3.1 响应式编程的本质:数据驱动的依赖图

什么是依赖图

响应式系统的核心数据结构是一个有向无环图(DAG, Directed Acyclic Graph)。图中的节点分为三种:

  1. 信号(Signal):源数据,如 ref(0),是依赖图的叶节点
  2. 计算(Computed):从信号或其他计算派生的数据,如 computed(() => count.value * 2)
  3. 副作用(Effect):当依赖变化时需要执行的操作,如 DOM 更新、日志打印

price 变化时,系统需要:

  1. 重算 total(因为它依赖 price
  2. 重算 tax(因为它依赖 total
  3. 重新执行两个 effect(DOM 更新)

关键约束是:不能多做(重算不需要重算的),也不能少做(遗漏需要重算的)。 这就是"精确传播"的含义。

推模型 vs 拉模型

依赖图中变化的传播方式有两种基本策略:

推模型(Push):当信号变化时,立即沿依赖图向下推送通知。

price 变化 → 推送给 total → total 重算 → 推送给 tax → tax 重算 → 推送给 effects

拉模型(Pull):当信号变化时,只标记为"脏"。下游节点在被读取时才检查上游是否脏,按需重算。

price 变化 → 标记 price 脏
...(什么都不发生,直到有人读取 total 或 tax)
读取 tax → 检查 total 是否脏 → 检查 price 是否脏 → 是 → 重算 total → 重算 tax → 返回新值
维度推模型拉模型
触发时机数据变化时数据被读取时
无用计算可能(推送给无人读取的节点)无(只计算被读取的节点)
延迟低(立即推送)可能更高(读取时才计算)
适用场景实时性要求高计算密集但读取稀少
典型实现Vue 3.0–3.4、RxJSAlien Signals、Solid.js

🔥 深度洞察

Vue 3.6 的 Alien Signals 并非纯粹的拉模型——它是混合模型。信号变化时,版本号递增(这是推的动作,但开销极低——只是一个整数加一)。下游节点在被读取时,通过版本号比较判断是否需要重算(这是拉的动作)。但对于 effect(副作用),系统仍然会主动调度它们的重新执行(因为没人会"读取"一个副作用)。这种混合策略取了两家之长:对 computed 用拉模型(避免无用计算),对 effect 用推模型(确保副作用及时执行)。

3.2 Vue 响应式的三代实现

第一代:Object.defineProperty(Vue 2)

Vue 2 使用 Object.defineProperty 拦截对象属性的 getter 和 setter:

typescript
// Vue 2 响应式核心(简化)
class Dep {
  private subs: Watcher[] = []

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

function defineReactive(obj: any, key: string, val: any) {
  const dep = new Dep()

  Object.defineProperty(obj, key, {
    get() {
      dep.depend()    // 当前 Watcher 订阅这个属性
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify()    // 通知所有 Watcher
    }
  })
}

这套方案有三个根本性局限:

  1. 无法检测属性的添加和删除Object.defineProperty 只能拦截已存在的属性。vm.newProp = 'hello' 不会触发更新,必须使用 Vue.set()

  2. 无法拦截数组索引赋值arr[0] = 'new' 不会触发更新。Vue 2 通过重写数组的 7 个变异方法(pushpopsplice 等)来部分解决,但这是一个补丁,不是一个解决方案。

  3. 初始化成本高defineReactive 必须在创建对象时递归遍历所有属性,一次性设置所有 getter/setter。对于大型对象,这个初始化开销不可忽视。

typescript
// Vue 2 的痛点演示
const vm = new Vue({
  data: {
    user: { name: 'Alice' }
  }
})

// ❌ 不触发更新 — defineProperty 无法拦截新属性
vm.user.age = 25

// ✅ 必须使用 Vue.set
Vue.set(vm.user, 'age', 25)

// ❌ 不触发更新 — defineProperty 无法拦截数组索引
vm.items[0] = 'new item'

// ✅ 必须使用 splice
vm.items.splice(0, 1, 'new item')

第二代:Proxy + Set-based tracking(Vue 3.0–3.4)

Vue 3 使用 ES6 Proxy 替代 Object.defineProperty,一举解决了前一代的所有局限:

typescript
// Vue 3.0 响应式核心(简化)
const targetMap = new WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>>()

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      track(target, key)      // 依赖收集
      return result
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      trigger(target, key)    // 触发更新
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      trigger(target, key)    // 删除也能触发更新!
      return result
    },
    has(target, key) {
      track(target, key)      // in 操作符也能追踪!
      return Reflect.has(target, key)
    }
  })
}

Proxy 的优势是全面拦截——不仅 get/set,还有 delete、has、ownKeys 等操作,且不需要事先知道对象有哪些属性。

但 Vue 3.0 的依赖追踪仍然基于 Set

typescript
// Vue 3.0 依赖追踪的数据结构
//
// WeakMap<target, Map<key, Set<ReactiveEffect>>>
//
// 例如:
// targetMap = {
//   { name: 'Vue' } => {
//     'name' => Set { effect1, effect2 }
//   }
// }

function track(target: object, key: string | symbol) {
  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)           // ← Set.add()
  activeEffect.deps.push(dep)     // ← 反向引用,用于 cleanup
}

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    const effects = new Set(dep)   // ← 创建副本以避免无限循环
    effects.forEach(effect => {
      if (effect !== activeEffect) {
        effect.scheduler ? effect.scheduler() : effect.run()
      }
    })
  }
}

基于 VitePress 构建