# 依赖收集和视图同步更新原理
之前已经讲解过 Vue2 中的属性劫持、虚拟 DOM 以及通过虚拟 DOM 生成真实 DOM 的过程,但是还没有实现响应式数据变化,视图同步更新的核心功能。因为我们虽然对数据做了劫持,可以监听到响应式数据发生的变化,也实现了根据模版生成 render 函数再获取到虚拟 DOM,最后转化为真实 DOM 挂载的这个流程。但是,这两个流程之间没有关联,我们希望的是,当响应式数据发生变化之后,相应的组件会自动执行模版->render->虚拟DOM->真实DOM的这一套渲染流程,从而更新视图。本文我们就来讲解这部分原理。文章源码项目地址:Nwu23787/mini-Vue2: 手写 Vue2 核心源码 (github.com) (opens new window)
# 1、MVVM
Vue 是一个渐进式的,基于 MVVM 的 JavaScript 框架。所谓 MVVM 设计模式,主要是为了实现程序的解耦,程序解耦的好处就在于,我们可以在不同的部分中,关注不同的业务逻辑,也就是关注点的分离,而不是将各个部分杂糅在一起来写的。比如我们在 template 中,更加关注页面布局,而在 script 中,就更加关注数据的相关逻辑。
所谓 MVVM,其实就是将系统结构分为了三个层次:
- Model(M):数据层,负责数据的处理和存储,是我们实现业务逻辑的地方。也就是 Vue 中的 data 对象。
- View(V):视图层,负责将数据展示给用户。也就是 Vue 中的模版。
- ViewModel(VM):视图模型层,是数据层和视图层之间的桥梁。也就是 Vue 中的 Vue 实例。
MVVM 架构中,不允许 Model 层与 View 层直接进行交互,必须通过 ViewModel 来进行通信。ViewModel 其实就是类似于一个观察者,它可以观察到数据的变化,并且通知变化数据对应的视图进行更新;也可以观察到视图层发生的变化,从而通知相应的数据发生改变。
Vue 其实就是一个 MVVM 的实现者,核心就是实现了数据劫持和DOM监听。
# 2、观察者模式
观察者模式是一种行为设计模式。其核心由两个对象组成:观察者和被观察者。当被观察者发生变化的时候,它会通知所有的观察者对象,观察者对象会对这种变化做出相应的反应。
Vue 在实现响应式数据发生变化,视图同步更新时,就使用到了观察者模式。首先 Vue 定义了两个类,作为观察者和被观察者。第一个是 watcher,他其实就是定义了一个观察者,每个 watcher 实例上有一个 update 方法,执行 update 方法,可以更新对应的视图。第二个是 Dep,dep 其实就是一个被观察者,我们为每一个模版中用到的属性绑定一个 Dep 实例,dep 中维护着该属性对应的 watcher 队列,当属性发生变化的时候,遍历通知 dep 对应的所有 watcher。
# 3、Watcher
Watcher 中的关键部分就在于 update 这个方法,其实这个方法就是封装了渲染部分的代码逻辑。在前面我们已经说过,想要实现模版转化为真实 DOM,必须经过 render 函数将模版转化为虚拟 DOM,再经过 update 方法(本质上就是调用了 patch 方法)将虚拟 DOM 转化为真实 DOM 并挂载。所以,我们只要将 update 和 render 这两个方法,封装到 watcher 的 update 方法中,在每次数据发生变化的时候,调用这个 watcher 相应的 update 方法,就可以实现组件的重新渲染,从而更新视图。
watcher 部分源码:
import Dep from "./dep";
let id = 0;
class Watcher {
// vm watcher 对应的组件的实例,fn 组件对应的渲染函数
constructor(vm, fn, options) {
// 使用 id 来区分不同组件的 watcher
this.id = id++
// 把渲染函数绑定watcher到实例上,调用getter即可重新渲染,更新视图
this.getter = fn
// 标记是否是一个渲染watcher
this.renderWatcher = options
// 收集 watcher 对应的 dep
this.deps = []
// 使用 set 保存 deps 中所有 dep 的id,便于去重操作
this.depsId = new Set()
// 调用
this.get()
}
// 渲染函数
get() {
// 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
Dep.target = this
this.getter();
// 渲染结束,把全局的 watcher 卸载
Dep.target = null
}
// 给 watch 添加 dep
addDep(dep) {
// 要判断这个 dep 是否已经被记录,防止重复记录
let id = dep.id
if (!this.depsId.has(id)) {
// id 不存在与 depsId 中,则这个 dep 没有被 watcher 收集过
this.deps.push(dep)
this.depsId.add(id)
// 让 dep 收集 watcher
dep.addSub(this)
}
}
// 更新视图
update() {
this.get()
}
}
export default Watcher
每一个组件在挂载前,都会生成一个对应的 watcher:
export function mountComponent(vm, el) {
// 将 el 对应的真实 dom 挂载到 vm 上,便于后面获取
vm.$el = el
const updateComponent = () => {
// 1. 调用 render 方法,获得虚拟 DOM
let vnode = vm._render()
// 2. 根据虚拟 DOM,生成真实 DOM
vm._update(vnode)
}
const w = new Watcher(vm, updateComponent, true)
}
将组件对应的渲染逻辑封装成函数传递给 watcher,作为 watcher 的 update 方法。
由此可以看出,Vue 视图的更新,是以组件为粒度的,组件中任何数据发生变化,都会导致整个组件的全部刷新。所以,我们在开发时一定要养成组件化的好习惯,组件化的好处不仅仅是复用,更重要的是还可以实现页面的局部刷新。
# 4、Dep
Dep 是被观察者,我们需要为模版中用到的每一个响应式数据都创建一个 dep 对象与之绑定。Dep 类的定义如下:
let id = 0
class Dep {
constructor() {
this.id = id++
// 用于收集数据对应的 watcher
this.subs = []
}
// 将 dep 传递给 watcher,进行去重
depend() {
// this.subs.push(Dep.target)
Dep.target.addDep(this)
}
// 给 dep 收集对应的 watcher 依赖
addSub(watcher) {
this.subs.push(watcher)
}
// 数据更新后,通知 watcher 更新
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
Dep.target = null
export default Dep
其中 subs 用于收集属性所对应的依赖,也就是 watcher。当数据发生变化时,调用 dep 的 notify 方法,改方法会遍历 subs 中的所有 watcher,并执行每一个 watcher 所对应的 update 方法,更新视图。
# 5、收集依赖
所谓收集依赖,就是为 dep 收集其所对应的 watcher。这个过程是在组件初次渲染的过程中执行的。在组件初始化时,我们为每一个 data 中的属性都进行了数据劫持,也就是设置了 get 和 set 方法,我们在 get 方法中进行依赖的收集,在 set 方法中进行响应式数据更新的通知操作(就是通知观察者 watcher 数据发生了变化):
export function defineReactive(target, key, value) {
// 对属性值进行深层递归遍历
observe(value)
// 为每个属性绑定一个dep
let dep = new Dep()
// 闭包。对外暴露了 set 和 get 方法,从而使 value 值不会被回收
Object.defineProperty(target, key, {
// 访问属性的时候,触发get
get() {
if(Dep.target){
// 全局上存在 watcher,收集这个 watcher
dep.depend()
}
return value
},
// 修改属性的时候,触发set
set(newValue) {
console.log('set', newValue);
if (newValue === value) return
// 修改之后重新劫持,因为如果用户将值修改为对象,那么要对这个对象进行深度劫持
observe(newValue)
value = newValue
// 修改了响应式数据之后,通知观察者更新
dep.notify()
}
})
}
在 set 方法中,我们直接调用 dep 对象的 notify 方法通知 watcher。在 get 方法中,我们需要进行判断,只有模版中的响应式数据被访问时,我们才会给这个数据对应的 dep 添加渲染 watcher 这个依赖。那么如何判断是不是模版中使用的数据触发的 get 呢?其实是通过一个全局的属性来判断的,我们在执行组件的 render 和 update 方法前,会先给 Dep 类上挂载一个全局可访问的 target 静态属性,将 当前的 watch 作为静态属性的值。执行完渲染部分的逻辑之后,再将这个全局属性的值赋为 null。
// 渲染函数
get() {
// 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
Dep.target = this
this.getter();
// 渲染结束,把全局的 watcher 卸载
Dep.target = null
}
在 get 方法中,我们对当前的全局属性 Dep.target 进行了判断,当其存在且不为 null 时,我们才会将当前的 watcher 加入到属性的 dep.subs 中。当执行 render 函数时,render 函数会向 vm 上取值,此时就触发了响应式数据的 get 方法,而此时正处于渲染流程中, Dep.target 为当前组件所对应的 watcher,所以 get 方法被触发后,会将当前的 watcher 加入到这个响应式数据所对应的 dep 的 subs 队列中。当渲染完成之后,Dep.target 被置为了 null,那么之后我们再访问响应式变量,尽管依然会触发 get 方法,但是由于 Dep.target 为 null,不会触发依赖收集的流程。所以,这样就保证了,只有模版中用到的数据,我们才会去收集对应的 watcher 依赖。
# 6、梳理执行流程
响应式数据初始化
- 创建observer实例,进行defineproperty数据劫持
- 在数据劫持时,为每个属性创建对应的 dep 对象,并设置 set/get方法,在 get 中进行依赖收集,set 中执行 dep.notify 也就是响应式数据变化的通知
开始准备执行组件的挂载逻辑
执行 new watcher,生成当前组件对应的渲染 watcher
创建watcher实例时,会自动执行一次实例的 get 方法,也就是封装了渲染逻辑的方法,从而开始执行组件的渲染逻辑,也就是 watcher.get()
// watcher中封装的组件对应的渲染函数 get() { // 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性) Dep.target = this this.getter(); // 渲染结束,把全局的 watcher 卸载 Dep.target = null }渲染方法第一步,将全局的 Dep.target 的值赋为当前组件所对应的渲染 watcher
渲染方法第二步,开始执行渲染逻辑,也就是
vm._render和vm._update- 执行
_render方法时,render 方法中的变量会向 vm 上取值,触发属性劫持时设置的 get 方法 - get 方法中做判断,发现当前存在全局的 watcher实例,将其添加到 dep.subs 中
- 执行
执行完所有的渲染逻辑之后,将全局的Dep.target的值赋为 null
后续我们再访问响应式数据时
- 触发 get 方法
- 由于当前全局的 Dep.target 值为 null,不会触发依赖收集流程