4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理
前言
我们从前面的文章中知道的所谓发布订阅模式的本质是不管代码结构如何变化,它的核心都是管理对象间的依赖关系,或者说是事件间的依赖关系,一方变化了,所有跟其建立依赖关系的依赖都将得到通知。同时发布者对象既可以是发布者也可以是订阅者,所以我们不能只从代码组织结构去分辨模式,而是从意图去分辨。
Vue2 的数据响应式的实现,在代码结构层面多少是看得出有经典发布订阅模式的架构影子,所以社区里也有人从发布订阅模式角度去分析过,但 Vue3 的数据响应式的实现从代码结构上来看跟所谓标准的发布订阅模式的代码架构差别是很大的。一般社区作者也不从发布订阅模式的角度去分析它的实现原理,那么今天就让我们从发布订阅模式的角度去理解 Vue3 的数据响应式原理吧。
发布订阅模式原理回顾
我们经过前面的学习,我们很容易通过发布订阅模式初步实现 Vue3 的 reactive API,代码如下:
class Dep {
constructor() {
// 订阅者存储中心
this.subs = []
}
// 添加订阅者
addSub(sub) {
this.subs.push(sub)
}
// 通知订阅者
notify() {
this.subs.forEach(sub => sub())
}
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 存在依赖就把依赖收集到依赖存储中心
activeEffect && dep.addSub(activeEffect)
return Reflect.get(target, key)
},
set(target, key, val) {
const result = Reflect.set(target, key, val)
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
dep.notify()
return result
}
})
}
我们就可以进行以下测试了:
const proxy = reactive({ author: 'Cobyte' })
// 订阅者
const subscriber = () => {
console.log(`我是:${proxy.author}`)
}
activeEffect = subscriber
subscriber()
activeEffect = null
// 修改
proxy.author = 'coboy'
根据上一篇 Vue2 的数据响应式原理的实践,我们可以做小小的优化:
class Dep {
// 省略...
- addSub(sub) {
+ addSub() {
if (activeEffect) {
- this.subs.push(sub)
+ this.subs.push(activeEffect)
}
}
// 省略...
}
// 省略...
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 存在依赖就把依赖收集到依赖存储中心
- activeEffect && dep.addSub(activeEffect)
+ dep.addSub()
return Reflect.get(target, key)
},
// 省略...
})
}
我们上面 reactive 的实现,每个订阅者还不能进行跟每个对象的属性进行隔离的。什么意思呢?看以下测试代码:
const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
// 订阅者
const subscriber = () => {
console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
console.log(`日期是:${proxy.date}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null
// 修改
proxy.author = 'coboy'
测试结果如下:
![]()
我们可以看到最后修改 author 属性值的时候,两个订阅者函数都执行了。是因为我们在 getter 进行订阅的时候,把不同属性的订阅者都存储在同一个全局变量中了,而在 Vue2 中把每一个属性的消息代理都通过闭包进行了隔离,也就是每一个属性都拥有属于自己的消息代理,相当于每一个属性都是一个发布者。
而 Vue3 中的 Proxy API 很明显不能通过闭包来进行隔离每个属性的消息代理。那么我们根据前面的发布订阅模式的实践理解,还可以通过给消息代理对象通过添加 key 的方式来让订阅者只订阅自己感兴趣的内容。
那么相关代码修改如下:
class Dep {
constructor() {
// 订阅者存储中心
- this.subs = []
+ this.subs = {}
}
// 添加订阅者
- addSub() {
+ addSub(key) {
+ if (!this.subs[key]) {
+ this.subs[key] = []
+ }
if (activeEffect) {
- this.subs.push(sub)
+ this.subs[key].push(activeEffect)
}
}
// 通知订阅者
- notify() {
+ notify(key)
- this.subs.forEach(sub => sub())
+ this.subs[key].forEach(sub => sub())
}
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 存在依赖就把依赖收集到依赖存储中心
- dep.addSub()
+ dep.addSub(key)
return Reflect.get(target, key)
},
set(target, key, val) {
const result = Reflect.set(target, key, val)
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
- dep.notify()
+ dep.notify(key)
return result
}
})
}
我们经过上面的修改再进行测试,我们发现已经可以正确打印我们期待的结果了。
![]()
我们上面实现的 reactive 函数还存在一个问题,我们现在可以通过 key 来把不同订阅者进行分类,但不同的对象中可能会存在相同的 key,例子如下:
const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const proxy2 = reactive({ author: 'Cobyte2' })
// 订阅者
const subscriber = () => {
console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
console.log(`我是:${proxy2.author}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null
// 修改
proxy.author = 'coboy'
测试结果如下:
![]()
我们发现我们只修改了 proxy.author 的值,但订阅者2 subscriber2 也执行了,这不是我们期待的结果,所以我们还要迭代我们的功能。
我们既然可以添加 key 来让订阅者订阅自己喜欢的内容,那么是否还可以进行增加 key, 来区分不同的对象呢?我们把对象也当成一个 key,也就是在 getter 添加依赖的时候这样操作:dep.addSub(target, key, activeEffect),那么在 setter 的时候这样操作:dep.notify(target, key)。很明显我们可以通过 Map 来把一个对象作为一个 key。
所以我们对消息代理中心做以下修改:
class Dep {
constructor() {
// 订阅者存储中心
- this.subs = {}
+ this.subs = new Map()
}
// 添加订阅者
- addSub(key) {
+ addSub(target, key) {
+ let depsMap = this.subs.get(target)
+ if (!depsMap) {
+ depsMap = {}
+ this.subs.set(target, depsMap)
+ }
- if (!this.subs[key]) {
- this.subs[key] = []
- }
+ if (!depsMap[key]) {
+ depsMap[key] = []
+ }
if (activeEffect) {
- this.subs[key].push(activeEffect)
+ depsMap[key].push(activeEffect)
}
}
// 通知订阅者
- notify(key) {
+ notify(target, key) {
- this.subs[key].forEach(sub => sub())
+ const depsMap = this.subs.get(target)
+ if (!depsMap) return
+ const deps = depsMap[key]
+ deps && deps.forEach(sub => sub())
}
}
接着我们也去修改 reactive 中相关的地方:
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 存在依赖就把依赖收集到依赖存储中心
- dep.addSub(key)
+ dep.addSub(target, key)
return Reflect.get(target, key)
},
set(target, key, val) {
const result = Reflect.set(target, key, val)
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
- dep.notify(key)
+ dep.notify(target, key)
return result
}
})
}
我们重新测试,我们发现打印了如期的结果:
![]()
桶的数据结构设计?
我们看到通过发布订阅模式去理解 Vue3 的数据响应式原理,理解起所谓依赖数据结构 桶,是非常好理解的。我们通过由浅入深的地讲解所谓 桶 数据结构的形成,它的形成是自然而然的形成的,而不是一开始就经过特别精心设计的,它没有那么的神秘,它是由最简单的功能一步步迭代形成的,是非常符合我们的日常开发规律的,因为我们日常的应用也是由最简单的功能开始慢慢迭代成非常复杂的功能。一开始所谓 桶,只是一个 Array (可以简单理解为:[]) 的结构,后来我们通过增加 key 来区分不同的订阅者,这行为在发布订阅模式中就是通过 key 来让订阅者只订阅自己感兴趣的内容;增加 key 后,桶 的数据结构变为 Object -> Array (可以简单理解为:{ key: [] }),再后来我们继续增加响应式对象作为 key,来区分不同的属性,避免不同响应式数据中可能存在相同属性的情况。最后我们的 桶 的数据结构变为 Map -> Object -> Array (可以简单理解为:{ target: { key: [] } })。
我们熟悉 Vue3 源码的同学会知道,所谓 桶 的数据结构跟我们上面还是区别的,那其实都是性能优化迭代的结果,我们也可以继续迭代我们的功能。首先是我们的订阅者是通过 Array 的方式存储的,为了防止重复添加订阅者,我们需要在执行完订阅者函数之后把 activeEffect 变量设置为 null,同时也是为了确保只在副作用函数中读取响应式变量才进行依赖收集。我们可以把订阅者的存储方法改成 Set 的数据结构,因为 Set 具有自动去除重复的功能。
相关代码修改如下:
class Dep {
// 省略...
addSub(target, key, sub) {
// 省略...
if (!depsMap[key]) {
- depsMap[key] = []
+ depsMap[key] = new Set()
}
if (activeEffect) {
- depsMap[key].push(activeEffect)
+ depsMap[key].add(activeEffect)
}
}
// 省略...
}
经过上面修改,我们的 桶 结构变成了 Map -> Object -> Set。我们还可以继续优化,我们可以把中间的 Object 改成 Map,因为在频繁增删键值对和存储大量数据的场景下 Map 的性能要比 Ojbect 更好。
class Dep {
// 省略...
addSub(target, key, sub) {
let depsMap = this.subs.get(target)
if (!depsMap) {
- depsMap = {}
depsMap = new Map()
this.subs.set(target, depsMap)
}
+ let dep = depsMap.get(key)
- if (!depsMap[key]) {
+ if (!dep) {
- depsMap[key] = new Set()
+ dep = new Set()
+ depsMap.set(key, dep)
+ }
if (activeEffect) {
- depsMap[key].add(activeEffect)
+ dep.add(activeEffect)
}
}
// 通知订阅者
notify(target, key) {
const depsMap = this.subs.get(target)
if (!depsMap) return
- const deps = depsMap[key]
+ const deps = depsMap.get(key)
deps && deps.forEach(sub => sub())
}
}
最后我们还可以继续优化的地方就是把存储订阅者的变量 this.subs 由 Map 类型改成 WeakMap 类型。
class Dep {
constructor() {
// 订阅者存储中心
- this.subs = new Map()
+ this.subs = new WeakMap()
}
}
为什么不采用 WeakMap 而不采用 Map 呢?我们通过下面的一个例子来说明:
const map = new Map()
const weakMap = new WeakMap()
function test() {
const mapObj = { test: 'mapObj' }
const weakMapObj = { test: 'weakMapObj' }
map.set(mapObj, true)
weakMap.set(weakMapObj, true)
}
test()
console.log('map', map)
console.log('weakMap', weakMap)
我们从打印的结果中可以一目了然地看出两者的区别,WeakMap 对 key 是弱引用的,所谓弱引用就是一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候,垃圾回收器就会把该对象从内存移除。Map 则不会把 key 对象进行移除,这样就会容易导致内存溢出,就算不内存溢出,当数据大的时候,操作性能也会下降,所以 Vue3 源码中就采用了 WeakMap。
最后小结 Vue3 底层源码是使用 WeakMap 和 Map 来构建依赖关系图,具体来说是:
-
targetMap是一个WeakMap,键是响应式对象(target),值是一个Map(depsMap)。 -
depsMap的键是对象的属性(key),值是一个Dep(即一个Set),存储了所有依赖该属性的副作用函数。
订阅者中介的实现
我们通过前面的文章对发布订阅模式的学习,可以知道发布者可以抽离一些公共功能统一放到一个中介类中,也就是所谓的事件总线或者消息代理,而订阅者同样也可以进行中介化,从而实现订阅者的多态化。所谓多态就是当不同的对象去执行同一个方法时会产生出不同的状态。我们通过上一篇文章可以知道 Vue2 中所谓的 Watcher 类其实就是订阅者中介,在项目中不同的组件其实底层都是通过 Watcher 类来执行的,而所谓依赖收集,其中收集的是 Watcher,那些响应式数据发生变化后去通知的也是 Watcher,然后再通过 Watcher 去执行具体的组件渲染。
那么 Vue3 的数据响应式也是通过发布订阅模式实现的,那么很自然的也存在订阅者中介。在 Vue3 源码中 ReactiveEffect 类从发布订阅模式的角度理解就是订阅者中介的角色,所以从发布订阅模式的角度理解 Vue3 的数据响应式原理,就非常容易理解为什么要有一个 ReactiveEffect 类了,甚至不用去看具体的实现细节,我们都可以知道 ReactiveEffect 所实现的功能是什么了。
我们知道 Vue2 中的 Watcher 有一个 update 方法,就是在发布者去通知所有订阅者的时候,订阅者统一执行的方法就是 update,那么很明显 ReactiveEffect 也同样需要这样的一个方法,在 Vue3 源码中这个方法叫 run,同样初始化的时候需要接收一个函数作为参数也就是具体订阅者需要做的事情。
ReactiveEffect 的初步实现:
class ReactiveEffect {
constructor(fn) {
this._fn = fn
}
run () {
// 根据 Vue2 的数据响应式原理,我们知道在执行具体订阅者函数之前需要把当前订阅者赋值给一个中间变量。
activeEffect = this
this._fn()
// 确保只在副作用函数中读取响应式变量才进行依赖收集
activeEffect = null
}
}
然后我们进行测试:
const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const _effect = new ReactiveEffect(() => {
console.log(`我是:${proxy.author}`)
})
_effect.run()
proxy.author = 'coboy'
我们可以看到正确打印了结果:
![]()
同时我们发现 ReactiveEffect 的订阅者函数参数初始化在外部手动执行的,而 Vue2 的 Watcher 中的订阅者函数初始化在 Watcher 内部实例化的时候自动执行的,这个只是设计上区别。
我们把上述实现订阅的过程进行封装一下,那么就是 effect API 了,代码如下:
function effect(fn) {
const _effect = new ReactiveEffect(fn)
_effect.run()
}
从发布订阅模式的角度来看本质上 Vue3 的数据响应式实现原理跟 Vue2 的数据响应式原理的实现是一脉相承的。
互为订阅者
我们通过前面文章的学习,我们知道在 Vue2 中会存在发布者中介类 Dep 和订阅者类 Watcher 互为订阅者的情况,场景就是可能会取消某一个副作用函数的中的响应式数据的追踪,比如组件卸载了,那么我们就需要停止组件的依赖追踪。在 Vue3 中自然也存在这种场景,那么也就说在 Vue3 中也存在互为订阅者的情况。但在 Vue3 中的情况又会跟 Vue2 不一样,Vue2 是订阅者 Watcher 类直接订阅发布者中介类 Dep,因为在 Vue2 中每一个 Dep 实例都和一个发布者关联,也就是和每一个属性或者对象进行关联。而在 Vue3 中因为是通过 Proxy API 实现的数据响应式,每一个 Dep 的实例并不对应着具体的属性,所以我们要找到对应具体的属性的记录的变量,其实就是对应 key 的记录变量。
我们再看看 Dep 中关于对应 key 部分的订阅者记录变量部分代码:
class Dep {
// 省略...
addSub(target, key) {
let depsMap = this.subs.get(target)
if (!depsMap) {
depsMap = new Map()
this.subs.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, deps)
}
if (activeEffect) {
dep.add(activeEffect)
}
}
// 省略...
}
我们可以看到对应每一个 key 的订阅者记录变量是 deps,所以我们只需要把对应的 deps 记录到 ReactiveEffect 中即可。
首先我们修改 ReactiveEffect 类,添加记录变量 deps:
class ReactiveEffect {
+ // 记录哪些变量记录了该订阅者,在 Vue2 中则是记录哪些 Dep 记录了该 Watcher
+ deps = []
// 省略...
}
接着我们在记录响应式数据对象的 key 的消息代理对象的地方把对应的 key 的消息代理对象添加到订阅者 ReactiveEffect 的 deps 变量中,代码如下:
class Dep {
// 省略...
addSub(target, key) {
// 省略...
if (activeEffect) {
deps.add(activeEffect)
+ activeEffect.deps.push(deps)
}
}
// 省略...
}
这样我们就完成了对应 key 的变量对 ReactiveEffect 的订阅,那么有订阅,也就有取消订阅。
取消订阅功能如下:
class ReactiveEffect {
// 省略...
// 取消订阅
+ stop () {
+ this.deps.forEach(dep => dep.delete(this))
+ }
}
接着我们再修改 effect API:
function effect(fn) {
const _effect = new ReactiveEffect(fn)
_effect.run()
+ return _effect
}
这样我们就可以进行以下测试了:
const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const _effect = effect(() => {
console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
_effect.stop()
proxy.author = '掘金签约作者'
打印结果如下:
![]()
我们看到取消依赖追踪后,我们再去修改响应式数据,我们之前设置的订阅者函数就不再执行了,也就是得不到通知了。
那么停止依赖追踪之后,我又想它继续进行依赖追踪呢?这样我们就需要把 ReactiveEffect 中的 run 方法也返回出来。
我们继续进行 effect API 的功能迭代,新的修改如下:
function effect(fn) {
const _effect = new ReactiveEffect(fn)
_effect.run()
+ const runner = _effect.run.bind(_effect)
+ runner.effect = _effect
+ return runner
}
这样我们就可以在取消依赖追踪后,还可以在某个时机中又恢复依赖追踪了,测试代码如下:
const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const runner = effect(() => {
console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
runner.effect.stop()
proxy.author = '掘金签约作者'
// 恢复依赖追踪
runner()
proxy.author = '恢复依赖追踪了'
我们可以看到如期打印了我们期待的结果:
![]()
为什么 Vue3 的发布订阅模式不采用传统代码结构?
我们上面实现 Vue2 的数据响应式原理是很明显采用了发布订阅模式的,因为我们存在一个发布者中介类 Dep,这个代码结构跟传统教学中的发布订阅模式中的代码结构是很相似的。但实际上 Vue3 源码中是不存在发布者中介类的,也就是跟传统发布订阅模式的代码结构是不相同的,那么是否意味着 Vue3 并没有采用发布订阅模式呢?答案是否定的,正如我们前面文章中所说的那样,判断模式不能从代码结构上进行判断,而应该从代码意图。
class Dep {
constructor() {
// 订阅者存储中心
this.subs = new WeakMap()
}
// 添加订阅者
- addSub(target, key) {
+ track(target, key) {
let depsMap = this.subs.get(target)
if (!depsMap) {
depsMap = new Map()
this.subs.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
if (activeEffect) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
// 通知订阅者
- notify(target, key) {
+ trigger(target, key) {
const depsMap = this.subs.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
deps && deps.forEach(effect => effect.run())
}
}
上面我们经过对方法名称的修改,我们的代码结构从命名上跟 Vue3 源码有些类似了,我们接着把 Dep 类也去掉:
// 全局订阅者记录变量
const targetMap = new WeakMap()
// 添加订阅者
function track(target, key) {
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)
}
if (activeEffect) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
// 通知订阅者
function trigger(target, key){
const depsMap = targetMap.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
deps && deps.forEach(effect => effect.run())
}
接着我们也要把 reactive 中的相关代码也进行修改:
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 存在依赖就把依赖收集到依赖存储中心
- dep.addSub(target, key)
+ track(target, key)
return Reflect.get(target, key)
},
set(target, key, val) {
const result = Reflect.set(target, key, val)
// 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
- dep.notify(target, key)
+ trigger(target, key)
return result
}
})
}
我们可以看到经过上述修改之后,我们的代码结构跟 Vue3 源码是一模一样的了,但并不是说代码结构变了,模式也变了,上述代码结构依然是发布订阅模式。那么 Vue3 为什么要把依赖收集和依赖触发的函数进行分开呢?主要是因为分开之后依赖收集和依赖触发的函数就可以分别独立导出了,给其他功能 API 比如 ref、computed 使用了,代码可以达到最极致的抽象及复用。
确保只在副作用函数中读取响应式变量才进行依赖收集
不采用 Proxy API 实现数据响应式
因为 Proxy 无法提供对原始值的代理,所以我们需要对原始值的响应式进行特别处理,我们可以使用一层对象作为包裹,间接实现原始值的响应式方案。
当我们不通过 Proxy 实现代理的时候,除了使用 Vue2 中使用的 Object.defineProperty以外,我们还可以根据前面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显我们可以使用在发布订阅模式那篇中讲到的公众号的例子。
// 定义发布者公众号
const weChatOfficialAccount = {
// 订阅公众号的人的记录列表
subscribers: [],
// 文章内容
article: '原始值内容',
// 发布文章
setArticle(value) {
this.article = value
// 更新文章的时候通知所有的订阅者
this.notify()
},
// 添加订阅者
addDep(fn) {
// 把订阅者添加进记录列表
this.subscribers.push(fn)
},
// 广播信息
notify(title) {
// 发布信息时就是把记录列表中的订阅者全部通知一次
this.subscribers.forEach(fn => fn(title));
}
}
上述代码就是前面我们实现公众号讲解发布订阅模式的例子。在上述例子中,我们实现了在数据更新的时候触发依赖,也就是 setArticle 函数。那么我们再实现在数据读取的时候进行依赖收集即可,为了现在这个功能,我们把读取 article 属性值的行为也封装成一个函数。
代码如下:
// 定义发布者公众号
const weChatOfficialAccount = {
// 订阅公众号的人的记录列表
subscribers: [],
// 文章内容
article: '',
+ getArticle() {
+ return this.article
+ },
// 省略...
}
这样我们就可以通过以下的方式获取文章内容了:
effect(() => {
console.log(`原始值内容:${weChatOfficialAccount.getArticle()}`)
})
// 更改内容
weChatOfficialAccount.setArticle(520)
同时我们的发布者的通知函数也需要进行修改:
// 定义发布者公众号
const weChatOfficialAccount = {
// 省略...
// 广播信息
- notify(title) {
+ notify() {
// 发布信息时就是把记录列表中的订阅者全部通知一次
- this.subscribers.forEach(fn => fn(title))
+ this.subscribers.forEach(dep => dep.run())
}
}
那么我们就可以在 getArticle 函数中进行依赖收集了:
// 定义发布者公众号
const weChatOfficialAccount = {
// 订阅公众号的人的记录列表
subscribers: [],
// 文章内容
article: '',
getArticle() {
+ // 进行依赖收集,也就是进行订阅
+ if (activeEffect) this.addDep(activeEffect)
return this.article
},
// 省略...
}
这样我们的测试结果如下:
![]()
我们上述方式是通过一个典型的发布订阅模式来实现对一个对象的观察,当这个对象发生改变之后,所有依赖该对象的订阅者都将得到通知。
我们通过一个工厂函数上面的公众号对象进行进行封装,代码如下:
// ref 工厂函数
function ref(value) {
return {
// 订阅公众号的人的记录列表
subscribers: [],
// 文章内容
_value: value,
getArticle() {
if (activeEffect) this.addDep(activeEffect)
return this._value
},
// 发布文章
setArticle(value) {
this._value = value
// 更新文章的时候通知所有的订阅者
this.notify()
},
// 添加订阅者
addDep(fn) {
// 把订阅者添加进记录列表
this.subscribers.push(fn)
},
// 广播信息
notify() {
// 发布信息时就是把记录列表中的订阅者全部通知一次
this.subscribers.forEach(dep => dep.run());
}
}
}
我们可以看到经过上述的代码封装之后,我们实现了对原始值的响应式。那么接下来我们希望通过普通的方式获取和设置对象的值:
const weChatOfficialAccount = ref('初始值')
effect(() => {
console.log(`原始值内容:${weChatOfficialAccount.article}`)
})
// 更改内容
weChatOfficialAccount.article = 520
通过前面的学习我们知道除了使用 Object.defineProperty 进行显式声明属性访问器之外,还可以通过字面量的方式,本质还是属性访问器。
修改如下:
function ref(value) {
return {
// 订阅公众号的人的记录列表
subscribers: [],
// 文章内容
_value: value,
- getArticle() {
+ get article() {
if (activeEffect) this.addDep(activeEffect)
return this._value
},
// 发布文章
- setArticle(value) {
+ set article(value) {
this._value = value
// 更新文章的时候通知所有的订阅者
this.notify()
},
// 省略...
}
经过上述修改之后,我们就可以通过属性访问器像普通方式那样访问和设置对象的属性值了。
那么为了跟 Vue3 的 ref API 设计一致,我们把 article 属性改成 value:
function ref(value) {
return {
// 订阅公众号的人的记录列表
subscribers: [],
// 文章内容
_value: value,
- get article() {
+ get value() {
if (activeEffect) this.addDep(activeEffect)
return this._value
},
// 发布文章
- set article(value) {
+ set value(value) {
this._value = value
// 更新文章的时候通知所有的订阅者
this.notify()
},
// 省略...
}
那么改了之后我们的 ref 就跟 Vue3 的一样用法了:
const weChatOfficialAccount = ref('初始值')
effect(() => {
console.log(`原始值内容:${weChatOfficialAccount.value}`)
})
// 更改内容
weChatOfficialAccount.value = 520
接着我们对依赖收集函数 track 和依赖触发函数 trigger 进行修改让我们的代码尽可能地复用。修改如下:
// 添加订阅者
function track(target, key) {
// 省略...
if (activeEffect) {
- dep.add(activeEffect)
- activeEffect.deps.push(dep)
+ trackEffect(dep)
}
}
+ function trackEffect(dep) {
+ dep.add(activeEffect)
+ activeEffect.deps.push(dep)
+ }
// 通知订阅者
function trigger(target, key) {
// 省略...
- deps && deps.forEach(effect => effect.run())
+ triggerEffect(deps)
}
+ function triggerEffect(deps) {
+ if(deps) {
+ deps.forEach(effect => effect.run());
+ }
}
接着我们进行重构 ref 函数:
function ref(value) {
return {
// 订阅公众号的人的记录列表
- subscribers: [],
+ dep: new Set()
// 文章内容
_value: value,
get value() {
- if (activeEffect) this.addDep(activeEffect)
+ if (activeEffect) trackEffect(this.dep)
return this._value
},
// 发布文章
set value(value) {
this._value = value
// 更新文章的时候通知所有的订阅者
- this.notify()
+ triggerEffect(this.dep)
},
- // 添加订阅者
- addDep(fn) {
- // 把订阅者添加进记录列表
- this.subscribers.push(fn)
- },
- // 广播信息
- notify() {
- // 发布信息时就是把记录列表中的订阅者全部通知一次
- this.subscribers.forEach(dep => dep.run());
- }
- }
}
我们可以看到经过重构之后,我们的 ref 函数就变得比较整洁了,我们 ref 中的部分发布订阅的功能就和前面 reative 的发布订阅已经实现的功能代码进行了复用。
我们通过前面文章的学习,我们知道 Vue3 的 ref 底层是通过 OOP 的方式进行实现的,但本质还是跟我们上面一样的,那么我们也通过 OOP 的方式实现一遍吧。
实现代码如下:
class RefImpl {
_value
dep = new Set()
constructor(value) {
// 如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式
this._value = isObject(value) ? reactive(value) : value
}
get value() {
// 存在依赖就把依赖收集到依赖存储中心
if (activeEffect) trackEffect(this.dep)
return this._value
}
set value(val) {
this._value = val
// 更新文章的时候通知所有的订阅者
triggerEffect(this.dep)
}
}
function ref(value) {
return new RefImpl(value)
}
最终我们的测试结果还是一样的,这里唯一值得注意的是,如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式。
API 的设计技巧及知识的串联
我们上文中实现的数据响应式代码中,有一个函数的名称叫:observe,还有一个类叫:Observer,在 Vue2 源码中也是这么起名的。那么为什么要这么起名称呢?这么起名称有什么特殊的含义吗?
我们上面这个所谓数据响应式的原理,其实是在观察数据的变化,跟我们在 web 开发中观察 DOM 对象的变化的行为是很像的,甚至可以说本质是一样的。
MutationObserver 与 Vue2 数据响应式的联系
我们如果要观察一个 DOM 对象发生改变了就进行某些操作的话,可以通过 MutationObserver API来实现。例子如下:
// 获取 DOM 对象
const targetNode = document.querySelector('#some-id');
// 观察者回调函数
const subscriber = (mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((addedNode) => {
console.log(`添加了子元素:${addedNode.nodeName}`);
// 执行相应的处理逻辑
});
mutation.removedNodes.forEach((removedNode) => {
console.log(`移除了子元素:${removedNode.nodeName}`);
// 执行相应的处理逻辑
});
}
});
}
// 创建一个观察器实例并传入回调函数,当观察到变动时便执行回调函数
const observer = new MutationObserver(subscriber);
// 配置需要观察的选项
const config = {
childList: true, // 观察子元素是否发生变化
};
// 观察 DOM 对象是否发生变化
observer.observe(targetNode, config);
我们从上面的代码可以看出 MutationObserver 所做的事情,跟我们 Vue2 中对响应式数据的监听是一样的。DOM 对象就是我们 Vue2 中的响应式数据,当它发生变化之后就会去触发回调函数执行,相当于 Vu2 中的响应式数据发生改变后会触发 Watcher 一样。所以 MutationObserver 本质也是一个发布订阅模式,但它使用方式跟我们所谓传统的发布订阅模式是不一样的,但正如我们前面说的理解一种模式不应该从代码组织结构去进行分辨,而是意图。
所以我们从 Vue2 的数据响应式实现原理,就可以联系到 MutationObserver,然后联系它们的相同点,从而加深我们对知识的理解。当然尤雨溪当初给 Vue2 对一个对象实现数据响应式的处理函数和类命名为 observe 和 Observer,是否参考了 MutationObserver 的 API 命名规则我们无从考证,但它们的工作方式值得我们联系,从而加深我们的知识理解。
总结
本文从发布订阅模式的核心思想出发,深入剖析了 Vue3 响应式系统的设计本质。发布订阅模式的关键在于管理对象间的依赖关系——一方变化时,所有依赖方都能得到通知,而非拘泥于特定的代码结构。Vue3 虽然不再像 Vue2 那样拥有显式的 Dep 类,但其底层依然遵循这一模式。
通过逐步迭代,我们自然形成了 Vue3 中著名的“桶”数据结构:最初用一个数组存储订阅者,然后按属性 key 分类,再按响应式对象 target 隔离,最终演变为 WeakMap(target) → Map(key) → Set(effect) 的依赖图。这种结构并非凭空设计,而是功能迭代的自然产物,体现了发布订阅模式在 Proxy 场景下的灵活应用。
Vue3 中的 ReactiveEffect 类扮演了订阅者中介的角色,类似于 Vue2 的 Watcher,负责管理具体副作用函数的执行与依赖追踪。通过 effect 函数封装,我们可以轻松创建响应式副作用,并借助 stop 机制实现取消订阅,这体现了订阅者与发布者之间“互为订阅”的关系。
值得注意的是,Vue3 将依赖收集(track)和依赖触发(trigger)拆分为独立函数,而非保留传统的 Dep 类结构。这一设计变化并非模式的改变,而是为了提升代码复用性,让 ref、computed 等 API 也能共享同一套响应式核心。
此外,对原始值的响应式实现(ref)同样基于发布订阅模式——通过属性访问器(getter/setter)在读取时收集依赖,在修改时触发更新。当 ref 包裹对象时,内部会回退到 reactive 处理,保证了逻辑的一致性。
最后,从 API 命名(如 observe / Observer)到与浏览器原生 MutationObserver 的类比,都能看出响应式系统与观察者模式之间的深刻联系。理解这些设计背后的模式思想,远比记忆具体代码实现更有价值。
上述文章写于:2023 年,由于个人原因今年 2026 年发布。
我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。