K 的一隅

Vue 3 Vue 核心机制

Vue 3 核心响应式原理:从读取一个值开始

用快递驿站、通知名单和合单预打包理解 Vue 3 如何收集依赖、触发更新并缓存计算结果。

11 分钟阅读 更新于 2026-07-23
本文目录

我第一次觉得 Vue 的响应式有点“神奇”,是在改一个购物车数量的时候。

ts
const cart = reactive({
  price: 12,
  count: 2,
})

const total = computed(() => cart.price * cart.count)

按钮里只写了一句 cart.count++,页面上的数量和总价就都变了。没有手动找到那两个 DOM,也没有调用某个 render()。刚开始用 Vue 时,这种体验很舒服;等到页面复杂起来,它又会变成一个问题:Vue 到底记住了什么,为什么有些修改能更新页面,有些却不能?

我后来发现,理解响应式不需要先背 API。只要抓住一条主线:

读取时登记关系,修改时通知用过这个值的人。

后面的 reactiverefcomputedwatchEffect,都可以放回这条主线里看。

响应式不是不停检查,而是有人订阅

假设一个小区没有快递通知系统。居民想知道包裹到了没有,只能每隔几分钟跑一趟门口。程序也可以这样做:不断比较数据有没有变化,再决定要不要更新页面。但这种轮询既浪费,也很难知道一处变化究竟影响哪些界面。

Vue 走的是另一条路。快递到站时,驿站只通知留下过手机号的人;某个值变化时,Vue 也只通知曾经读取过它的副作用。

这里的“副作用”不是贬义词。组件渲染、更新 DOM、执行一个监听回调,都属于数据之外发生的动作。Vue 用 ReactiveEffect 表示这类需要在依赖变化后重新运行的工作。

所以第一个问题不是“谁修改了数据”,而是“谁读过数据”。关系是在读取那一刻建立的。

Proxy 像一个统一的快递驿站

普通 JavaScript 对象不会主动告诉我们它的属性被读取或修改了:

ts
const state = { count: 0 }
console.log(state.count)
state.count++

代码执行完,JavaScript 不会额外发出“刚才有人读取了 count”这样的事件。Vue 3 使用 Proxy 包住对象,让属性访问先经过一个统一入口。这有点像小区把所有快递统一放到驿站:包裹最终还是交给居民,但入站和出站都能被记录。

把实现压到只剩主线,大致是这样:

ts
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)

      if (!Object.is(oldValue, value)) {
        trigger(target, key)
      }

      return result
    },
  })
}

读取 proxy.count 会进入 get,Vue 在这里调用 track;修改它会进入 set,Vue 在这里调用 trigger。真正的 Vue 还要处理数组、MapSet、新增属性、删除属性和迭代等情况,但入口仍然是“读时追踪,写时触发”。

这也解释了一个常见问题:

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

count++

解构时确实读取过一次 state.count,但后面的 count++ 修改的是局部变量,不再经过代理。就像包裹离开驿站后,居民把它从客厅搬到卧室,驿站不会知道这次移动。需要保留响应式连接时,可以继续访问 state.count,或者使用 toReftoRefs

activeEffect:当前是谁在读取

track 要登记订阅关系,先得知道当前是谁在读。

可以把一次副作用运行想成到驿站办理通知登记。工作人员面前一次只处理一个人:这个人查了哪些包裹,就把他的联系方式记到对应的通知名单里。程序里也会保存一个“当前正在运行的副作用”。

ts
let activeEffect

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

  run() {
    const parent = activeEffect
    activeEffect = this

    try {
      return this.fn()
    } finally {
      activeEffect = parent
    }
  }
}

function effect(fn) {
  const reactiveEffect = new ReactiveEffect(fn)
  reactiveEffect.run()
  return reactiveEffect
}

fn 执行并读取响应式属性时,代理的 get 会调用 tracktrack 看见 activeEffect,就知道该把谁放进通知名单。

这里用 parent 恢复上一个副作用,是为了说明副作用可能嵌套。真正的 Vue 还会处理清理旧依赖、暂停追踪、调度和递归保护。我们暂时不展开,因为它们没有改变最核心的登记过程。

targetMap:一份分层通讯录

一个应用里有很多响应式对象,每个对象又有很多属性,每个属性可能被多个副作用读取。把所有关系塞进一张表会很难查找,所以官方文档常用下面的简化模型解释依赖结构:

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

