Vue变化侦测原理

深入浅出 - Vue变化侦测原理

如何侦测变化?
  • 关于变化侦测首先要问一个问题,在js中是如何侦测一个对象的变化的.其实这个问题还算比较简单的,js中有两种方法可以侦测到变化,Object.defineProperty和ES6中的proxy.
  • 到目前为止vue使用的还是Object.defineProperty,那么我们可以写出这样的代码
    function defineReaction (data, key, val) {
        Object.defineProperty (data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                return val
            },
            set: function (newVal) {
                if(val === newVal) {
                    return
                }
                val = newVal
            }
        })
    }

Object.defineProperty 的使用请看 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

  • 写一个函数封装一下Object.defineProperty,毕竟Object.defineProperty的用法复杂,封装一个我们只需要传递data,key,val的就好了
  • 现在封装好了之后每当datakey读取数据get这个函数可以被触发,设置数据的时候set函数可以被触发.但是执行起来没什么用~~~
怎么观察变化!
  • 思考一下,我们之所以要观察一个数据,目的是为了当数据的属性发生变化时,可以通知那些使用了这个key的地方
    举例说明:
    <template>
        <div>{{key}}</div>
        <p>{{key}}</p>
    </template>
  • 模板中有两处使用了key,所以当数据发生变化时,要把这两处都通知到.
  • 所以上面的问题,先收集依赖,吧这些使用到key的地方先收集起来,然后等属性发生变化时,把收集好的依赖循环触发一遍
  • getter收集依赖,在setter中,触发依赖
在那收集依赖
  • 首先想到的是每个key都有一个数组,用来存储当前key的依赖,假设依赖是一个函数存在window.target上,先把defineReactive改造一下
    function defineReaction (data, key, val) {
        let dep = [] 
        Object.defineProperty (data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                dep.push(window.target) 
                return val
            },
            set: function (newVal) {
                if(val === newVal) {
                    return
                }

                for (let i = 0; i < dep.length; i++) {
                    dep[i](newVal, val)
                }
                val = newVal
            }
        })
    }
  • 新增数组dep,用来存储被收集的依赖,然后触发set时,循环dep把收集到的依赖触发
  • 上述代码耦合较高,封装一下
    export default class Dep {
        static target: ?Watcher,
        id: number,
        subs: Array<Watcher>,

        constructor () {
            this.id = uid++
            this.subs = []
        },
        addSub (sub: Watcher) {
            this.subs.push(sub)
        },
        removeSub (sub: Watcher) {
            remove(this.subs, sub)
        },
        depend () {
            if (Dep.target) {
                this.addSub(Dep.target)
            }
        },
        notify () {
        const subs = this.subs.slice()
            for (let i = 0, l = subs.length; i < l; i++) {
                subs[i].update()
            }
        },
    }
    function defineReaction (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
                }

                dep.notify()
                val = newVal
            }
        })
    }
收集谁?
  • 收集谁,换句话说就是当属性发生变化后,通知谁.上面我们假装window.target是需要我们收集的依赖,但我们已经改成了Dep.target.
  • 我们要通知那个使用到数据的地方,而使用这个数据的地方有很多,而且类型不一样,有可能是模板,有可能是用户写得一个watch,所以这个时候我们需要抽象出一个能集中处理这些不同情况的类.然后我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个,然后它在负责通知其他地方!!!
watcher
  • watcher是一个中介的角色,数据变化通知给watcher,然后watcher在通知给其他地方
    watcher使用方法:
    vm.$watch('a.b.c', function (newVal, oldVal) {
        // ...
    })

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

  • 我们只要把这个watcher实例添加到Dep中去就行了,然后数据变化会通知到watcher,然后执行参数中的这个回调函数
    class Watch {
        constructor (expOrFn, cb) {
            this.getter = parsePath(expOrFn) 
            this.cb = cb
            this.value = this.get()
        }
        
        get () {
            Dep.target = this
            value = this.getter.call(vm, vm)
            Dep.target = undefined
        }  

        update () {
            const oldValue = this.value
            this.value = this.get()
            this.cb.call(this.vm, this.value, oldValue)
        }
    }
  • 这段代码可以吧自己主动push到变化的Dep中去.因为我在get这个方法中,先把Dep.target设置成了this,也就是当前watcher实例,然后在读一下变化的值,因为有读取,肯定会触发getter.触发了getter上面封装的defineReactive函数中有一段逻辑就会从Dep.target里读取一个依赖pushDep
  • 依赖注入到Dep之后,当这个变化的值有所变化时,就把所有的依赖循环触发update方法,update方法会触发参数中的回调函数,监valueoldValue传到参数中
  • 其实不管是用户执行的vm.$watch('a.b.c', (value, oldValue) => {})还是模板中用到的data,都是通过watcher来通知自己是否需要发生变化
递归侦测所有的key
  • 现在其实已经可以实现变化侦测的功能了,但是我们之前写的代码只能侦测数据中的一个key,所以我们要加工一下defineReactive这个函数
    function walk (obj: Object) {
        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) {
        walk(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
                }
                dep.notify()
                val = newVal
            },
        })
    }
  • 这样我们就可以通过执行walk(data),把data中的所有key都加工成可以被侦测的,因为是一个递归的过程,所以key中的value如果是一个对象,那这个对象的所有key也会被侦测
