MVVM原理及实现VUE

32次阅读

共计 8932 个字符,预计需要花费 23 分钟才能阅读完成。

MVVM 框架

在讲 MVVM 框架的时候,就绕不开 MVC 框架

MVC 框架

将整个前端页面分成 View,Controller,Modal,视图上发生变化,通过 Controller(控件)将响应传入到 Model(数据源),由数据源改变 View 上面的数据。

但是由于 MVC 框架允许 view 和 model 直接俄通信,所以随着业务量的扩大,可能会出现很难处理的依赖关系,完全背离了开发所应该遵循的“开放封闭原则”。

MVVM 详解

面对这个问题,MVVM 框架就出现了,它与 MVC 框架的主要区别有两点:
1、实现数据与视图的分离
2、通过数据来驱动视图,开发者只需要关心数据变化,DOM 操作被封装了。

数据就是简单的 javascript 对象, 需要将数据绑定到模板上。监听视图的变化, 视图变化后通知数据更新, 数据更新会再次导致视图的变化!

VUE 双向绑定原理

Vue 的双向绑定主要通过 compile(编译模板)、数据劫持、发布者 - 订阅者模式模式来实现的。初始化数据时通过 Object.defineProperty 来劫持各个属性的 getter 和 setter。在编译模板时,把依赖数据的元素创建观察者,在 get 属性的时候,将 watcher 实例放入订阅列表中,在 set 数据的时候,notify 所有的订阅者,触发订阅者的 update 方法来更新数据。达到数据变化 —> 视图更新;视图交互变化(input)—> 数据 model 变更双向绑定效果。

Object.defineProperty

Object.defineProperty 可以又两种方式在对象上直接定义一个属性,或者修改一个已经存在的属性的值。

方法 1:属性描述符

let obj = {};
    Object.defineProperty(obj, 'test', {
        // 可配置
        configurable: true,
        // 可写
        writable: true,
        value: 'liuliu',
        // 是否可遍历
        enumerable: true
    })
    

运行结果:

可以通过 definePorperty 方法给对象添加一个 test 属性,此属性 configurable 时,此属性可删除;writeable 时,可以重新给此属性赋值。enumable 时,此时行可以被 Object.keys()等方法遍历。

方法 2: 存取描述符

let obj = {};
let val = null;
Object.defineProperty(obj, 'test', {get() {console.log('get value')
        return val;
    },  
    set(value) {console.log('set value')
        val = value;
    }
})


由一对 getter、setter 函数功能来描述的属性。

注意:两种方法不可同时使用

vue 数据劫持

index.html

<body>
</body>
<script src="./mvvm.js"></script>
<script>
    let vue =  new Vue({
        el: '#app',
        data: {a: {b: 1},
            c: 2
        }
    })

</script>

mvvm.js

function Vue(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    observe(data);
}

// 观察对象 给对象添加 defineProperty
function observer(data) {for (let key in data) {let val = data[key];
        // 如果 val 不是基本数据类型,则需要继续劫持
        if (val != null && typeof val === "object") {observer(val);
        }
        Object.defineProperty(data, key, {
            enumerable: true,
            get() {console.log('get data')
                // 返回 data[key]的值。return val;
            },
            set(newval) {console.log('set data');
                // 如果数据发生变化
                if (newval === val) {return;}
                // 将新值赋予 val
                val = newval;
                // 新值如果不是基本数据类型,则需要继续
                if (val != null && typeof val === "object") {observer(val);
                }
            }
        })
    }
}

测试结果

在初始化数据的时候,我们通过 observe 函数来观察初始数据,设置数据的 getter 和 setter 来劫持数据。我们在获取 a 的值的时候,会执行其 get 方法,打印数据并返回 a 的值,在给属性赋值的时候执行 set 方法,如此这样我们就可以在 get 和 set 数据的时候做一些其它的操作。

数据代理

mvvm.js

function Vue(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    observe(data);

    // this 代理 this._data
    Object.keys(data).forEach(key =>{
        Object.defineProperty(this, key, {
            enumerable: true,
            get() {return this._data[key];
            },
            set(value) {this._data[key] = value;
            }
        })
    })
}

