# 依赖收集和视图同步更新原理

之前已经讲解过 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,其实就是将系统结构分为了三个层次:

  1. Model(M):数据层,负责数据的处理和存储,是我们实现业务逻辑的地方。也就是 Vue 中的 data 对象。
  2. View(V):视图层,负责将数据展示给用户。也就是 Vue 中的模版。
  3. 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、梳理执行流程

  1. 响应式数据初始化

    • 创建observer实例,进行defineproperty数据劫持
    • 在数据劫持时,为每个属性创建对应的 dep 对象,并设置 set/get方法,在 get 中进行依赖收集,set 中执行 dep.notify 也就是响应式数据变化的通知
  2. 开始准备执行组件的挂载逻辑

    • 执行 new watcher,生成当前组件对应的渲染 watcher

    • 创建watcher实例时,会自动执行一次实例的 get 方法,也就是封装了渲染逻辑的方法,从而开始执行组件的渲染逻辑,也就是 watcher.get()

          // watcher中封装的组件对应的渲染函数
          get() {
              // 在渲染开始之前,把 watcher 挂载到全局,也就是 Dep 类上(静态属性)
              Dep.target = this
              this.getter();
              // 渲染结束,把全局的 watcher 卸载
              Dep.target = null
          }
      
    • 渲染方法第一步,将全局的 Dep.target 的值赋为当前组件所对应的渲染 watcher

    • 渲染方法第二步,开始执行渲染逻辑,也就是 vm._rendervm._update

      • 执行 _render 方法时,render 方法中的变量会向 vm 上取值,触发属性劫持时设置的 get 方法
      • get 方法中做判断,发现当前存在全局的 watcher实例,将其添加到 dep.subs 中
    • 执行完所有的渲染逻辑之后,将全局的Dep.target的值赋为 null

  3. 后续我们再访问响应式数据时

    • 触发 get 方法
    • 由于当前全局的 Dep.target 值为 null,不会触发依赖收集流程