【Vue2.x源码系列04】依赖收集原理(Dep、Watcher、Observer)
观察者模式定义了对象间一对多的依赖关系。即被观察者状态发生变动时,所有依赖于它的观察者都会得到通知并自动更新。解决了主体对象和观察者之间功能的耦合。
Vue中基于 Observer、Dep、Watcher 三个类实现了观察者模式
- Observer类 负责数据劫持,访问数据时,调用
dep.depend()
进行依赖收集;数据变更时,调用dep.notify()
通知观察者更新视图。我们的数据就是被观察者 - Dep类 负责收集观察者 watcher,以及通知观察者 watcher 进行 update 更新操作
- Watcher类 为观察者,负责订阅 dep,并在订阅时让 dep 同步收集当前 watcher。当接收到 dep 的通知时,执行 update 重新渲染视图
dep 和 watcher 是一个多对多的关系。每个组件都对应一个渲染 watcher,每个响应式属性都有一个 dep 收集器。一个组件可以包含多个属性(一个 watcher 对应多个 dep),一个属性可以被多个组件使用(一个 dep 对应多个 watcher)
Dep
我们需要给每个属性都增加一个 dep 收集器,目的就是收集 watcher。当响应式数据发生变化时,更新收集的所有 watcher
- 定义 subs 数组,当劫持到数据访问时,执行
dep.depend()
,通知 watcher 订阅 dep,然后在 watcher内部执行dep.addSub()
,通知 dep 收集 watcher - 当劫持到数据变更时,执行
dep.notify()
,通知所有的观察者 watcher 进行 update 更新操作
Dep有一个静态属性 target,全局唯一,Dep.target 是当前正在执行的 watcher 实例,这是一个非常巧妙的设计!因为在同一时间只能有一个全局的 watcher
注意:
渲染/更新完毕后我们会立即清空 Dep.target,保证了只有在模版渲染/更新阶段的取值操作才会进行依赖收集。之后我们手动进行数据访问时,不会触发依赖收集,因为此时 Dep.target 已经重置为 null
let id = 0
class Dep {
constructor() {
this.id = id++
// 依赖收集,收集当前属性对应的观察者 watcher
this.subs = []
}
// 通知 watcher 收集 dep
depend() {
Dep.target.addDep(this)
}
// 让当前的 dep收集 watcher
addSub(watcher) {
this.subs.push(watcher)
}
// 通知subs 中的所有 watcher 去更新
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
// 当前渲染的 watcher,静态变量
Dep.target = null
export default Dep
Watcher
不同组件有不同的 watcher。我们先只需关注渲染watcher。计算属性watcer和监听器watcher后面会单独讲!
watcher 负责订阅 dep ,并在订阅的同时执行dep.addSub()
,让 dep 也收集 watcher。当接收到 dep 发布的消息时(通过 dep.notify()
),执行 update 重新渲染
当我们初始化组件时,在 mountComponent 方法内会实例化一个渲染 watcher,其回调就是 vm._update(vm._render())
import Watcher from './observe/watcher'
// 初始化元素
export function mountComponent(vm, el) {
vm.$el = el
const updateComponent = () => {
vm._update(vm._render())
}
// true用于标识是一个渲染watcher
const watcher = new Watcher(vm, updateComponent, true)
}
当我们实例化渲染 watcher 的时候,在构造函数中会把回调赋给this.getter
,并调用this.get()
方法。
这时!!!我们会把当前的渲染 watcher 放到 Dep.target 上,并在执行完回调渲染视图后,立即清空 Dep.target,保证了只有在模版渲染/更新阶段的取值操作才会进行依赖收集
import Dep from './dep'
let id = 0
class Watcher {
constructor(vm, fn) {
this.id = id++
this.getter = fn
this.deps = [] // 收集当前 watcher 对应被观察者属性的 dep
this.depsId = new Set()
this.get()
}
// 收集 dep
addDep(dep) {
let id = dep.id
// 去重,一个组件 可对应 多个属性 重复的属性不用再次记录
if (!this.depsId.has(id)) {
this.deps.push(dep)
this.depsId.add(id)
dep.addSub(this) // watcher已经收集了去重后的 dep,同时让 dep也收集 watcher
}
}
// 执行 watcher 回调
get() {
Dep.target = this // Dep.target 是一个静态属性
this.getter() // 执行vm._render时,会劫持到数据访问,调用 dep.depend() 进行依赖收集
Dep.target = null // 渲染完毕置空,保证了只有在模版渲染阶段的取值操作才会进行依赖收集
}
// 重新渲染
update() {
this.get()
}
}
我们是如何触发依赖收集的呢?
在执行this.getter()
回调时,我们会调用vm._render()
,在_s()
方法中会去 vm 上取值,这时我们劫持到数据访问走到 getter,进而执行dep.depend()
进行依赖收集
流程:vm._render()
->vm.$options.render.call(vm)
-> with(this){ return _c('div',null,_v(_s(name))) }
-> 会去作用域链 this 上取 name
在 MDN 中是这样描述 with 的
JavaScript 查找某个未使用命名空间的变量时,会通过作用域链来查找,作用域链是跟执行代码的 context 或者包含这个变量的函数有关。'with'语句将某个对象添加到作用域链的顶部,如果在 statement 中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值
Observer
我们只会在 Observer 类 和 defineReactive 函数中实例化 dep。在 getter 方法中执行dep.depend()
依赖收集,在 setter 方法中执行dep.notity()
派发更新通知
依赖收集
依赖收集的入口就是在Object.defineProperty
的 getter 中,我们重点关注2个地方,一个是在我们实例化 dep 的时机,另一个是为什么递归依赖收集。我们先来看下代码
class Observer {
constructor(data) {
// 给数组/对象的实例都增加一个 dep
this.dep = new Dep()
// data.__ob__ = this 给数据加了一个标识 如果数据上有__ob__ 则说明这个属性被观测过了
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false, // 将__ob__ 变成不可枚举
})
if (Array.isArray(data)) {
// 重写可以修改数组本身的方法 7个方法
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walk(data)
}
}
// 循环对象"重新定义属性",对属性依次劫持,性能差
walk(data) {
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
// 观测数组
observeArray(data) {
data.forEach(item => observe(item))
}
}
// 深层次嵌套会递归处理,递归多了性能就差
function dependArray(value) {
for (let i = 0; i < value.length; i++) {
let current = value[i]
current.__ob__ && current.__ob__.dep.depend()
if (Array.isArray(current)) {
dependArray(current)
}
}
}
export function defineReactive(target, key, value) {
// 深度属性劫持;给所有的数组/对象的实例都增加一个 dep,childOb.dep 用来收集依赖
let childOb = observe(value)
let dep = new Dep() // 每一个属性都有自己的 dep
Object.defineProperty(target, key, {
get() {
// 保证了只有在模版渲染阶段的取值操作才会进行依赖收集
if (Dep.target) {
dep.depend() // 依赖收集
if (childOb) {
childOb.dep.depend() // 让数组/对象实例本身也实现依赖收集,$set原理
if (Array.isArray(value)) { // 数组需要递归处理
dependArray(value)
}
}
}
return value
},
set(newValue) { ... },
})
}
实例化 dep 的时机
我们只会在 Observer 类 和 defineReactive 函数中实例化 dep
- Observer类:在 Observer 类中实例化 dep,可以给每个数组/对象的实例都增加一个 dep
- defineReactive函数:在 defineReactive 方法中实例化 dep,可以让每个被劫持的属性都拥有一个 dep,这个 dep 是被闭包读取的局部变量,会驻留到内存中且不会污染全局
我们为什么要在 Observer 类中实例化 dep?
- Vue 无法检测通过数组索引改变数组的操作,这不是 Object.defineProperty() api 的原因,而是尤大认为性能消耗与带来的用户体验不成正比。对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如10000条
- Object.defineProperty() 无法监听数组的新增
如果想要在通过索引直接改变数组成员或对象新增属性后,也可以派发更新。那我们必须要给数组/对象实例本身增加 dep 收集器,这样就可以通过 xxx.__ob__.dep.notify()
手动触发 watcher 更新了
这其实就是 vm.$set 的内部原理!!!
递归依赖收集
数组中的嵌套数组/对象没办法走到 Object.defineProperty,无法在 getter 方法中执行dep.depend()
依赖收集,所以需要递归收集
举个栗子:data: {arr: ['a', 'b', ['c', 'd', 'e', ['f', 'g']], {name: 'libc'}]}
我们可以劫持 data.arr,并触发 arr 实例上的 dep 依赖收集,然后循环触发 arr 成员的 dep依赖收集。对于深层数组嵌套的['f', 'g']
,我们则需要递归触发其实例上的 dep 依赖收集
派发更新
对于对象
在 setter 方法中执行dep.notity()
,通知所有的订阅者,派发更新通知
注: 这个 dep 是在 defineReactive 函数中实例化的。 它是被闭包读取的局部变量,会驻留到内存中且不会污染全局
Object.defineProperty(target, key, {
get() { ... },
set(newValue) {
if (newValue === value) return
// 修改后重新观测。新值为对象的话,可以劫持其数据。并给所有的数组/对象的实例都增加一个 dep
observe(newValue)
value = newValue
// 通知 watcher 更新
dep.notify()
},
})
对于数组
在数组的重写方法中执行xxx.__ob__.dep.notify()
,通知所有的订阅者,派发更新通知
注: 这个 dep 是在 Observer 类中实例化的,我们给数组/对象的实例都增加一个 dep。可以通过响应式数据的__ob__获取到实例,进而访问实例上的属性和方法
let oldArrayProto = Array.prototype // 获取数组的原型
// newArrayProto.__proto__ = oldArrayProto
export let newArrayProto = Object.create(oldArrayProto)
// 找到所有的变异方法
let methods = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice'] // concat slice 都不会改变原数组
methods.forEach(method => {
// 这里重写了数组的方法
newArrayProto[method] = function (...args) {
// args reset参数收集,args为真正数组,arguments为伪数组
const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法,函数的劫持,切片编程
// 我们需要对新增的数据再次进行劫持
let inserted
let ob = this.__ob__
switch (method) {
case 'push':
case 'unshift': // arr.unshift(1,2,3)
inserted = args
break
case 'splice': // arr.splice(0,1,{a:1},{a:1})
inserted = args.slice(2)
default:
break
}
if (inserted) {
// 对新增的内容再次进行观测
ob.observeArray(inserted)
}
// 通知 watcher 更新渲染
ob.dep.notify()
return result
}
})