运行结果


Vue 在访问 data 数据的时候是使用 this.a 的方式而不是 this._data.a,所以遍历 data 的属性,将其通过 defineProperty 的方法代理到 Vue 上面。在获取 vue.a 值的时候,get 中返回 vue._data.a 的值,这时候会触发 observe 中对 a 属性 get 的劫持,返回 this._data.a 的值。在给 vue.a 进行赋值时,由于 get 的是 vue._data.a 的数值,则需要将新值 set 给 vue._data.a。
通过数据代理,我们将 vue._data 中的数据代理到 vue 中。

编译模板

index.html

<body>
   <div id="app">
    <p>b 的值为:{{a.b}}</p>
    <p>c 的值为:{{c}}</p>
   </div>
</body>
<script src="./mvvm.js"></script>
<script>
    let vue =  new Vue({
        el: '#app',
        data: {a: {b: 1},
            c: 2
        }
    })

</script>

mvvm.js

function Vue(options) {
    ...
    // 和上文保持一致

    // 编译模板
    new Compile(options.el, this);
}

function Compile(el, vm) {
    // 获取 vue 实例的根元素
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 将 dom 节点移动到内存中
    while(child = vm.$el.firstChild) {fragment.appendChild(child);
    }
    function replace(fragment) {
        // fragement 是一个类似数组结构
        Array.from(fragment.childNodes).forEach(node =>{
            // 获取节点的文本内容
            let content = node.textContent;
            // {{}}的正则
            let reg = /\{\{(.*)\}\}/;
            
            // 如果 node 是文本节点且有需要编译的{{}}
            if(node.nodeType === 3 && reg.test(content)) {
                debugger
                // 获取匹配正则表达式中的第一个匹配(a.b) 并将其分割成字符数组[a,b]
                let arr = RegExp.$1.split('.');
                var val = vm;
                // 获取 vue.a.b 的值
                arr.forEach(key =>{
                    // 会劫持 vue._data 中的 get 方法来获取返回的数据
                    val = val[key];
                })
                // 把获取的数据替换掉模板
                node.textContent = content.replace(/\{\{(.*)\}\}/, val);
            }
            // 如果当前结点还有子节点 则递归编译
            if(node.childNodes) {replace(node);
            }
        })
    }
    // 编译 vue 实例的根节点
    replace(fragment);
    // 将在内存中的节点重新入到 dom 中
    vm.$el.appendChild(fragment);
}

运行结果:


劫持并代理数据之后,我们开始编译文档中存在的模板。获取根节点 app 的文档元素, 并将其移入到内存中,递归循环判断节点文本中的{{}},并将其替换为 data 中对应的数据。最后将替换完成的文档片段插入到 dom 中,从而完成编译。

观察者

mvvm.js

function Compile(el, vm) {
    // 获取 vue 实例的根元素
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 将 dom 节点移动到内存中
    while(child = vm.$el.firstChild) {fragment.appendChild(child);
    }
    function replace(fragment) {
        // fragement 是一个类似数组结构
        Array.from(fragment.childNodes).forEach(node =>{
            // 获取节点的文本内容
            let content = node.textContent;
            // {{}}的正则
            let reg = /\{\{(.*)\}\}/;
            
            // 如果 node 是文本节点且有需要编译的{{}}
            if(node.nodeType === 3 && reg.test(content)) {
                debugger
                // 获取匹配正则表达式中的第一个匹配(a.b) 并将其分割成字符数组[a,b]
                let arr = RegExp.$1.split('.');
                var val = vm;
                // 获取 vue.a.b 的值
                arr.forEach(key =>{
                    // 会劫持 vue._data 中的 get 方法来获取返回的数据
                    val = val[key];
                })
                // 添加 watcher 当 data 中依赖的数据改变时,通过 watcher 的 update 方法更新到页面中去
                new Watcher(vm, RegExp.$1, function(newVal){node.textContent = content.replace(/\{\{(.*)\}\}/, newVal);
                })
                // 把获取的数据替换掉模板
                node.textContent = content.replace(/\{\{(.*)\}\}/, val);
            }
            // 如果当前结点还有子节点 则递归编译
            if(node.childNodes) {replace(node);
            }
        })
    }
    // 编译 vue 实例的根节点
    replace(fragment);
    // 将在内存中的节点重新入到 dom 中
    vm.$el.appendChild(fragment);
}