它像一份分层通讯录:

  1. 先按对象找到哪一栋楼;
  2. 再按属性找到哪一个房间;
  3. 最后拿到订阅这个房间变化的通知名单。

最外层使用 WeakMap 很重要。它不会因为依赖表还留着一个键,就强行阻止原对象被垃圾回收。

ts
const targetMap = new WeakMap()

function track(target, key) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }

  dep.add(activeEffect)
}

读到这里,可以回头看 computed(() => cart.price * cart.count)。计算函数执行时会依次读取 pricecount,所以同一个计算副作用会进入两个属性的依赖集合。Vue 不需要分析函数字符串,也不需要猜测你用了什么;函数真实执行时读到什么,就登记什么。

trigger:只通知真正订阅过的人

修改发生时,trigger 沿着同一份通讯录反向查找:

ts
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap?.get(key)

  if (!dep) return

  const effectsToRun = new Set(dep)
  effectsToRun.forEach((effect) => effect.run())
}

先复制一份集合,是为了避免副作用在运行过程中清理并重新收集依赖时,直接修改当前正在遍历的集合。真正的 Vue 还会把更新放进批处理和调度流程,组件不会因为同一轮里连续修改多个值就无节制地重复渲染。

把前面的片段合起来,已经能做出一个最小响应式系统:

ts
const state = reactive({ price: 12, count: 2 })

effect(() => {
  console.log('总价:', state.price * state.count)
})

state.count = 3
// 总价:36

第一次运行 effect 是为了完成初次渲染,也为了收集依赖。之后只有 pricecount 变化时,这个副作用才需要重新执行。

依赖会重收集,不是登记一次就结束

页面真正运行时,依赖关系可能随条件变化:

ts
const state = reactive({
  showTotal: true,
  total: 24,
})

effect(() => {
  console.log(state.showTotal ? state.total : '暂不显示')
})

第一次运行时,副作用读取了 showTotaltotal。后来 showTotal 变成 false,副作用再次运行,却不再读取 total。从这时起,修改 total 不应该继续触发这段输出。

这意味着通知名单不能只增不减。每次副作用重新运行,Vue 都要确认这一次实际读了哪些依赖,并清理已经不用的旧关系。否则页面里切换过一次条件,组件就可能永久订阅条件分支里的所有数据,既多做更新,也难以释放关系。

教学代码通常省略这一步,是为了把 tracktrigger 讲清楚。Vue 3.5 的 DepLink 和版本字段也服务于这类关系维护:链接不只是表示“订阅过”,还要帮助判断它在本轮运行中是否仍然有效。

简化模型和 Vue 3.5 源码不是一回事

WeakMap -> Map -> Set 很适合理解概念,但它不是 Vue 3.5 源码的一比一抄写。

Vue 3.5 仍然使用 targetMap 这个 WeakMap,对象下面也仍然按属性维护 Map。不同之处在最后一层:源码用 Dep 表示一个依赖,用 Link 把依赖和订阅者连接起来,并通过双向链表、版本号和批处理减少重复遍历与清理成本。computed 也会利用全局版本号判断缓存是否可能继续使用。

这层差别值得知道,但不必一开始就背 DepLink 的每个字段。先用通知名单理解“谁依赖谁”,再读源码里的链表优化,会比直接陷进实现细节更稳。文章里的 Set<ReactiveEffect> 是教学模型,不是假装复刻当前源码。

ref 为什么总要写 .value

Proxy 只能代理对象,不能直接代理一个数字或字符串:

ts
reactive(1) // 没有可拦截的属性访问

ref 的办法是给值套一个对象外壳,把真正的读取和修改统一放在 .value 上。这个外壳仍然像驿站,只是它只管理一个固定窗口:

ts
function ref(rawValue) {
  const wrapper = {
    get value() {
      track(wrapper, 'value')
      return rawValue
    },
    set value(newValue) {
      if (Object.is(rawValue, newValue)) return
      rawValue = newValue
      trigger(wrapper, 'value')
    },
  }

  return wrapper
}

因此 .value 不是随意增加的语法负担,而是一个真实的拦截点。模板里不用写 .value,是因为 Vue 在模板求值时做了解包;JavaScript 代码里仍然需要明确访问它。

对象当然也可以放进 ref。Vue 会把对象值转换成响应式对象,所以常见经验不是“对象一律用 reactive,基本类型一律用 ref”这么绝对。更实用的判断是:这个状态是否需要整体替换、是否需要跨函数传递一个稳定引用,以及团队更希望用哪种一致写法。

