Vue 3 设计与实现

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

作者 杨艺韬 · 10,166 字

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

本章要点

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

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

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

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

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 不会自动更新。你必须手动重新计算。在这个简单例子中,手动同步还能应付。但在一个有数百个相互依赖的状态的前端应用中,手动同步就是噩梦的起点。

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

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。数据变了,所有依赖它的计算自动保持一致。

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

为什么要从”哲学”开始讲响应式?

读到这里你可能会疑惑——为什么本章叫”设计哲学”而不是直接开讲 refreactive 的源码?原因是:响应式系统是 Vue 3 里抽象层级最深的部分,直接跳进源码你会看到一堆版本号递增、双向链表维护、脏检查标志——但不知道它们在为什么样的”目标”服务。没有哲学的铺垫,你读到的只是”一堆代码怎么跑”,而不是”一个系统为什么这么设计”。

这种从哲学到实现的叙事顺序不是学术矫情,是一种学习策略:当你理解了”系统要解决什么问题、面临哪些约束、有哪些候选解法”之后,具体的实现代码会变成”原来他们是这样解决问题的”的自然落地;反之,先看实现再找动机,你会觉得每一行代码都突兀、记不住。本章 3.3-3.4 节会把 Vue 的路线和 React / MobX / Solid / Svelte 做系统对比——看完这些对比,你对”响应式这件事可以有多少种做法”有了全景视野,后面第 4、5、6 章读 Vue 具体实现时会感到”原来它选了这条路,其他路我也见过”,记忆效率会高很多。

本章也和第 1 章的”三次蜕变”叙事紧密呼应——第 1 章从外部视角讲 Vue 为什么要做响应式升级,本章从内部视角讲”升级具体是怎么发生的”。两边结合你才能把”Vue 响应式的过去现在未来”立体理解。

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

响应式编程(Reactive Programming)作为一种编程范式,比 Vue 早得多。早在 1997 年,Conal Elliott 就提出了”Functional Reactive Programming”(FRP)——用函数式风格建模随时间变化的值。后来的 ReactiveX(RxJS、RxJava)、Elm、Cycle.js 都是这条血脉。前端框架这一波响应式浪潮(Knockout.js → Angular → Vue → MobX → Solid)是这个古老思想在 UI 领域的具体应用——所有这些框架背后的核心问题都是同一个:怎么让数据变化自动沿依赖图传播、同时最大程度地避免冗余计算。理解这一点,你再看任何一个新框架的响应式机制,都不会觉得它是”新发明”——它只是这个古老问题的又一种答案。

什么是依赖图

依赖图”这个词在计算机科学里出现频率极高——从构建系统(Makefile、Bazel)、包管理器(npm、Cargo)、数据库查询优化器、到 Excel 电子表格,任何”一个值的改变要触发另一组值重算”的场景都涉及依赖图。前端响应式系统只是这个古老概念在 UI 领域的具体应用。如果你之前对依赖图的概念熟悉(比如用过 Make、读过编译器原理),这一节会让你觉得”熟悉得不能再熟悉”——这是一个跨领域都适用的基础概念。

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

  1. 信号(Signal):源数据,如 ref(0),是依赖图的叶节点
  2. 计算(Computed):从信号或其他计算派生的数据,如 computed(() => count.value * 2)
  3. 副作用(Effect):当依赖变化时需要执行的操作,如 DOM 更新、日志打印
graph TD
    A["ref(price)<br/>信号"] --> C["computed(total)<br/>= price × quantity"]
    B["ref(quantity)<br/>信号"] --> C
    C --> D["effect: 更新 DOM"]
    C --> E["computed(tax)<br/>= total × 0.1"]
    E --> F["effect: 更新税额显示"]

    style A fill:#4ecdc4,stroke:#333
    style B fill:#4ecdc4,stroke:#333
    style C fill:#ffd93d,stroke:#333
    style E fill:#ffd93d,stroke:#333
    style D fill:#ff6b6b,stroke:#333,color:#fff
    style F fill:#ff6b6b,stroke:#333,color:#fff

price 变化时,系统需要:

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

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

听起来简单,但”不多不少”的保证在工程上出奇地困难。“不少做”容易——全部重算肯定不会漏掉任何依赖,但会做大量冗余;“不多做”也不难——完全不做更新当然没有冗余,但会漏掉真正需要的更新。难的是同时做到不多不少,这需要精确的依赖追踪机制。响应式系统的每一代演进本质上都是在这个”不多不少”的天平上反复微调——Vue 2 靠 defineProperty 追踪到属性级别、Vue 3.0 换成 Proxy 解决 Vue 2 的局限、Alien Signals 又用版本号 + 惰性求值把无用计算降到更低。每一代都在”不多不少”上多往”不多”的方向迈一步,同时保持”不少”不后退——这种双约束优化就是响应式系统工程师毕生求索的方向。

