Vue原理剖析(一)双向绑定

200 阅读5分钟

众所周知,vue对数据的监听主要是通过Object.defineProperty来对对象进行一层封装,vue3中主要是通过proxy。这个点面试的时候也经常会被问起,但很多前端同学回答vue是如何进行双向绑定这个问题时,很多时候都是一句用Object.defineProperty带过的。实际上这样的回答是很掉分的。下面我们仔细探讨vue2中是如何对数据监听的。

Object.defineProperty

let data = {key: 1}
Object.defineProperty(data, 'key', {
    get: function (val) {
        alert('get')
    },
    set: function (newVal) {
        alert('set')
    }
})
data.key
data.key = 1

Object.defineProperty的作用主要是侦测对象中属性的变化。拷贝上诉代码并在浏览器中输入,我们可以看到运行到data.key时浏览器弹出了get字符串,运行到data.key = 1时浏览器弹出set字符串。由此可以看出Object.defineProperty可以对对象的属性的读和写进行监听。利用该特性,我们可以在getter中收集依赖,在setter中出发依赖

依赖

什么是依赖?这里的依赖具体是指用到该属性的地方,也有可能是开发者自己写的watcher,每个需要调用该属性的地方都会调用get方法。例如

<p>{{text}}</p>
...
data () {
    return {
        text: 1
    }
}

在当前模板中,p标签要显示text的文字必然会调用一次text中的值,也就是触发了一次get方法。这里就是一个用到该属性的一个地方,收集起当前一个依赖,记住这个地方用到了该属性。放到一个数组里边管理。

依赖管理

项目中的属性变量是非常多的,每次都用数组储存依赖后果是不堪设想的。这里我们引入一个Dep类。

class Dep {
    target = null // target 是用于
    constructor () {
        this.subs = []
    }

    addSub (sub) {
        // ..
    }

    removeSub(sub) {
        // ..
    }

    depend () {
        //Dep.target 作用只有需要的才会收集依赖
        if (Dep.target) {
           Dep.target.addDep(this)        } 
    }

    notify () {
        const subs = this.subs.slice()
        subs.forEach(sub => sub.update()) // 通知每个依赖点更新事件
    }
}

注意上述代码中target的作用,双向绑定是通过getter来收集依赖的,但并非是每次触发getter就会收集依赖,当且只有Dep.target不为空,也就是表明当前操作是为增加订阅者,订阅者便是Dep.target,也就是所谓的依赖(用到该变量的一个地方),综上所述,收集依赖的过程有以下三步

  • 将自己放在Dep.target上

  • 对自己依赖的key进行取值

  • 将自己从Dep.target移除

有了Dep类,这样管理属性起来就明了很多了,在每一次对一个对象的属性监听时,我们都新建一个dep对象来管理当前属性。vue双向绑定时代码如下:

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 () {
            if (val === newVal) {
                return 
            }
            val = newVal
            dep.notify()
        }
    })
}

这样后续我们只需要针对Dep对象来进行对监听属性的管理了。

Observer

有了watcher,我们就实现了数据变化侦测的功能了,为了把数据中所有的属性都侦测到,我们来封装一个Observer类。

class Observer {
    constructor (value) {
        this.value = value
        if(!Array.isArray(value)) {
            this.walk(value)
        }
    }

    walk(obj) {
        const keys = Object.keys(obj)
        keys.forEach(key => {
            defineReactive(obj, key, obj[key])
        })
    }
}

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 () {
            if (val === newVal) {
                return 
            }
            val = newVal
            dep.notify()
        }
    })
}

上述代码就是vue2实现双向绑定的雏形。从这里我们也能看出有两个缺陷的。上述代码中我们可以看到这种方式只能对对象的已有属性做监听,对对象新增和删除属性vue2中是监听不到的(有经验的vue2开发者应该都经历过这一个问题,必须先初始化一个值)。还有上述雏形并不能对数组进行监听,对数组的监听单纯通过Object.defineProperty并不能实现,下面我们剖析下vue2中是如何对数组属性进行监听的。

数组监听

我们先看看Array中提供的几个可以改变自身机构的原生方法:

push pop shift unshift splice sort reverse

上述方法都能够对数组的结构进行改变,我们看下为什么上述的逻辑不适合对数组进行监听。

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

let obj = {
    arr: [1, 2, 3]
}
defineReactive(obj, 'arr', obj.arr)
obj.arr.push(4)

复制上述代码到浏览器中执行,我们发现浏览器并没有弹出任何东西,但我们打印obj.arr的值却发现数组的值已经改变了,如果我们执行obj.arr = 1时,我们又发现浏览器又弹出set字符串,所以我们可以得出一个结论就是,Ojbect.defineProperty对数组进行绑定时,push等方法并不能出发我们的监听事件。

当使用push等方法改变数组自身结构时,我们要如何触发事件监听呢。代码是死的,人是活的。下面我们引入拦截器来对数组的监听进行增强。

const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach((method) => {
    const original = arrayProto[method]
    Object.defineProperty(arrayMethods, method, {
        value: function mutator(...args) {
            notify() // 注入想要的监听函数
            return original.apply(this, args)
        },
        enumerable: false,
        writable: true,
        configurable: true
    })
})

class Observer {
    constructor (value) {
        this.value = value
        if (Array.isArray(value)) {
            value.__proto__ = arrayMethods
        } else {
            this.walk(value)
        }
    }
}

上述是拦截器的创建过程,为了不污染全局的Array对象,我们新创建了一个空对象(也就是拦截器),在空对象中引入了Array中['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']等方法,同时在各个函数中添加了自己想要的通知事件,最后再把目标数组的原型指针指向拦截器。这样调用以上函数时便可以触发监听事件。

添加了上述的拦截器后,我们得到了通知数组变更的通知能力,但如何去通知呢。在前面的知识点中我们提到了对象属性是通过getter收集起来依赖的,而getter的触发所绑定的对象的key值,也就是说无论是否为数组,都是通过getter来收集依赖的。二者管理依赖的方式有所差异,这里也不再深究了。有兴趣的同学可以自己去搜一下。