vue源码学习11:响应式原理和依赖收集

834 阅读7分钟

以前面试官问我:请说一下vue的响应式是怎么实现的? 我答:用Object.defineProperty的原理实现的。 答案到此结束,我知道面试官再继续往下问,我就蒙了。

vue源码学习10: 将虚拟dom创建成真实dom一文中,实现了如何将虚拟Dom生成真实Dom。接下来核心问题来了,在script中改变数据,视图是如何发生变化的?

先来看一下vue的代码和实现的结果:

<div id="app">
    hello({{name}})word
</div>
<script src="dist/vue.js"></script>

<script>
    let vm = new Vue({
        el: '#app',
        data() {
            return { name: '阿飞', age: 11 }
        }
    });
    setTimeout(() => {
        vm.name = '1s后变成程序员'
    }, 1000)
</script>

在这里,data中定义了一个name阿飞,在视图中引用了name,渲染到页面。

今天要学习的,就是当我1s之后,通过vm.name修改成1s后变化才能程序员。页面要同步变化。

动图.gif

回顾:页面如何渲染真实Dom的

在之前的vue源码学习(8-10)中,实现了从ast到render字符串,然后从render字符串生成虚拟Dom,再由虚拟Dom生成真实的Dom。

而生成真实Dom,在lifecycle.js中有这样一段代码

export function mountComponent(vm, el) {
    // 数据变化后,会再次调用更新函数
    let updateComponent = () => {
        vm._update(vm._render())
    }
    updateComponent()
}

vm._update(vm._render())就是把虚拟Dom渲染到页面上的方法,也就是说,我们在1s之后,调用这个方法就可以了。

setTimeout(() => {
    // 注意:重新调用render方法,并没有diff算法
    vm.name = '1s后变成程序员'
    vm._update(vm._render())
}, 1000)

然而,我们并不应该在每次发生更新的时候,都让用户来调用vm._update(vm._render())。这件事情,需要在数据发生变化的时候,自动去执行。

在源码之前先谈两个概念

1. 观察者模式

当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知依赖它的对象。观察者模式属于行为型模式。

2. 依赖收集

Vue能够实现当一个数据变更时,视图就进行刷新,而且用到这个数据的其他地方也会同步变更;

而且,这个数据必须是在有被依赖的情况下,视图和其他用到数据的地方才会变更。

所以,Vue要能够知道一个数据是否被使用,实现这种机制的技术叫做依赖收集

image.png

解释一下这张图:

  1. Component生成虚拟Dom,每一个组件都有一个watcher,页面渲染时,会把属性记录为依赖。
  2. 当script中,操作属性是,触发setter,此时则通知watcher,触发组件的重新渲染。

那么问题来了:

  1. 在Vue的依赖收集中,谁是观察者?
  2. 谁是被观察者?
  3. 每个组件是如何做到观察数据变化的?

带着这几个问题,我继续深入的学习源码。

响应式源码实现