// 观察者
function Watcher(vm, exp, fn) {
    // 当前对象
    this.vm = vm;
    // 正则
    this.exp = exp;
    // 回掉函数
    this.fn = fn;
    // 获取引用对应的值
    let arr = this.exp.split('.');
    let val = vm;
    arr.forEach(key => {val = val[key];
    })
}
Watcher.prototype.update = function() {
    // 获取 data 中的值
    let val = this.vm;
    let arr = this.exp.split('.');
    arr.forEach(key =>{val = val[key];
    })
    // 执行 watcher 的 update 方法,使更新的值渲染到文档中
    this.fn(val);
}

观察者需要在依赖的数据该生改变时,执行 wathcer 的 update 方法,将新的数据渲染到文档中去。所以我们要给需要编译替换的元素添加 Watcher 实例,并将当前元素的 data 和表达式传入。更新值的时候可以根据这两个参数获取到最新的数据。

发布订阅

function Sub()

// 发布订阅
function Dep() {
    // subs 中存储订阅实例
    this.subs = [];}
// 添加订阅,给依赖当前数据的实例的 watcher 添加到订阅列表中
Dep.prototype.addSub = function(sub) {this.subs.push(sub);
}
// 发布信息函数
Dep.prototype.notify = function() {
    // 依次执行订阅者的 update 方法使得订阅者也更新
    this.subs.forEach(item => {item.update();
    })
}

function Watcher()

// 观察者
function Watcher(vm, exp, fn) {
    // 当前对象
    this.vm = vm;
    // 正则
    this.exp = exp;
    // 回掉函数
    this.fn = fn;
    // 将当前实例绑定到 Dep 构造的属性上
    Dep.target = this;
    // 获取引用对应的值
    let arr = this.exp.split('.');
    let val = vm;
    arr.forEach(key => {
        // 获取 data 数据的时候 因为 target 不为空,所以会将当前实例放入 val 的订阅列表中
        val = val[key];
    })
    // 循环完依赖 将 target 置空,以免将当前实例添加在非依赖的订阅列表中
    Dep.target = null;
}

function observe()

// 观察对象 给对象添加 defineProperty
function observe(data) {for(let key in data) {let val = data[key];
        let dep = new Dep();
        // 如果 data 的属性值还是对象,则递归做劫持
        if (data !== null &&typeof val=== 'object') {observe(val);
        }
        // 使用 defineProperty 的方式定义属性
        Object.defineProperty(data, key, {
            enumerable: true,
            get() {
                // 当 target 不为空时,则当前 data 为 target 的依赖,所以将其添加到订阅列表中
                if (Dep.target) {dep.addSub(Dep.target);
                }
                return val;
            },
            set(newVal) {
                // 如果旧值不等于新值 则赋值
                if(val === newVal) {return ;}
                // 在 get 数据的时候可以将新值返回
                val = newVal;
                // 如果新值为一个对象,则需要继续对属性做劫持
                if(val !== null && typeof val === 'object') {observe(val);
                }
                // 数据发生变化时,通知订阅者更新
                dep.notify();}
        })
    }

}

运行结果:

当获取编译模板时,生成 Watcher 实例,在观察者的构造方法中循环获取依赖数据的 value,此时 observe 会 get 劫持,将当前观察实例放入订阅列表中。若模板依赖的数据发生改变,observe 劫持 set,将新值复制给当前属性,并通知 subs 中所有的依赖,使其执行 update 方法来进行更新。

双向绑定

index.html

<body>
   <div id="app">
    <p>b 的值为:{{a.b}}</p>
    <p>c 的值为:{{c}}</p>
    <input type="text" v-model="a.b">
   </div>
</body>

function Compile()