推模型 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 响应式的三代实现

一个软件系统经历三次内核级重写——这在开源世界里非常少见。大多数框架一旦某个子系统被写出来并被生态接受,就会陷入”不敢动”的境地:下游用户太多、API 的微妙行为被大量代码依赖,任何大改动都可能变成破坏性版本。Vue 的响应式系统却成功进行了三次重写,每一次都在不破坏上层 API 的前提下完全重做内核。这种”API 兼容 + 内核替换”的能力是 Vue 3 架构质量的最好证明——它说明 Vue 的 API 设计得足够抽象,不泄漏内部实现细节。本节追踪这三次重写,你会看到一个优秀框架是如何在长期演进中保持活力的。

第一代:Object.defineProperty(Vue 2)

每一代响应式实现都有它所处时代的技术约束。Vue 2 的 Object.defineProperty 方案在今天看当然落后,但放到 2014-2016 年的浏览器兼容环境里看,它其实是个相当精巧的方案——在 ES5 时代能做到属性级追踪的 API 就这一个。理解这一代不是为了怀旧,而是为了看清”Vue 3 的 Proxy 升级解决了什么老问题”——没有这种对比,你会觉得 Proxy 的好处是”理所当然”,实际上它是花了整整一代用户的痛苦换来的认知升级。

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

// 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
    }
  })
}

看这段代码时值得想一个问题:Vue 2 为什么非要用 Object.defineProperty 这种”笨拙”的 API?答案和当时的 JavaScript 生态有关——Vue 2 发布于 2016 年,彼时 ES6 Proxy 还没被主流浏览器充分支持,IE11 完全不支持,Safari 在几代版本里性能也比较差。Vue 作者不得不在”用 Proxy 限定浏览器”和”用 defineProperty 换取兼容性”之间做选择——他选了后者。这是典型的”技术能力受限于时代”——如果 2026 年 Evan You 要从头设计 Vue,他肯定会直接用 Proxy,但 2016 年的选择只能是 defineProperty。工程决策永远要放在时代背景里理解,脱离时代评价”这个设计不好”是不公平的。

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

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

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

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

// 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 发布于 2020 年——此时 Proxy 已经在 2016 年被主流浏览器支持,IE11 的市场份额也降到可以不再兼容的水平。这次 Vue 团队终于有机会”用最对的工具做对的事”——Proxy 提供了 JavaScript 层面最完整的对象操作拦截能力,再也不用靠重写数组方法、不用靠 Vue.set 这种补丁。这是一次”等待了四年的技术升级”——技术债一旦具备清理条件就不该拖延。

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

// 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 2 时代大家都要记 “直接修改数组索引不触发更新”、“添加新属性要用 Vue.set” 这些坑,教程里每一本都有这个章节。Vue 3 发布后这些坑全部消失——用户可以自然地写 obj.newProp = xarr[0] = y,一切都会自动触发更新。这种”让用户忘记框架限制”的能力是一次范式升级真正落地的标志。很多框架的新版本虽然号称”重大升级”,但用户实际感知不到任何便利提升——那不是真升级。Vue 3 的响应式升级是能让用户立刻感受到”代码比以前更自然”的那种。

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

// 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()
      }
    })
  }
}

这套系统的问题在于 cleanup 机制。每次 effect 重新执行时,它必须先清除所有旧的依赖关系,然后在执行过程中重新收集:

// Vue 3.0 的 effect cleanup
class ReactiveEffect {
  deps: Set<ReactiveEffect>[] = []

  run() {
    // 1. 清除旧依赖
    cleanupEffect(this)              // ← 遍历 this.deps,从每个 Set 中删除自己

    // 2. 设置当前 effect
    activeEffect = this

    // 3. 执行函数(触发 getter → 重新收集依赖)
    const result = this.fn()

    // 4. 恢复
    activeEffect = undefined
    return result
  }
}

function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect)           // ← Set.delete()
  }
  deps.length = 0
}

这个 cleanup 过程有显著的性能开销:每次 effect 执行,都需要从所有依赖集合中删除自己(Set.delete()),然后重新添加(Set.add())。在依赖关系频繁变化的场景下(如条件渲染),这些集合操作累积起来不可忽视。