在vue中,实现了三个类。

  • Observer类:它负责将数组、对象转换成可观测的类。(这个在之前的# 探讨vue2.x的数据劫持是怎么实现的?一文中有详细学习)

  • Dep (dependent的缩写):它扮演被观察者,每一个数据都有一个dep,dep里面有一个subs队列,存放watcher,当这一个数据发生变化时,就是通过dep.notify()通知对应的watcher重新渲染

  • Watcher:它扮演的是观测者,每一个组件都有一个watcher,每一个watcher内部都有一个deps,保存着这个watcher观测的所有数据。

由此可见:可以看做deps和watcher是多对多的关系。deps中有多个watcher(一个属性会被多个组件使用),watcher中会有多个deps(一个组件使用多个数据,都需要监听)

Watcher类

// 一个组件对应一个watcher
let id = 0;
import { popTarget, pushTarget } from './dep';
class Watcher {
    constructor(vm, exprOrFn, cb, options) {
        this.vm = vm
        this.exprOrFn = exprOrFn
        this.cb = cb
        this.options = options
        this.id = id++ // 给watcher添加标识
        // 默认应该执行exprOrFn
        // exprOrFn 做了渲染和更新
        // 方法被调用的时候,会取值
        this.getter = exprOrFn
        this.deps = []
        this.depsId = new Set()
        // 默认初始化执行get
        this.get()
    }
    get() {
        pushTarget(this) // Dep的target就是一个watcher
        /* 创建关联
            * 每个属性都可以收集自己的watcher
           * 希望一个属性可以对应多个watcher
           * 一个watcher可以对应多个属性
        */
        // 稍后用户更新的时候可以重新调用get方法
        this.getter()
        popTarget() // 这里去除Dep.target,是防止用户在js中取值产生依赖收集
    }
    update() {
        this.get()
    }
    addDep(dep) {
        let id = dep.id
        if (!this.depsId.has(id)) {
            this.depsId.add(id)
            this.deps.push(dep)
            dep.addSub(this)
        }
    }

}

export default Watcher

简单的对上述的类的功能进行一下整理:

  • 每一个组件对应一个watcher,所以需要一个id来记录watcher的唯一性。每次new watcher实例的时候,生成一个新的id

  • popTarget, pushTarget:这两个方法是Dep类中暴露出来的方法,在下面Dep类内容中可以看到,他们的作用分别是:把Dep上挂载的target赋值成当前的watcher 以及 把Dep上挂载的target赋值为null。

get() {
    pushTarget(this)
    this.getter()
    popTarget()
}

javascript是一个单线程的语言,这里的做法是,当get方法被调用的时候,会执行pushTarget,然后渲染页面,然后在去除这个target上的watcher对象。

之所以要去除这个对象,是因为要防止用户在js中取值产生依赖收集。

再看这张图片

image.png

render的时候,只有getter才会产生依赖收集,不会在setter的时候收集。

  • addDep:这个方法被Dep类使用,每new一个Dep的时候,都要存放到对应的watcher中。depsId是一个set,用来去重,因为一个相同的变量在视图中使用多次,只需要做一个依赖收集
addDep(dep) {
    let id = dep.id
    if (!this.depsId.has(id)) {
        this.depsId.add(id)
        this.deps.push(dep)
        dep.addSub(this)
    }
}

Dep类

// 一个属性对应一个dep,做属性收集
let id = 0
class Dep {
    // 每一个属性都分配一个Dep,每一个Dep可以存放watcher,watch中要存放Dep
    constructor() {
        this.id = id++
        this.subs = [] // 用来存放watcher
    }
    depend() {
        // Dep.target dep里面要存放这个watcher watcher一样要存放dep
        if (Dep.target) {
            // 把dep给watcher,让watcher存放dep
            Dep.target.addDep(this)
        }
    }
    addSub(watcher) {
        this.subs.push(watcher)
    }
    notify() {
        this.subs.forEach(watcher => {
            watcher.update()
        })
    }
}
Dep.target = null
export function pushTarget(watcher) {
    Dep.target = watcher
}

export function popTarget() {
    Dep.target = null
}

export default Dep
  • id:和watcher类一样,id也是为了dep的唯一性。

  • depend:在Observer类中,get方法被触发的时候,会调用depend方法。这个方法,把dep实例给watcher,让watcher存放当前这个dep实例。

  • addSub:向subs队列存放watcher

  • notify:发送变更的消息。当这个变量发生变化的时候,subs队列中的每一个watcher都要接受到变更消息,重新渲染页面

  • Dep.target 作为一个静态变量,所有的dep实例公用,pushTarget和popTarget的作用在Watcher类中已经介绍了。

什么时候对watcher进行实例化?

在前文中说过,每一个组件都有一个watcher

所有,组件挂载的时候就会有一个new watcher的过程。

lifecyle.js中的代码做如下修改:

export function mountComponent(vm, el) {
    let updateComponent = () => {
        // 1. 通过render生成虚拟dom
        vm._update(vm._render()) // 后续更新可以调动updateComponent方法
        // 2. 虚拟Dom生成真实Dom
    }
    // 观察者模式:属性是被观察者 刷新页面:观察者
    // 如果属性发生变化,就调用updateComponent方法
    // 每一个组件都有一个watcher
    new Watcher(vm, updateComponent, () => {
        // true 告诉他是一个渲染过程
        // 后续还有其他的watcher
    }, true)
}

何时收集依赖,何时同时重新渲染页面?

在observer类中,我们对对象进行遍历,将每个属性用defineProperty重新定义,对数据进行了全量劫持

因此,每一个属性发生变化和被使用的时候,都会触发get和set方法。核心代码如下

function defineReactive(data, key, value) {
    // value 有可能是对象(对象套对象),递归劫持
    observe(value)
    let dep = new Dep()
    Object.defineProperty(data, key, {
        get() {
            console.log('key', key)
            // 取值时候我希望将watcher和Dep关联起来
            // 但是这里没有watcher
            if (Dep.target) {
                // 说明这个get是在模板中使用的
                // 让dep记住watcher,依赖收集,它是一个依赖收集器 
                dep.depend()
            }
            return value
        },
        set(newV) {
            if (newV !== value) {
                observe(newV)
                // 如果用户赋值的是一个新对象,需要将这个对象进行劫持
                value = newV
                dep.notify() // 通知当前的属性存放的watcher执行
            }

        }
    })
}

依赖收集:

// 当数据被页面渲染调用的时候,进行依赖收集
get() {
    if (Dep.target) {
        dep.depend()
    }
    return value
}

通知变化:

set(newV) {
    // 如果新的值和旧的值不一致的话,则重新渲染页面
    if (newV !== value) {
        ...
        dep.notify() // 通知当前的属性存放的watcher执行,重新渲染页面
    }

}

好了,今天的学习就到此结束了,很期待下一次的学习。

历史相关文章