美文网首页
vue源码之数据响应式原理

vue源码之数据响应式原理

作者: zx_lau | 来源:发表于2019-06-20 13:31 被阅读0次

    vue 简介

    渐进式框架:就是把框架分层。

    最核心的是视图层渲染,然后往外是组件机制,在这个基础上加入路由机制,再加入状态管理,以及最外层的构建工具。

    所谓分层:就是说既可以用最核心的视图层渲染来开发一些需求,也可以用vue全家桶来开发大型应用。可以更具自己的需求来选择不同的层级。

    数据监听(Object)

    有两种方法可以侦测到变化:使用Object.definePropertyES6Proxy

        function defineReactive(data, key ,val) {
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get: function() {
                    return val
                },
                set: function(newVal) {
                    if(val === newVal) {
                        return;
                    }
                    val = newVal
                }
            })
        }
    

    这里的函数defineReactive 用来对Object.defineProperty 进行封装。从函数的名字可以看出,其作用是定义一个响应式数据。也就是在这个函数中进行变化追踪,封装后只需要传递datakeyval 就行了。

    封装好之后,每当从datakey 中读取数据时,get 函数被触发;每当往datakey 中设置数据时,set 函数被触发。

    如何收集依赖

    如果只是把Object.defineProperty 进行封装,那其实并没什么实际用处,真正有用的是收集依赖。

    思考一下,我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。

        <template>
            <h1>{{ name }}</h1>
        </template>
    

    该模板中使用了数据name,所以当它发生变化时,要向使用了它的地方发送通知。

    注意:在Vue.js 2.0 中,模板使用数据等同于组件使用数据,所以当数据发生变化时,会将通知发送到组件,然后组件内部再通过虚拟DOM重新渲染。

    对于上面的问题,先收集依赖,即把用到数据name 的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。

    总结起来,其实就一句话,在getter 中收集依赖,在setter 中触发依赖。

    依赖收集在哪里

    思考一下,首先想到的是每个key 都有一个数组,用来存储当前key 的依赖。假设依赖是一个函数,保存在window.target 上,现在就可以把defineReactive 函数稍微改造一下:

        function defineReactive(data, key, val) {
            let dep = [];
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get: function () {
                    dep.push(window.target) // 新增
                    return val
                },
                set(newVal) {
                    if(val === newVal) {
                        return;
                    }
                    // 新增
                    for (let i = 0; i < dep.length; i++) {
                        dep[i](newVal, val)
                    }
                    val = newVal
                }
            })
        }
    

    这里我们新增了数组dep,用来存储被收集的依赖。

    然后在set 被触发时,循环dep 以触发收集到的依赖。

    但是这样写有点耦合,我们把依赖收集的代码封装成一个Dep 类,它专门帮助我们管理依赖。使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。其代码如下:

        export default class Dep {
            constructor() {
                this.subs = []
            }
            addSub (sub) {
                this.subs.push(sub)
            }
            removeSub (sub) {
                remove(this.subs, sub)
            }
            depend () {
                if (window.target) {
                    this.addSub(window.target)
                }
            }
            notify() {
                const subs = this.subs.slice();
                for(let i = 0, l = subs.length; i < l; i++) {
                    subs[i].update()
                }
            }
        }
        
        function remove (arr, item) {
            if (arr.length) {
                const index = arr.indexOf(item)
                if (index > -1) {
                    return arr.splice(index, 1)
                }
            }
        }
    

    之后再改造下defineReactive:

        function defineReactive (data, key, val) {
            let dep = new Dep() // 修改
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get: function () {
                    dep.depend() // 修改
                    return val
                },
                set: function (newVal) {
                    if(val === newVal){
                        return
                    }
                    val = newVal
                    dep.notify() // 新增
                }
            })
        }
    

    依赖是谁

    在上面的代码中,我们收集的依赖是window.target,那么它到底是什么?我们究竟要收集谁呢?

    收集谁,换句话说,就是当属性发生变化后,通知谁。

    我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。所以,我们要抽象的这个东西需要先起一个好听的名字。嗯,就叫它 Watcher 吧。

    现在就可以回答上面的问题了,收集谁?Watcher

    什么是Watcher

    Watcher 是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

    关于Watcher,先看一个经典的使用方式:

        // keypath
        vm.$watch('a.b.c', function (newVal, oldVal) {
        // 做点什么
        })
    

    这段代码表示当data.a.b.c 属性发生变化时,触发第二个参数中的函数。

    思考一下,怎么实现这个功能呢?好像只要把这个watcher 实例添加到data.a.b.c 属性的Dep 中就行了。然后,当data.a.b.c 的值发生变化时,通知Watcher。接着,Watcher 再执行参数中的这个回调函数。

    export default class Watcher {
        constructor (vm, expOrFn, cb) {
            this.vm = vm
            // 执行this.getter(),就可以读取data.a.b.c 的内容
            this.getter = parsePath(expOrFn)
            this.cb = cb
            this.value = this.get()
        }
        get() {
            window.target = this
            let value = this.getter.call(this.vm, this.vm)
            window.target = undefined
            return value
        }
        update () {
            const oldValue = this.value
            this.value = this.get()
            this.cb.call(this.vm, this.value, oldValue)
        } 
    }
    

    这段代码可以把自己主动添加到data.a.b.cDep 中去,是不是很神奇?

    因为我在 get 方法中先把 window.target 设置成了this,也就是当前watcher 实例,然后再读一下data.a.b.c 的值,这肯定会触发getter

    触发了getter,就会触发收集依赖的逻辑。而关于收集依赖,上面已经介绍了,会从window.target 中读取一个依赖并添加到Dep 中。

    这就导致,只要先在window.target 赋一个this,然后再读一下值,去触发getter,就可以把this 主动添加到keypathDep 中。有没有很神奇的感觉啊?

    依赖注入到Dep 中后,每当data.a.b.c 的值发生变化时,就会让依赖列表中所有的依赖循环触发update 方法,也就是Watcher 中的update 方法。而update 方法会执行参数中的回调函数,将valueoldValue 传到参数中。

    所以,其实不管是用户执行的vm.$watch('a.b.c', (value, oldValue) => {}),还是模板中用到的data,都是通过Watcher 来通知自己是否需要发生变化。

    这里有些小伙伴可能会好奇上面代码中的parsePath 是怎么读取一个字符串的keypath 的,下面用一段代码来介绍其实现原理:

    /**
    * 解析简单路径
    */
    const bailRE = /[^w.$]/
    export function parsePath (path) {
        if (bailRE.test(path)) {
            return
        }
        const segments = path.split('.')
        return function (obj) {
            for (let i = 0; i < segments.length; i++) {
                if (!obj) return
                    obj = obj[segments[i]]
                }
                return obj
            }
       }
    

    可以看到,这其实并不复杂。先将keypath 用 . 分割成数组,然后循环数组一层一层去读数据,最后拿到的obj 就是keypath 中想要读的数据。

    递归侦测所有key

    现在,其实已经可以实现变化侦测的功能了,但是前面介绍的代码只能侦测数据中的某一个属性,我们希望把数据中的所有属性(包括子属性)都侦测到,所以要封装一个Observer 类。这个类的作用是将一个数据内的所有属性(包括子属性)都转换成getter/setter 的形式,然后去追踪它们的变化:

        /**
    * Observer 类会附加到每一个被侦测的object 上。
    * 一旦被附加上,Observer 会将object 的所有属性转换为getter/setter 的形式
    * 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
    */
    export class Observer {
        constructor (value) {
            this.value = value
            if (!Array.isArray(value)) {
                this.walk(value)
            }
        }
    /**
    * walk 会将每一个属性都转换成getter/setter 的形式来侦测变化
    * 这个方法只有在数据类型为Object 时被调用
    */
        walk (obj) {
            const keys = Object.keys(obj)
            for (let i = 0; i < keys.length; i++) {
                defineReactive(obj, keys[i], obj[keys[i]])
            }
        }
    }
    function defineReactive (data, key, val) {
        // 新增,递归子属性
        if (typeof val === 'object') {
            new Observer(val)
        }
        let dep = new Dep()
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                dep.depend()
                return val
            },
            set: function (newVal) {
                if(val === newVal){
                    return
                }
                val = newVal
                dep.notify()
            }
        })
    }
    

    在上面的代码中,我们定义了Observer 类,它用来将一个正常的object 转换成被侦测的object

    然后判断数据的类型,只有Object 类型的数据才会调用walk 将每一个属性转换成getter/setter 的形式来侦测变化。

    最后,在defineReactive 中新增new Observer(val)来递归子属性,这样我们就可以把data 中的所有属性(包括子属性)都转换成getter/setter 的形式来侦测变化。

    data 中的属性发生变化时,与这个属性对应的依赖就会接收到通知。

    也就是说,只要我们将一个object 传到Observer 中,那么这个object 就会变成响应式的object

    关于Object的问题

    有些语法即便数据发生了变化,vue.js也监测不到,比如向Object添加和删除属性。

    es6 proxy方式监听数据响应的方式

        let obj = {
            a: 1,
            b: 2,
            c: 3
        }
        
        let reactive = new Proxy(obj, {
            get: function(target, key, receiver) {
                console.log(`getting ${key}`);
                return Reflect.get(target, key, receiver)
            }
            set: function(target, key, receiver) {
                console.log(`setting ${key}`);
                return Reflect.set(target, key, receiver)
            }
        })
        
        
        reactive.a      // getting a  // 1
        reactive.a = 4  // setting a
        reactive.a      // getting a  // 4
    

    总结

    变化侦测就是侦测数据的变化,当数据发生变化时,要能侦测并发送出通知。

    Object可以通过Object.defineProperty将属性转换成getter/setter的形式来追踪变化。读取数据会触发getter,修改数据会触发setter。

    在getter中手机有哪些依赖使用了数据。当setter被触发时,通知getter中收集到的依赖数据发生了变化

    收集依赖存储的地方是创建了一个Dep,它们用来收集依赖、删除依赖和向依赖发送消息等。

    依赖就是watcher,只有watcher触发的getter才会收集依赖,哪个watcher触发了getter,就把哪个watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的watcher都通知一遍。

    watcher的原理是先把自己设置到全局唯一的指定位置(例如window.target),然后读取数据。因为读取了数据,所以会触发这个数据的getter。接着在getter中就会从全局唯一的window.target读取当前正在读取数据的watcher,并收集这个watcher到Dep中。

    此外,创建一个Observe类,作用是把一个Object中所有数据都转换成响应式的。

    Data、Observe、Dep和Watcher之间的关系:Data通过Observe转换成getter/setter的形式来追踪变化。当外界通过watcher读取数据时,会触发getter从而将watcher添加到依赖中。当数据发生了变化时, 会触发setter,从而向Dep中的依赖(watcher)发送通知。watcher接收到通知后,会向外界发送通知,变化通知到外界后可能触发视图更新,也有可能触发用户的某个回调函数等。

    相关文章

      网友评论

          本文标题:vue源码之数据响应式原理

          本文链接:https://www.haomeiwen.com/subject/opbdqctx.html