你可以把这种”每次都 delete 再 add”的代价具象化:一个复杂页面可能有 300 个响应式 effect,每个 effect 依赖 10 个属性,每次触发更新要做 3000 次 Set 操作——每次 Set 操作看起来便宜,三千次堆起来就不便宜了。更要命的是这些操作每次产生”短生命周期的中间对象”(新 Set、用于遍历的 iterator 等),给 GC 造成周期性压力。Chrome 的 Performance 面板上你经常能在 Vue 3.0-3.4 项目里看到”锯齿状的 GC pause”——就是这个 cleanup 机制的副作用。Alien Signals 的双向链表方案从根子上消除了这些代价。

第三代:版本计数 + 双向链表(Vue 3.5–3.6,Alien Signals)

Alien Signals 是这三代里最精巧的一次升级——它不是在原方案上打补丁,而是彻底重构数据结构。从”一堆 Set 组成的关系图”变成”一张由双向链表节点构成的稀疏关联图”。数据结构的变化看似微小,但影响的是系统每一个核心操作的复杂度——依赖追踪、脏检查、通知传播的每一步都因此变快。这种”底层数据结构的一次精心选择决定上层系统的全部性能特性”是计算机科学里一再被验证的真理——Linux 进程调度从 O(n) 到 O(1) 再到 O(log n) CFS 都是这种故事、数据库索引从 B-Tree 到 LSM-Tree 也是这种故事。响应式系统只是又一个舞台。

Alien Signals 从根本上重新思考了依赖追踪的数据结构:

// Vue 3.6 Alien Signals 核心数据结构(简化)

// 全局版本号 — 任何 signal 变化都会递增
let globalVersion = 0

// Signal(信号)
interface Signal {
  _value: any
  _version: number        // 自身版本号
  _subs: Link | undefined // 订阅者链表头
}

// Computed(计算值)
interface Computed {
  _value: any
  _version: number        // 自身版本号
  _globalVersion: number  // 上次求值时的全局版本号
  _deps: Link | undefined // 依赖链表头
  _subs: Link | undefined // 订阅者链表头
  _fn: () => any
  _flags: number          // 状态标志(dirty、running 等)
}

// Link — 双向链表节点,连接 dep 和 sub
interface Link {
  dep: Signal | Computed
  sub: Computed | Effect
  prevDep: Link | undefined   // 同一个 sub 的前一个 dep
  nextDep: Link | undefined   // 同一个 sub 的下一个 dep
  prevSub: Link | undefined   // 同一个 dep 的前一个 sub
  nextSub: Link | undefined   // 同一个 dep 的下一个 sub
}

关键改变:

1. 版本号取代 Set

信号变化时只递增版本号,不遍历订阅者:

function signalWrite(signal: Signal, value: any) {
  if (value !== signal._value) {
    signal._value = value
    signal._version++       // O(1)
    globalVersion++          // O(1)
    // 不做任何遍历,不通知任何人
  }
}

Computed 被读取时,通过版本号判断是否需要重算:

function computedRead(computed: Computed): any {
  if (computed._globalVersion !== globalVersion) {
    // 全局有变化,但不确定是否影响自己
    if (isDirty(computed)) {
      // 确实脏了,重新计算
      computed._value = computed._fn()
      computed._version++
    }
    computed._globalVersion = globalVersion
  }
  return computed._value
}

2. 双向链表取代 Set

依赖关系通过 Link 节点组成的双向链表维护:

Signal A         Computed X         Effect E
  _subs ──→ Link ──→ Link
              │         │
              ↓         ↓
          dep: A    dep: X
          sub: X    sub: E

双向链表的优势:

  • 无内存分配:Link 节点可以复用,不需要创建/销毁 Set
  • O(1) 插入/删除:链表操作是常数时间
  • 无 cleanup 开销:不需要每次 effect 执行时清除旧依赖再重建

3. 惰性脏检查

function isDirty(computed: Computed): boolean {
  // 遍历 computed 的所有依赖
  let link = computed._deps
  while (link) {
    const dep = link.dep
    if ('_fn' in dep) {
      // dep 是另一个 computed
      if (dep._version !== link.version) {
        // dep 的版本号变了,尝试更新 dep
        if (dep._globalVersion !== globalVersion) {
          if (isDirty(dep)) {           // ← 递归检查
            dep._value = dep._fn()
            dep._version++
          }
          dep._globalVersion = globalVersion
        }
        if (dep._version !== link.version) {
          return true                    // ← dep 确实变了
        }
      }
    } else {
      // dep 是 signal
      if (dep._version !== link.version) {
        return true                      // ← signal 变了
      }
    }
    link = link.nextDep
  }
  return false                           // ← 没有依赖变化
}

