YIngChenIt/Blogs

【源码】手写mvvm 之 实现数据双向绑定

Opened this issue · 0 comments

【源码】手写mvvm 之 实现数据双向绑定

前言

在上一篇 【源码】手写mvvm 之 Compiler类 中我们大致知道了Vue中是如何对模板进行编译的,那么现在我们要在上一篇的基础上让我们的代码具有双向数据绑定的功能,也就是完善我们的MVVM.

数据劫持 - Observer类

作为Vue的使用者,我们或多或少都知道Vue中是通过Object.defineProperty来进行数据劫持的,如果不清楚的同学可以参考我的另外一篇文章【深入了解Vue响应式特点】,上面有对Vue数据劫持很详细的解答

那我们现在就来完善下代码,让它具有数据劫持的能力吧

class Vue {
    constructor(options) {
        this.$el = options.el
        this.$data = options.data

        if (this.$el) { 
            new Observer(this.$data) // 让 $data里面的元素具有数据劫持的能力
            new Compiler(this.$el, this)
        }
    }
}

我们将重点放在Observer类上

class Observer { // 实现数据劫持的功能
    constructor(data) {
        this.observer(data)
    }
    observer(data) {
        if (data && typeof data === 'object') {
            for(let key in data) {
                this.defineReactive(data, key, data[key])
            }
        }
    }
    defineReactive(obj, key, value) {
        this.observer(value)
        Object.defineProperty(obj, key, {
            get() {
                return value
            },
            set:(newValue) => {
                if (newValue != value) {
                    this.observer(newValue)
                    value = newValue
                }
            }
        })
    }
}

这里就不对代码进行详细的解读了(这里只针对对象),我们可以总结一点就是Observer类给我们$data中的某一些特定的数据添加数据劫持的能力, 也就是我们的getter 和 setter方法

观察者 - Watcher类

我们首先要知道 Watcher类的作用是什么?

Watcher类其实很简单,当我们被观察者发生变化的时候,观察者也会做出对应的变化。

class Watcher { // 观察者 数据一变化 调用回调更新视图
    constructor(vm, expr, cb) {
        this.vm = vm
        this.expr = expr
        this.cb = cb
        this.oldValue = this.get() // 存放一个老值
    }
    get() {
        let value = CompilerUnit.getVal(this.vm, this.expr)
        return value
    }
    updata() {
        let newValue = CompilerUnit.getVal(this.vm, this.expr)
        if (newValue != this.oldValue) {
            this.cb(newValue)
        }
    }
}

从代码我们可以看到, Watcher内部会根据vm 和表达式如man.name等获取到当前表达式对应的$data的值,然后缓存起来,当调用updata()方法的时候,如果新值和缓存起来的老值相等,则执行对应的回调函数来更新视图

被观察者 - Dep类

有了观察者我们也需要有触发观察者update方法的被观察者

class Dep { // 被观察者 自己的数据变化 通知所有观察者
    constructor() {
        this.subs = [] // 用来存放观察者的数组
    }
    addSub(watcher) { // 订阅
        this.subs.push(watcher)
    }
    notify() { // 发布
        this.subs.forEach(watcher => watcher.updata())
    }
}

典型的发布订阅模式,这里也不详细解析代码了,接下来我们将重心放到Vue是如何将视图和数据关联在一起实现数据双向绑定的

将视图和数据关联

回到最初的起点

class Vue {
    constructor(options) {
        this.$el = options.el
        this.$data = options.data

        if (this.$el) { 
            new Observer(this.$data) 
            new Compiler(this.$el, this)
        }
    }
}

new Vue()的时候,首先会new Observer()$data中的数据具有数据劫持的能力,我们可以在这一步为每个数据添加被观察者

    defineReactive(obj, key, value) {
        this.observer(value)
        let dep = new Dep() //新 给每个数据添加被观察者
        Object.defineProperty(obj, key, {
            get() {
                Dep.target && dep.addSub(Dep.target)  //新
                return value
            },
            set:(newValue) => {
                if (newValue != value) {
                    this.observer(newValue)
                    value = newValue
                    dep.notify() //新
                }
            }
        })
    }

