技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

深入解析 Vue 依赖收集原理

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/91-深入解析Vue依赖收集原理
本文目录14 个章节
  1. 一、先谈观察者模式
  2. 二、依赖收集到底要解决什么问题
  3. 三、Vue 2 的三个核心角色
  4. 四、Vue 2 的 Dep 与当前目标
  5. 五、Watcher 如何收集和清理依赖
  6. 六、观测对象和数组
  7. 七、从数据读取到视图更新的完整链路
  8. 八、计算属性为什么也能参与依赖收集
  9. 九、Vue 3 的依赖收集:Proxy 与 ReactiveEffect
  10. 十、观察者模式与 Vue 3 effect 的关系
  11. 十一、依赖清理与条件分支
  12. 十二、调试依赖收集
  13. 总结
  14. 参考资料

深入解析 Vue 依赖收集原理

原文以观察者模式为入口,分析 Vue 2 源码中的 DepWatcherObserver、数组观测和 defineReactive。本文尽量保留这条源码学习路线,清理抓取造成的代码拼接,并补充 Vue 3.5 的 ReactiveEffect、Proxy、集合类型和调度器。示例代码是教学版,不是完整框架源码。

一、先谈观察者模式

观察者模式用于建立“一对多”的关系:一个观察目标发生变化时,通知多个观察者。

class Subject {
  constructor() {
    this.observers = new Set()
  }

  addObserver(observer) {
    this.observers.add(observer)
    return () => this.observers.delete(observer)
  }

  notify(value) {
    for (const observer of [...this.observers]) {
      observer.update(value)
    }
  }
}

class Observer {
  constructor(update) {
    this.update = update
  }
}

const subject = new Subject()
subject.addObserver(new Observer(value => {
  console.log(`A 的最新值是:${value}`)
}))

subject.notify('Hello, world')

观察者通常知道自己订阅的是哪个 Subject;发布/订阅模式则经常通过事件总线或主题名解耦发布者和订阅者。两者在工程实践中会有重叠,不能只凭“有通知”就绝对区分。

Vue 的依赖收集和观察者模式有相似之处:一个响应式属性可能被渲染 effect、计算属性、侦听器等多个消费者使用;属性变化后,需要通知这些消费者。但 Vue 还要自动发现依赖、清理过期依赖、去重并通过 scheduler 批量更新,因此不只是简单的 observers.forEach()

二、依赖收集到底要解决什么问题

下面这个模板使用了 ABC

<template>
  <p>{{ A }}</p>
  <p>{{ B + C }}</p>
</template>

框架需要知道:

  1. 这次渲染实际读取了哪些响应式属性?
  2. A 改变时,应该通知哪些渲染或计算任务?
  3. 下一次条件分支改变后,如何移除已经不再使用的依赖?
  4. 同一个属性被读取多次时,如何避免重复订阅?
  5. 多个属性在同一轮事件中改变时,如何只调度一次组件更新?

最自然的时机是:

  • getter/Proxy get:读取数据时收集当前正在运行的依赖;
  • setter/Proxy set:数据写入时通知已经收集的依赖;
  • effect 执行前后:设置当前依赖、完成清理并恢复上层依赖上下文。

Vue 依赖收集和更新流程示意图(已本地化)

三、Vue 2 的三个核心角色

本文原始源码主要是 Vue 2 早期实现;Vue 2.7 的源码仍然保留这套核心概念。

1. Observer

Observer 负责把普通对象/数组转换为可观测数据:

  • 对对象的已有属性调用 defineReactive
  • 给被观测对象附加 __ob__,保存 observer 和对象/数组级别的 dep
  • 递归观测嵌套对象和数组元素;
  • 为数组替换七个变异方法。

2. Dep

Dep 是一个依赖容器,每个响应式属性通常有一个闭包中的 Dep,对象和数组 observer 还拥有自己的 dep。它负责:

  • depend():让当前 watcher 订阅自己;
  • addSub()/removeSub():管理订阅者;
  • notify():通知订阅的 watcher 调度更新。