🔥 深度洞察

isDirty 函数是 Alien Signals 最精巧的部分。它实现了一种渐进式脏检查:不是简单地回答”你脏了吗”,而是沿着依赖链逐层检查”你的哪个依赖变了”。如果一个 computed 依赖另一个 computed,后者的值虽然上游信号变了,但重算后结果相同——那么前者不算脏。这种”惰性传播”避免了不必要的连锁重算,是 Alien Signals 性能优势的核心来源之一。

三代实现的量化对比

维度Vue 2(defineProperty)Vue 3.0–3.4(Proxy + Set)Vue 3.6(Alien Signals)
拦截方式defineProperty(逐属性)Proxy(对象级)Proxy + 版本计数
依赖存储Dep(数组)Set双向链表
依赖清理Watcher.teardowncleanup(Set.delete 遍历)无需清理(链表复用)
新属性检测❌(需 Vue.set)
数组索引❌(需 splice)
内存效率低(频繁创建 Set)高(Link 节点复用)
变化通知推(同步遍历)推(同步遍历)拉(版本比较 O(1))
GC 压力极低

3.3 细粒度 vs 粗粒度响应式:Vue vs React

Vue 和 React 到底哪个更好”是前端社区问了十年的老问题——大多数讨论都停留在语法、生态、招聘市场等表层指标。本节要从响应式系统这个底层差异出发,把 Vue 和 React 的分歧追到它们设计哲学的源头:Vue 相信”精确定位每一个变化”、React 相信”重算整个组件再用 VDOM diff 找差别。这不是两种技术路线的竞争,而是两种”在不完美世界里做出何种 trade-off”的哲学分歧。理解这个分歧,你会对任何其他框架的类似路线迅速形成判断。

Vue 和 React 在状态管理上的分歧,不是实现细节的差异——它是哲学层面的分歧

React 的粗粒度更新

React 的更新模型是”粗粒度”的——当 setState 被调用时,React 标记整个组件为”需要重新渲染”,然后重新执行组件函数(或 render 方法),生成新的 JSX/VNode 树,与旧树 diff,找出差异。

// React 的更新模型
function Counter() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('React')

  // 每次 count 或 name 变化,整个函数重新执行
  console.log('Counter re-render')

  return (
    <div>
      <p>{count}</p>     {/* 即使只有 name 变了,这行也会重新求值 */}
      <p>{name}</p>
  )
}

setName('Vue') 被调用时,count 相关的代码也会重新执行——尽管 count 没有变化。React 通过 useMemoReact.memouseCallback 等 API 让开发者手动标记”不需要重新计算的部分”:

// React 需要手动优化
const expensiveValue = useMemo(() => computeExpensive(count), [count])
const MemoizedChild = React.memo(ChildComponent)

Vue 的细粒度更新

Vue 的更新模型是”细粒度”的——每个响应式数据(ref/reactive)都精确追踪它的依赖者。当数据变化时,只有真正依赖它的 computed 和 effect 会被重新执行。

// Vue 的更新模型
<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const name = ref('Vue')

// 只有 count 变化时才重算
const doubled = computed(() => count.value * 2)

// 只有 name 变化时才重算
const greeting = computed(() => `Hello, ${name.value}`)
</script>

<template>
  <p>{{ doubled }}</p>    <!-- count只更新这里 -->
  <p>{{ greeting }}</p>   <!-- name只更新这里 -->
</template>

name.value = 'React' 时,只有 greeting 会重算,doubled 完全不受影响。开发者不需要手动添加 useMemo 等优化——精确更新是默认行为

两种模型的深层对比

维度React(粗粒度)Vue(细粒度)
更新单位组件(函数/类)响应式依赖(ref/computed/effect)
默认行为整组件重渲染只更新变化的部分
手动优化需要(memo、useMemo、useCallback)不需要
心智模型”数据变了 → 重新执行整个函数""数据变了 → 只通知依赖者”
数据流方向自顶向下(props drilling)依赖图(任意拓扑)
编译器角色Babel 转 JSX(最小化)模板编译 + 优化标志(深度参与)
React Compiler自动添加 memo(尝试弥合差距)

🔥 深度洞察

React 2024 年推出的 React Compiler,本质上是在编译期自动插入 useMemouseCallback——让编译器做开发者手动做的优化工作。这是一个有趣的趋同:React 试图通过编译器来逼近 Vue 的细粒度更新效果。但两者的路径截然不同——Vue 的细粒度更新是运行时驱动的(响应式系统在运行时追踪依赖),React Compiler 的优化是编译期驱动的(编译器在编译期分析哪些值不需要重算)。Vue 的方式更精确(运行时信息更完备),React 的方式开销更低(编译期完成,运行时零开销)。两条路各有千秋,最终目标却是相同的——精确更新

3.4 与 MobX、Solid Signals、Svelte Runes 的横向对比

前一节我们对比了 Vue 和 React——本节把视野拉到更广的响应式图谱。MobX、Solid、Svelte 各有自己的响应式实现,有些和 Vue 极像、有些和 Vue 差异很大。读完本节你会发现一个有趣的事实:响应式这个问题的解空间其实没那么大——所有主流方案都在几种核心机制(Proxy vs 函数调用、推 vs 拉、运行时 vs 编译期)之间做不同组合。每种组合都有自己的优势和代价——没有哪种是”完美”的。知道这一点,你在未来看到新框架时就不会盲目赞叹”好巧妙”——你会先把它映射到这个解空间里,再判断它的真实创新点在哪。

graph TD
    subgraph Vue3["Vue 3 (Proxy)"]
        V1["reactive(obj)"] -->|"读取时追踪"| V2["track(target, key)"]
        V2 -->|"修改时触发"| V3["trigger(target, key)"]
        V3 -->|"精确更新"| V4["只更新依赖的组件"]
    end
    subgraph MobX["MobX (Observable)"]
        M1["observable(obj)"] -->|"autorun/computed"| M2["自动追踪依赖"]
        M2 --> M3["精确更新观察者"]
    end
    subgraph Solid["Solid (Signals)"]
        S1["createSignal()"] -->|"读取即订阅"| S2["createEffect()"]
        S2 --> S3["细粒度 DOM 更新"]
    end
    subgraph React["React (setState)"]
        R1["setState()"] --> R2["标记组件 dirty"]
        R2 --> R3["重渲染整个子树"]
        R3 --> R4["Virtual DOM diff"]
    end

    style Vue3 fill:#dcfce7,stroke:#22c55e
    style React fill:#dbeafe,stroke:#3b82f6

Vue 的响应式系统不是孤立存在的。让我们将它放在更广阔的响应式编程图谱中审视。

横向对比是最高效的学习路径之一。当你只熟悉一个工具时,很容易陷入”这就是唯一的做法”的思维定式——每个细节都觉得”本该如此”。把多个同类工具放在一起对比,你会立刻发现”噢,这个 API 的选择其实还有另一种写法”、“原来依赖追踪可以放在编译期而不是运行时”。这种”被对比打破的默认假设”就是你认知升级的关键时刻。读本节的最大收获不是”记住四个框架的 API”——这些 API 几个月就会变——而是把它们各自所代表的”响应式设计空间里的坐标”记在心里。

MobX:Observable + Derivation(和 Vue 最像的朋友)

先说 MobX——它是响应式浪潮中和 Vue 最同宗同源的伙伴。作者 Michel Weststrate 在 2015 年左右独立发明了 observable + derivation 的架构,和 Vue 的 reactive + computed 几乎是孪生兄弟——甚至某些实现细节(比如 Atom 自动追踪)的思路也高度一致。MobX 早期主要服务 React 生态,填补了 React 没有原生响应式的真空。到今天它仍然是 React + MobX 组合里的经典选择。把 Vue 和 MobX 对比,你会发现一个惊人事实:两个团队独立开发出的高度相似方案,很大程度上说明”细粒度响应式 + 派生值”这个模型是这个问题的”自然解——一旦你充分理解了问题,就会趋同到类似的答案。

MobX 是 React 生态中最接近 Vue 响应式思想的库。它同样基于细粒度追踪:

// MobX
import { makeObservable, observable, computed, autorun } from 'mobx'

class Store {
  count = 0

  constructor() {
    makeObservable(this, {
      count: observable,
      doubled: computed
    })
  }

  get doubled() {
    return this.count * 2
  }
}

const store = new Store()
autorun(() => console.log(store.doubled))  // 类似 Vue 的 watchEffect

MobX 和 Vue Reactivity 的核心思想一致,但实现策略不同:

维度Vue ReactivityMobX
代理方式Proxy(对象级)Proxy 或 Object.defineProperty
追踪粒度属性级属性级
事务支持❌(通过 scheduler 批处理)✅(runInAction
依赖追踪版本计数(Alien Signals)Set-based
框架耦合Vue 内置独立库(可配合任何框架)

Solid.js Signals:编译时 + 细粒度(和 Vue 竞争最直接的对手)

Solid 是本节里和 Vue Reactivity 竞争关系最直接的框架——它们都是细粒度响应式、都支持 fine-grained reactivity、都在”编译器 + 响应式”的组合里找到了共同的演进方向。但它们在 API 形态上做了相反的选择:Vue 保守地用 .value 对象属性(JS 代码即可跑、不需要编译)、Solid 激进地用 signal() 函数调用(必须经过 JSX 编译才能优化)。这两条路线的差异看似细节,其实决定了两个框架的生态走向——Vue 能服务”不想用编译工具链”的场景(比如嵌入式 Vue、CDN 直接引入),Solid 必须始终在构建工具链里用。生态包容度 vs 极致性能——这就是两个框架在响应式 API 上的核心取舍。

Solid.js 的 Signals 是与 Vue Reactivity 最接近的竞品。两者都是细粒度响应式,都使用依赖图模型:

// Solid.js
import { createSignal, createMemo, createEffect } from 'solid-js'

const [count, setCount] = createSignal(0)           // 类似 ref
const doubled = createMemo(() => count() * 2)       // 类似 computed
createEffect(() => console.log(doubled()))          // 类似 watchEffect

关键区别在于 API 风格和编译策略

// Vue — .value 访问(运行时代理)
const count = ref(0)
console.log(count.value)  // 通过 .value 触发 getter

// Solid — 函数调用(编译时优化)
const [count, setCount] = createSignal(0)
console.log(count())      // 通过函数调用触发追踪

Vue 的 .value 是运行时机制——ref 是一个带 getter/setter 的对象。Solid 的 count() 是函数调用——编译器知道在哪里调用了信号,可以做编译期优化。

维度Vue ReactivitySolid Signals
API.value(对象属性)() 函数调用
虚拟 DOM有(VDOM + Vapor)无(从一开始)
编译器角色模板 → render/VaporJSX → 细粒度 DOM 操作
组件模型setup 函数执行一次 + render 多次整个组件函数只执行一次
生态系统成熟(Pinia、Router、DevTools)成长中

Svelte 5 Runes:编译器魔法(走向另一极端的选择)

Svelte 的路线是”用编译器把开发体验做到最像原生 JavaScript”——开发者写 let count = $state(0) 看起来就像普通变量赋值,但编译器会把它转成响应式代码。这种”语法伪装成普通变量”的设计魅力巨大——新手没有 .value 的认知负担、没有 count() 的函数调用开销、没有 ref.get() 的啰嗦。但代价也明显:它不是 JavaScript——没有 Svelte 编译器你的 .svelte 文件就是一堆语法错误。这种”DSL over JavaScript”的路线和 Vue 的”JavaScript 库”路线是两种截然不同的哲学——理解两边的取舍,你对”为什么 Vue 选择 ref.value 而不是更简洁的语法”会有更深的体会。

Svelte 5 引入了 Runes——一种看似原生 JavaScript 但由编译器特殊处理的响应式语法:

// Svelte 5 Runes
let count = $state(0)            // 看起来像普通变量
let doubled = $derived(count * 2) // 看起来像普通赋值

$effect(() => {
  console.log(doubled)           // 自动追踪 doubled
})

count++                          // 像普通变量一样修改

Svelte 的激进之处在于:没有 .value,没有函数调用——就是普通的 JavaScript 变量语法。 编译器在编译期将这些看似普通的变量声明和赋值转化为响应式操作。

维度Vue ReactivitySvelte Runes
语法.value(显式)普通变量(隐式)
编译器依赖可选(运行时也能工作)必须(纯运行时不工作)
可调试性高(.value 在运行时可见)低(编译后代码与源码差异大)
学习曲线需理解 .value接近原生 JS
脱离框架使用✅(@vue/reactivity 独立包)❌(必须在 Svelte 组件中)

🔥 深度洞察

四个响应式系统的设计选择,实际上回答了同一个问题的四种答案:响应式追踪应该发生在哪一层?

  • Vue:运行时层(Proxy + 版本计数)
  • MobX:运行时层(Observable + Derivation)
  • Solid:编译时 + 运行时(函数调用 + 细粒度追踪)
  • Svelte:编译时层(编译器重写变量操作)

趋势是清晰的——越来越多的工作被推向编译期。Vue 的 Vapor Mode 也在走这条路。但 Vue 保留了一个关键优势:@vue/reactivity 可以完全脱离编译器独立工作。这意味着你可以在 Node.js 脚本、React 组件、甚至嵌入式系统中使用 Vue 的响应式系统——不需要任何编译步骤。这种渐进式依赖编译器的策略,是 Vue 哲学的典型体现。

TC39 Signals 提案

值得一提的是,JavaScript 语言本身正在考虑内置 Signals 原语。TC39 的 Signals 提案 受到了 Vue、Solid、Angular 等框架的响应式系统的深刻影响。

// TC39 Signals 提案(Stage 1)
const count = new Signal.State(0)
const doubled = new Signal.Computed(() => count.get() * 2)

Signal.subtle.Watch(() => {
  console.log(doubled.get())
})

count.set(1)  // 触发更新

如果这个提案最终进入 JavaScript 标准,Vue 的 @vue/reactivity 可能会基于原生 Signals 重新实现——而 Alien Signals 的版本计数架构,很可能就是这个标准的参考实现之一。

3.5 “精确”二字价值千金

本节的标题听起来像是一句鸡汤,但它背后藏着响应式系统最核心的工程价值判断。“精确”这两个字在 Vue 响应式的三代演进里始终是那个北极星——每一次重写都是在不断逼近更高精度的变化传播。本节把这个抽象追求变得具体:一个简单的 fullName/displayName 示例,让你亲眼看到”精确”和”不精确”在真实场景下的差别、以及 Alien Signals 的惰性脏检查是如何把”不精确”赖以生存的所有空间都压到极致。

让我们回到本章的核心命题:响应式系统的终极目标不是”检测变化”,而是”精确传播变化”。

关于”精确”这个词我想再多说一段。在软件工程里,“精确”的对立面不是”不精确”——而是”宽松”、“保守”、“过度”。Vue 2 时代的响应式也”能工作”——它只是不够精确。而”不够精确”在小项目里无所谓、在大项目里是 DNA 级别的性能问题:数据量大 + 更新频繁 + 响应式图复杂 = 不精确的代价指数级放大。一个 Web 表格几千个单元格,如果响应式系统做不到”改一个单元格只更新那一个”,而是”改一个单元格更新一整行”,卡顿就来了。Alien Signals 把精确度推到了一个新高度——这不是为了炫技,是为了让 Vue 能承担那些它以前做不了的重任务。

什么是”精确”?考虑以下场景:

const firstName = ref('Evan')
const lastName = ref('You')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

const displayName = computed(() => {
  if (fullName.value.length > 10) {
    return fullName.value.slice(0, 10) + '...'
  }
  return fullName.value
})

effect(() => {
  document.title = displayName.value
})

firstName.value = 'Evan'(设置为相同的值)时,一个”精确”的响应式系统应该:

  1. firstName 的 setter 被调用 → 检测到值相同 → 什么都不做
  2. fullName 不重算
  3. displayName 不重算
  4. DOM 不更新

firstName.value = 'E.' 时:

  1. firstName 值变化 → 版本号递增
  2. fullName 被读取 → 检测到 firstName 版本变化 → 重算 → 值从 'Evan You' 变为 'E. You'
  3. displayName 被读取 → 检测到 fullName 版本变化 → 重算 → 值从 'Evan You' 变为 'E. You'(长度未超 10,逻辑不变)
  4. effect 执行 → document.title 更新

但如果 fullName 重算后的值恰好没变呢?比如 firstName.value = 'Evan'firstName.value = 'EVAN',但 fullName 的计算函数做了 toLowerCase()

Alien Signals 的惰性脏检查会在重算 fullName 后发现:虽然输入变了,但输出没变。此时它不会递增自己的版本号,下游的 displayName 也就不会重算。

这就是”精确”的含义:不仅追踪”什么变了”,还追踪”变化是否真的产生了不同的结果”。 每一层都是一道过滤器,只有真正有意义的变化才能穿透到最终的副作用。

这种”层层过滤”的设计让 computed 不再是简单的”派生值缓存”——它成了变化传播链上的一个减震器。你可以刻意在响应式链路上插入 computed 层作为”屏障”:比如一个大对象变化频繁但真正影响 UI 的只是其中一个字段时,用 computed 提取那个字段出来作为”中间层”,下游副作用就只会在那个字段真正变化时重新执行。这种”用 computed 做变化滤波器”的模式在大型应用里极其有用——它让你能在响应式图里主动降噪,而不是被动承受每一次上游抖动。读这一节你应该能感觉到:响应式系统不只是”自动”——它还是”精确到计较每一次无意义重算。这种精益求精才是 Vue 响应式系统能被称为 “工业级” 的根本原因。

💡 最佳实践

利用 computed 的过滤特性来优化性能。将复杂计算拆分为多层 computed,每一层都是一道”变化防火墙”。如果中间某层的输出没有变化,下游的所有 computed 和 effect 都不会重新执行。这种模式在大规模应用中可以显著减少不必要的 DOM 更新。

3.6 本章小结

本章从哲学层面剖析了响应式系统的设计思想。关键要点:

  1. 响应式编程的本质是从命令式的手动同步,转向声明式的自动传播。核心数据结构是有向无环图(DAG),节点是 Signal、Computed 和 Effect。

  2. 推模型 vs 拉模型:Vue 3.6 的 Alien Signals 采用混合模型——对 computed 用拉模型(惰性求值),对 effect 用推模型(主动调度)。

  3. 三代实现的演进:Object.defineProperty(局限多)→ Proxy + Set(功能完备但开销大)→ 版本计数 + 双向链表(极致优化)。每次重写都不是修修补补,而是对核心模型的重新思考。

  4. 细粒度 vs 粗粒度:Vue 的细粒度响应式默认精确更新,不需要开发者手动优化;React 的粗粒度更新需要 useMemo 等手动优化,React Compiler 试图用编译器弥合差距。

  5. 横向对比:MobX、Solid、Svelte 各有特色,但核心问题相同——响应式追踪应该发生在哪一层?趋势是越来越多的工作被推向编译期。

  6. **“精确传播”**是响应式系统的终极追求。Alien Signals 的版本计数 + 惰性脏检查,不仅追踪”什么变了”,还追踪”变化是否产生了不同结果”。

下一章深入 @vue/reactivity 源码,逐行解析 reactive()ref()track()trigger()computed()

第 1-3 章构成全书宏观部分:Vue 演进(1)→ 源码地图(2)→ 响应式设计哲学(3)。进入第 4 章之前若对 _v_isRefReactiveFlags.RAW_globalVersion 这些符号没感觉,先别停下——后面章节会逐一落地。

延伸阅读

  • Conal Elliott Functional Reactive Programming 1997 原论文:理解响应式编程范式的源头,是所有前端响应式框架的”祖师爷论文”。
  • MobX 作者 Michel Weststrate 演讲 Becoming fully reactive(React Amsterdam 2017):对细粒度响应式设计的经典阐述,很多 Vue 3 的思路能追到这里。
  • Ryan Carniato 博客 A Hands-on Introduction to Fine-Grained Reactivity:Solid.js 作者写的细粒度响应式入门长文,和本章主题高度重合,是理解响应式本质的第一等材料。
  • TC39 Signals Proposal(github.com/tc39/proposal-signals):JavaScript 标准响应式提案的最新进展,Vue、Angular、Solid 团队成员共同参与,代表未来走向。
  • Rich Harris Rethinking Reactivity(2019):Svelte 作者 2019 年的经典演讲,反思 VDOM 并提出编译期响应式路线,是 Vapor Mode 和 Solid 共同的思想源头。

思考题

  1. 概念理解:解释”推模型”和”拉模型”的区别。为什么 Alien Signals 对 computed 使用拉模型,但对 effect 使用推模型?如果对 effect 也使用拉模型,会出现什么问题?

  2. 深入思考:Vue 的 ref 使用 .value 语法,Solid 使用函数调用 count(),Svelte 使用普通变量 count。从 TypeScript 类型推断、可调试性、代码可读性三个维度分析这三种设计的优劣。

  3. 横向对比:React 的 useMemo 和 Vue 的 computed 都是”记忆化计算”。它们在依赖追踪方式上的根本区别是什么?(提示:显式依赖数组 vs 隐式依赖收集)

  4. 工程思考:Alien Signals 的双向链表取代了 Set 来存储依赖关系。链表在哪些操作上比 Set 更快?在哪些操作上更慢?Vue 选择链表的关键考量是什么?

  5. 开放讨论:TC39 的 Signals 提案如果进入 JavaScript 标准,对 Vue、Solid、Angular 等框架意味着什么?框架是否还需要自己实现响应式系统?框架的竞争点会转移到哪里?