因为我们是循环遍历的$data, 也就是说每个数据中都有属于自己的被观察者dep,

当调用get方法的时候,对应着我们的代码

    <input type="text" v-model="man.name">
    <div>{{ man.name }}</div>
    <div>{{ man.age }}</div>

我们会往自身的dep.subs中添加当前的watcher

当自身数据发生变化(也就是调用)set方法的时候,会触发当前自己dep.subs中全部观察者watcher的更新方法来更新视图.

我们来看下当前的观察者Dep.target是如何实现的

class Watcher { // 观察者 数据一变化 调用回调更新视图
    ...
    get() {
        Dep.target = this // 将当前的watcher缓存在 Dep.target
        let value = CompilerUnit.getVal(this.vm, this.expr)
        Dep.target = null
        return value
    }
    ...
}

也就是我们每一次 new Watcher()的时候会将Dep.target赋值为当前的实例

那么我们在什么时候添加观察者呢?

    <input type="text" v-model="man.name">
    <div>{{ man.name }}</div>
    <div>{{ man.age }}</div>

从代码中我们可以知道,当我们v-model="xxx" 或者 { { xxx } }的时候需要添加观察者吧, 也就是

CompilerUnit = {
    ...
    model(node, expr, vm) {
        const fn = this.updata['modelUpdata']
        new Watcher(vm, expr, (newValue) => {
            fn(node, newValue)
        })
        let value = this.getVal(vm, expr)
        fn(node, value)
    },
    getContentValue(vm, expr) {
        return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(vm, args[1].trim())
        })
    },
    text(node, expr, vm) {
        const fn = this.updata['textUpdata']
        let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            new Watcher(vm, args[1].trim(), () => {
                fn(node, this.getContentValue(vm, expr))
            })
            return this.getVal(vm, args[1].trim()) 
        })
        fn(node, content)
    },
    ...
}

可能看到这里还是有点蒙,我们来从头理一下思路

  • 首先我们通过new Observer()让每个数据拥有自己的dep,并且每次get的时候往dep.subs中添加观察者watcher,当数据发生变化的时候循环遍历调用每个观察者watcherupdate更新视图

  • 然后我们通过new Compiler()对模板进行编译,发现模板中的Vue语法如v-xxx="xxx" 或者 { { xxx } },然后需要这些语法添加观察者,因为他们依赖的数据xxx发生变化的时候他们的视图也需要更新,所以我们在对应节点的处理方法里面通过new Watcher()添加观察者

  • 编译模板过程中由于我们是循环编译编译的每个节点,为他们添加观察者,这个时候我们就往Dep.target上缓存当前生成的watcher,然后获取$data的值,也就是调用第一步的get方法,就完成了往当前模板节点依赖的数据的dep.subs中添加观察者watcher(当前的Dep.target)这一步步骤,然后将Dep.target赋值为空让下一个节点使用

通过这个逻辑我们就实现了将数据和视图关联在一起了,数据发生变化会通知所有使用到这个数据的观察者,然后更新视图

但是我们视图改变的时候并不会更新数据,那我们来完善下代码吧

CompilerUnit = {
    ...
    setVal(vm, expr, val) {
        expr.split('.').reduce((data, current, index, array) =>{
            if(index === array.length - 1) { // 如果是遍历到最后一项 也就是取到对应的值 重新赋值
                data[current] = val
            }
            return data[current]
        }, vm.$data)
    },
    model(node, expr, vm) {
        const fn = this.updata['modelUpdata']
        new Watcher(vm, expr, (newValue) => {
            fn(node, newValue)
        })
        node.addEventListener('input', (e) => { // 新
            const value = e.target.value
            this.setVal(vm, expr, value)
        })
        let value = this.getVal(vm, expr)
        fn(node, value)
    },
    ...
}

这样就可以实现视图更改数据了

总结

Vue中的MVVM是Vue的核心,如果这一块没有弄明白的话后序的源码如computedwatch这些阅读起来会很麻烦,往往会停留在源码的最后一步不知道视图和数据是如何关联在一起引起变化的

源码

点击查看源码