function Compile(el, vm) {
    // 获取 vue 实例的根元素
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 将 dom 节点移动到内存中
    while(child = vm.$el.firstChild) {fragment.appendChild(child);
    }
    function replace(fragment) {
        // fragement 是一个类似数组结构
        Array.from(fragment.childNodes).forEach(node =>{
            // 获取节点的文本内容
            let content = node.textContent;
            // {{}}的正则
            let reg = /\{\{(.*)\}\}/;
            
            // 如果 node 是文本节点且有需要编译的{{}}
            if(node.nodeType === 3 && reg.test(content)) {// 获取匹配正则表达式中的第一个匹配(a.b) 并将其分割成字符数组[a,b]
                let arr = RegExp.$1.split('.');
                var val = vm;
                // 获取 vue.a.b 的值
                arr.forEach(key =>{
                    // 会劫持 vue._data 中的 get 方法来获取返回的数据
                    val = val[key];
                })
                // 添加 watcher 当 data 中依赖的数据改变时,通过 watcher 的 update 方法更新到页面中去
                new Watcher(vm, RegExp.$1, function(newVal){node.textContent = content.replace(/\{\{(.*)\}\}/, newVal);
                })
                // 把获取的数据替换掉模板
                node.textContent = content.replace(/\{\{(.*)\}\}/, val);
            }

            // 如果是元素节点
            if(node.nodeType === 1) {
                // 获取元素的所有属性
                let attrs = node.attributes;
                Array.from(attrs).forEach(attr =>{
                      //attr = 'v-model="b" '
                      let name = attr.name; //name = v-model
                      let exp = attr.value  //exp = b;
                    // 如果属性以 v - 开头
                    if (name.indexOf('v-') === 0) {
                        let val = vm;
                        exp.split('.').forEach(key =>{val = val[key];
                        })
                        node.value = val;
                        // 订阅数据更新事件
                        new Watcher(vm, exp, function (newVal) {node.value = newVal;})
                        // 输入框变化时,将值赋予到 vm 上
                        node.addEventListener('input', function (e) {
                            let newVal = e.target.value; // 获取新值
                            // 触发 observe 的 set
                            expr = exp.split('.');
                            expr.reduce((prev, next, index) => {if(index === expr.length -1) {prev[next] = newVal;
                                } else {return prev[next];
                                }
                            }, vm)
                        })
                    }
                })
            }
            // 如果当前结点还有子节点 则递归编译
            if(node.childNodes) {replace(node);
            }
        })
    }
    // 编译 vue 实例的根节点
    replace(fragment);
    // 将在内存中的节点重新入到 dom 中
    vm.$el.appendChild(fragment);
}

运行结果:

监听输入框的 input 事件,将新值复制给 data,set 会通知订阅者进行更新。直接改变 data 数据,劫持数据 set 的时候会通知依赖 update,从而实现了数据的双向绑定。

计算属性

index.html

<body>
   <div id="app">
    <p>b 的值为:{{a.b}}</p>
    <p>c 的值为:{{c}}</p>
    <input type="text" v-model="a.b">
    {{hello}}
   </div>
</body>
<script src="./mvvm.js"></script>
<script>
    let vue =  new Vue({
        el: '#app',
        data: {a: {b: 1},
            c: 'sdsd'
        },
        computed: {hello() {return this.a.b + this.c;}
        }
    })

</script>

mvvm.js

function Vue(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    // 观察数据 data
    observe(data);

    // this 代理 this._data
    Object.keys(data).forEach(key =>{
        Object.defineProperty(this, key, {
            enumerable: true,
            get() {return this._data[key];
            },
            set(value) {this._data[key] = value;
            }
        })
    })
    initComputed.call(this);
    // 编译模板
    new Compile(options.el, this);
}

// 初始化计算属性
function initComputed() {
    debugger
    let vm = this;
    let computed = this.$options.computed;
    for(k in computed) {
        Object.defineProperty(vm, k, {
            enumerable: true,
            //  判断计算属性是个函数还是一个对象 hello() {} or hello: {get(){}, set() {}}
            get: typeof computed[k] === 'function' ? computed[k] : computed[k].get,
            set() {} 
        })
  

运行结果:

盼盼计算属性是一个函数还是有 get 和 set 的对象。执行其方法,获取 data 的数据并返回。由于 data 中的数据都是在缓存中的,所有 computed 属性具有缓存依赖性。

正文完
 0