3. Watcher

Watcher 包装一个 getter 或观察函数:

  • 渲染 watcher 执行 render 并更新组件;
  • 计算属性 watcher 通常是 lazy 的;
  • 用户 watcher 执行 watch 回调;
  • watcher 重新执行 getter 时会重新收集依赖,并清理过期依赖。

四、Vue 2 的 Dep 与当前目标

Vue 2.7 源码中的简化版 Dep 如下:

let uid = 0

class Dep {
  static target = null

  constructor() {
    this.id = uid++
    this.subs = []
  }

  addSub(watcher) {
    this.subs.push(watcher)
  }

  removeSub(watcher) {
    const index = this.subs.indexOf(watcher)
    if (index > -1) this.subs.splice(index, 1)
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify() {
    const subs = this.subs.slice()
    for (const watcher of subs) {
      watcher.update()
    }
  }
}

原文中的 Dep.target.addDep(this) 很关键:dep 不直接无条件把 watcher 塞进数组,而是交给 watcher 通过 id 去重。真实 Vue 2 源码还会使用 target stack:

const targetStack = []

function pushTarget(target) {
  targetStack.push(Dep.target)
  Dep.target = target
}

function popTarget() {
  Dep.target = targetStack.pop() || null
}

只用一个简单的全局变量,在计算属性嵌套渲染或 watcher getter 嵌套时容易丢失外层依赖;源码的栈结构就是为恢复上下文服务的。

五、Watcher 如何收集和清理依赖

class Watcher {
  constructor(getter, callback, { lazy = false } = {}) {
    this.getter = getter
    this.callback = callback
    this.lazy = lazy
    this.dirty = lazy
    this.value = lazy ? undefined : this.get()
    this.deps = []
    this.depIds = new Set()
    this.newDeps = []
    this.newDepIds = new Set()
  }

  get() {
    pushTarget(this)
    let value

    try {
      value = this.getter()
    } finally {
      popTarget()
      this.cleanupDeps()
    }

    return value
  }

  addDep(dep) {
    if (this.newDepIds.has(dep.id)) return

    this.newDepIds.add(dep.id)
    this.newDeps.push(dep)

    if (!this.depIds.has(dep.id)) {
      dep.addSub(this)
    }
  }

  cleanupDeps() {
    for (const dep of this.deps) {
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }

    this.deps = this.newDeps
    this.depIds = this.newDepIds
    this.newDeps = []
    this.newDepIds = new Set()
  }

  update() {
    if (this.lazy) {
      this.dirty = true
      return
    }

    queueWatcher(this)
  }

  run() {
    const oldValue = this.value
    const newValue = this.get()
    this.value = newValue
    this.callback?.(newValue, oldValue)
  }
}

真实实现还会支持 deepsync、用户回调错误处理、effect scope、watcher teardown 和 scheduler 去重。上面最值得记住的是两组依赖集合:

  • deps/depIds:上一轮 getter 使用过的依赖;
  • newDeps/newDepIds:本轮 getter 使用到的依赖。

当条件分支变化时,只有使用两组集合比较,才能把不再读取的依赖从 dep.subs 移除。

六、观测对象和数组

1. defineReactive

function defineReactive(object, key, initialValue) {
  const dep = new Dep()
  let value = initialValue
  let childOb = observe(value)

  Object.defineProperty(object, key, {
    enumerable: true,
    configurable: true,

    get() {
      if (Dep.target) {
        dep.depend()

        // 让访问对象/数组本身的 watcher 也能收到内部变化通知。
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },

    set(nextValue) {
      if (Object.is(nextValue, value)) return
      value = nextValue
      childOb = observe(nextValue)
      dep.notify()
    },
  })
}

这里有两个容易混淆的 Dep

  • 属性闭包中的 dep:处理 object.key = value
  • 对象/数组 __ob__.dep:处理对象整体、数组变异方法、Vue 2 $set/删除等场景。

dependArray 会递归访问数组元素上的 __ob__.dep,因为 Vue 2 无法为每个数组下标安装普通属性 getter:

function dependArray(array) {
  for (const item of array) {
    if (item?.__ob__) item.__ob__.dep.depend()
    if (Array.isArray(item)) dependArray(item)
  }
}

2. Vue 2 数组的七个变异方法

const arrayMethods = [
  'push', 'pop', 'shift', 'unshift',
  'splice', 'sort', 'reverse',
]

for (const method of arrayMethods) {
  const original = Array.prototype[method]

  Object.defineProperty(observedArray, method, {
    value(...args) {
      const result = original.apply(this, args)

      if (method === 'push' || method === 'unshift') {
        this.__ob__.observeArray(args)
      } else if (method === 'splice') {
        this.__ob__.observeArray(args.slice(2))
      }

      this.__ob__.dep.notify()
      return result
    },
  })
}

这段代码只是示意。Vue 2 真实实现会把方法挂在增强原型或数组实例上,并兼容不支持 __proto__ 的环境。

下面这些限制是 Vue 2 的经典检测缺陷:

// Vue 2 中不能直接可靠触发更新
vm.items[1] = 'x'
vm.items.length = 1
vm.user.newProperty = 1

// Vue 2 的替代方式
vm.$set(vm.items, 1, 'x')
vm.items.splice(1, 1, 'x')
vm.items.splice(1)
vm.$set(vm.user, 'newProperty', 1)

Vue 2 配置 Observer 和 Dep 的示意图(已本地化)

七、从数据读取到视图更新的完整链路

以模板中的 {{ message }} 为例:

初次渲染

  1. Vue 创建组件渲染 watcher;
  2. watcher 执行 render,并设置 Dep.target
  3. render 读取 message,触发 message 的 getter;
  4. getter 调用 dep.depend()
  5. watcher 把 dep 放进自己的依赖集合,dep 把 watcher 放进订阅列表;
  6. render 返回 vnode,Vue 完成首次 patch;
  7. watcher 清理本轮与上一轮依赖的差异。

数据变化

message setter
  ↓
dep.notify()
  ↓
render watcher.update()
  ↓
queueWatcher 去重
  ↓
异步 flush
  ↓
重新执行 render
  ↓
patch old vnode/new vnode

因此“setter 调用了 notify”并不代表 render() 在 setter 内立即同步执行。Vue 2 的默认 scheduler 会批处理 watcher;读取更新后的 DOM 应等待 $nextTick

八、计算属性为什么也能参与依赖收集

计算属性通常由一个 lazy watcher 表示:

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

在 Vue 2 的概念模型中:

  1. 计算属性第一次访问时执行 getter,收集 firstName/lastName
  2. 依赖变化时,计算 watcher 被标记为 dirty,而不是立刻无条件计算;
  3. 模板再次访问计算属性时,watcher 重新求值;
  4. 如果渲染 watcher 依赖了这个计算属性,计算 watcher 还需要把自己的依赖传递给外层 watcher(depend())。

这也是为什么“某个 computed 用到了 a”不代表 a 永远被所有组件依赖:只有计算属性被实际执行并被外层使用时,依赖链才会建立。

九、Vue 3 的依赖收集:Proxy 与 ReactiveEffect

Vue 3 的官方概念模型通常表示为:

WeakMap<target, Map<key, Dep>>
let activeEffect
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)
}

function trigger(target, key) {
  targetMap.get(target)?.get(key)?.forEach(effect => {
    if (effect.scheduler) effect.scheduler()
    else effect.run()
  })
}
function reactive(target) {
  return new Proxy(target, {
    get(object, key, receiver) {
      track(object, key)
      return Reflect.get(object, key, receiver)
    },
    set(object, key, value, receiver) {
      const oldValue = object[key]
      const result = Reflect.set(object, key, value, receiver)
      if (!Object.is(oldValue, value)) trigger(object, key)
      return result
    },
    deleteProperty(object, key) {
      const hadKey = Object.prototype.hasOwnProperty.call(object, key)
      const result = Reflect.deleteProperty(object, key)
      if (hadKey && result) trigger(object, key)
      return result
    },
  })
}