Array怎么进行侦测
  • 现在考虑的是data中所有的value都是对象和基本类型,但如果是一个数组怎么办?数组是没有办法通过Object.defineProperty来侦测到行为的
  • 在vue中对这个数组问题的解决方案非常简单粗暴,大体分三步
    • 第一步:先把原生的Array的原型方法继承下来
    • 第二步:对继承后的对象使用Object.defineProperty做一些拦截操作
    • 第三步:把加工后可以被拦截的原型,赋值到需要被拦截的Array类型的数据的原型上
      vue的实现
    const arrayProto = Array.prototype
    export const arrayMethods = Object.create(arrayProto)
    [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
    ]
    .forEach(function (method) {
        const original = arrayProto[method]

        Object.defineProperty(arrayMethods, method, {
            value: function mutator (...args) {
                console.log(method) // 打印数组方法
                return original.apply(this, args)
            },
                enumerable: false,
                writable: true,
                configurable: true
        })
    })
  • 现在可以看到,每当被侦测的array执行方法操作数组时,我都可以知道他执行的方法是什么,并且打印到console
  • 现在我要对这个数组方法类型进行判断,如果操作数组的方法是push,unshift,splice,需要把新增的元素用上面封装的walk来进行变化侦测,并且不论操作数组的是什么方法,我都要触发消息,通知依赖列表中依赖数据发生了什么变化
    // 工具函数
function def (obj: Object, key: string, val: any, enumerable?: boolean) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        writable: true,
        configurable: true
    })
}

export class Observer {
    value: any
    dep: Dep
    vmCount: number

    constructor (value: any) {
        this.value = value
        this.dep = new Dep()
        this.vmCount = 0
        def(value, '__ob__', this) 

        if (Array.isArray(value)) {
            this.observeArray(value)
        } else {
            this.walk(value)
        }
    }

    walk (obj: Object) {
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
        defineReactive(obj, keys[i], obj[keys[i]])
        }
    }


    observeArray (items: Array<any>) {
        for (let i = 0, l = items.length; i < l; i++) {
        new Observer(items[i])
        }
    }
}
  • 我们定义了一个Observer类,他的职责是将data转换成可以被注册到变化的data,并且新增了对类型的判断,如果是value的类型是Array,循环Array将每个元素丢到Observer
  • 并且在value上做了一个标记__ob__,这样我们就可以通过value__ob__拿到Observer实例,然后使用__ob__上的dep.notify()就可以发送通知
[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
]
.forEach(function (method) {
    const original = arrayProto[method]
    def(arrayMethods, method, function mutator (...args) {
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        switch (method) {
        case 'push':
        case 'unshift':
            inserted = args
            break
        case 'splice':
            inserted = args.slice(2)
            break
        }
        if (inserted) ob.observeArray(inserted)
        // notify change
        ob.dep.notify()
        return result
    })
})
  • 可以看到谢了一个switchmethod进行判断,如果是push,unshift,splice这种可以新增数组元素的方法就使用 ob.observeArray(inserted) 把新增的元素也丢到Observer中去转换成可以被侦测到变化的数据。
  • 在最后不论操作数组的方法是什么,都会调用ob.dep.notify()去通知watcher数据发生了改变.
arrayMethods 是怎么生效的
  • 现在我们有一个arrayMethods是被加工后的Array.prototype,那么怎么让这个对象应用到Array呢?
  • 我们不能直接修改Array.prototype因为这样会污染全局的Array,我们希望arrayMethods只对data中的Array生效,所以我们只需要把arrayMethods赋值给 value 的__proto__上就好了

我们改造一下 Observer:

export class Observer {
    constructor (value: any) {
        this.value = value
        this.dep = new Dep()
        this.vmCount = 0
        def(value, '__ob__', this)

        if (Array.isArray(value)) {
        value.__proto__ = arrayMethods
        this.observeArray(value)
        } else {
        this.walk(value)
        }
    }
}
// 不使用__proto__
const hasProto = '__proto__' in {} 
export class Observer {
    constructor (value: any) {
        this.value = value
        this.dep = new Dep()
        this.vmCount = 0
        def(value, '__ob__', this)

        if (Array.isArray(value)) {
        // 修改
        const augment = hasProto
            ? protoAugment
            : copyAugment
        augment(value, arrayMethods, arrayKeys)
        this.observeArray(value)
        } else {
        this.walk(value)
        }
    }
}

function protoAugment (target, src: Object, keys: any) {
    target.__proto__ = src
}

function copyAugment (target: Object, src: Object, keys: Array<string>) {
    for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key])
    }
}
关于Array的问题
  • 关于vue对Array的拦截,正因为这种实现方法,其实有些数组操作vue是拦截不到的
    this.list[0] = 2
  • 修改数组第一个元素的值,是无法侦测到数组的变化的,所以并不会触发re-render或者watch
  • this.list.length = 0清空数组的操作,也是无法侦测到的
  • 因为vue的实现方法就决定了无法对上面的例子做拦截,也就没有办法做到响应,ES6是有能力做到的,在ES6之前是无法做到模拟数组的原生行为的,现在ES6的Proxy可以模拟数组的原生行为,也可以通过ES6的继承来继承数组原生行为,从而进行拦截.
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值