computed:合单预打包,但不会提前把所有包裹封好

我更愿意把 computed 想成驿站的合单预打包。

没人取件时,驿站不会不停重做同一份合单;第一次有人来取,才按清单装箱。只要清单上的货没变,后面再来取同一单可以直接发上次封好的包。货变了,先把“这份合单还能不能用”的标记改掉,等下一次真的有人来取时再重新打包。

这对应 computed 的两个关键点:

  • 惰性:没人读取 .value 时,不必立即重新计算;
  • 缓存:依赖没变时,重复读取可以复用上次结果。
ts
const price = ref(12)
const count = ref(2)

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

console.log(total.value) // 重新计算,24
console.log(total.value) // 直接使用缓存,24

count.value = 3          // 先让缓存失效
console.log(total.value) // 重新计算,36

computed 自己既是订阅者,也是一个可被别人订阅的值。它订阅 pricecount;组件渲染又可以订阅 total.value。Vue 3.5 的 ComputedRefImpl 内部也有自己的 Dep、依赖链和版本信息,用来完成这两层关系。

watch 和 watchEffect:指定提醒与自动登记

watchwatchEffect 都建立在响应式副作用之上,但它们回答的问题不同。

watch 像给某个快递单号设置提醒。你明确告诉系统要看什么,状态变化后再拿到新旧值:

ts
watch(
  () => cart.count,
  (count, previousCount) => {
    console.log(`数量从 ${previousCount} 变成 ${count}`)
  },
)

依赖来源和执行逻辑是分开的。默认情况下,回调不会因为创建监听就立刻执行。

watchEffect 更像工作人员先开始处理一次任务,并把过程中查询过的项目自动加入通知名单:

ts
watchEffect(() => {
  console.log(`当前总价:${cart.price * cart.count}`)
})

它会立即运行一次,并在同步执行期间自动收集读取过的响应式依赖。代码短,但依赖藏在函数体里。需要明确来源、旧值和新值时,我通常优先用 watch;逻辑本身就是“用到什么就跟随什么”时,watchEffect 更自然。

异步 watchEffect 还要注意一个边界:只有首次 await 之前同步读取的依赖会被自动收集。因为 await 之后已经进入另一个执行阶段,不能再简单地归到这次同步副作用登记里。

几个容易误判的地方

修改数据不等于立刻改完 DOM

响应式触发的是更新调度。Vue 会把同一轮里的多次修改合并,再刷新组件。刚改完状态就要读取更新后的 DOM 时,应使用 nextTick,而不是假设赋值语句结束时 DOM 已经同步完成。

computed 不是后台常驻计算

依赖变化时,计算属性通常先失效;下一次读取才重新求值。把它理解为缓存,比理解为“自动运行的函数”更准确。

reactive 返回的不是原对象

代理和原对象行为相近,但身份不同:

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

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

业务代码应尽量围绕代理工作。混用原对象和代理,会让依赖追踪与身份比较都变得难以判断。

真实实现比教学代码多很多边界

数组长度、Map 的键迭代、属性新增与删除、嵌套副作用、依赖清理、调度器和递归保护,都不是几十行示例能覆盖的。最小实现的价值是建立地图,不是替代源码。

再走一遍完整路径

现在把购物车例子重新走一遍:

  1. 组件渲染或 computed 开始运行,当前 ReactiveEffect 被设为活跃订阅者;
  2. 代码读取 cart.pricecart.count
  3. Proxy.get 调用 track
  4. tracktargetMap 中找到对象和属性,把当前副作用连到相应 Dep
  5. 点击按钮执行 cart.count++
  6. Proxy.set 发现值真的变化,调用 trigger
  7. count 对应的依赖通知订阅者,计算属性缓存失效,组件更新进入调度队列;
  8. 下一轮渲染读取 total.value,得到重新计算后的结果;
  9. Vue 比较新旧虚拟 DOM,只更新真正变化的部分。

“页面自己更新”并不是魔法。它只是把读取、依赖和更新连成了一条稳定的链。

以后遇到响应式问题,我通常先问三个问题:这次读取有没有经过代理或 .value?读取发生时有没有活跃副作用?修改之后,通知名单里到底登记了谁?沿着这三个问题往下查,多数问题都会从“Vue 怎么没反应”变成一个可以定位的具体环节。

参考资料