上述是语义模型,不是 Vue 3.5 的完整内部结构。当前 Vue 3 的 effect/dep 实现已经使用版本计数和双向链接等优化,但依赖收集的外部行为仍然可以用“读取 track、写入 trigger、effect 订阅 dep”理解。

1. Proxy 能处理更多操作

Vue 3 的基础 handler 可以追踪:

  • 对象属性读取、设置、删除;
  • 数组下标和 length 相关变化;
  • in 和 key 迭代;
  • 新增属性和删除属性。

Map、Set、WeakMap、WeakSet 使用专门的 collection handlers,不能把它们简单等同于普通对象属性:

const cache = reactive(new Map())

cache.set('user', { id: 1 })
cache.delete('user')
cache.clear()

这些操作会按照集合的 key、size 和迭代依赖触发不同的 effect。

2. ref 依赖的是 .value

const count = ref(0)

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

count.value++

ref 本身可以看作一个带 .value getter/setter 的对象。Vue 3 不是“完全不使用 getter/setter”,更准确地说是:reactive 对象使用 Proxy,ref 使用访问器属性。

十、观察者模式与 Vue 3 effect 的关系

Vue 2 文章常用 Dep.targetWatcher 解释依赖收集;Vue 3 使用 ReactiveEffect、scheduler 和 effect scope。两者的语义对应关系可以这样理解:

概念Vue 2Vue 3
当前执行任务Dep.targetactive effect
依赖容器DepDep/依赖链接
任务WatcherReactiveEffect
读取时收集getter → dependProxy/ref → track
写入时通知setter/array → notifyProxy/ref → trigger
调度watcher queuescheduler/job queue
作用域清理teardowneffect scope/unmount cleanup

这个表是概念映射,不表示 Vue 3 内部类名与 Vue 2 完全一一对应。

十一、依赖清理与条件分支

依赖收集不是只增加不删除。假设:

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

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

第一次运行订阅 enableda;当 enabled 变成 false 并重新运行后,effect 应该取消对 a 的订阅,改为订阅 b。否则以后修改 a 也会无意义地触发这个 effect,甚至导致逻辑错误。

Vue 2 的 newDeps/depIds,以及 Vue 3 effect 的依赖链接和清理机制,都是为此服务的。

十二、调试依赖收集

Vue 3 开发环境提供渲染调试钩子:

<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'

onRenderTracked(event => {
  debugger
  console.log('本次渲染追踪:', event)
})

onRenderTriggered(event => {
  debugger
  console.log('本次渲染触发:', event)
})
</script>

watchwatchEffectcomputed 也支持 onTrack/onTrigger 调试回调。它们只用于开发调试,不应当依赖调试事件实现业务逻辑。

依赖收集阶段示意图(已本地化)

依赖通知阶段示意图(已本地化)

依赖收集、派发和更新总结图(已本地化)

总结

  1. 依赖收集的核心是:effect 执行时读取数据,读取操作记录当前 effect;
  2. 数据变化后,依赖容器通知相关 effect,但实际视图更新通常还要经过 scheduler;
  3. Vue 2 使用 getter/setter、DepWatcherObserver 和数组七个变异方法;
  4. Vue 2 的对象新增属性、数组下标和 length 有检测限制,需要 $setsplice 等方式;
  5. Vue 3 的 reactive 使用 Proxy,ref 使用 .value getter/setter,集合类型有专门 handler;
  6. 依赖需要去重,也需要在条件分支变化后清理,否则会产生过度更新;
  7. 官方文档中的 Set/WeakMap 图是理解模型,Vue 3.5 的底层实现已经进一步优化。

参考资料

原文作者:一只小考拉。原文关于观察者模式、DepWatcher、数组观测、defineReactive 和依赖链路的内容予以保留;错误的 Markdown 代码围栏、拼接成一行的源码、Dep.target 无嵌套栈、Vue 3 仍等同于 Vue 2 以及抓取推广噪声已整理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS