实现一个自己的mvvm

942 阅读6分钟

相信大家对mvvm并不陌生吧,下面看下实现的代码,参考vue源码,整理出来的小demo。 想看整体代码 猛戳 github,如果要是觉得有对您有帮助 麻烦给个star。

<div id="app">
    <input type="text" v-model="message.a" />
    {{message.a}}
</div>
<script src="./mvvm/watcher.js"></script>
<script src="./mvvm/observer.js"></script>
<script src="./mvvm/compile.js"></script>
<script src="./mvvm/mvvm.js"></script>
<script>
    //将标签放到内存中去,然后 编译 => 提前想要的元素元素节点 v-model 和文本节点 {{}}
    let vm = new MVVM({
        el : "#app",
        data:{
            message:{
                a:'1212'
            }
        }
    })
</script>

几种实现双向绑定的做法

1.发布订阅模式

一般通过sub,pub的方法实现数据和视图的绑定监听,更新数据方法通常做法是 vm.set('property', value)。

2.脏检查

angular.js 就是通过脏值检测的方法对数据是否有变更,来决定更新视图,最简单的方式就是setInterval()定时轮询检测数据变动,当然Google不会这么low,angular只有在指定的事件触发时进入脏值检测,大致如下:

  • DOM事件,比如用户输入文本,点击按钮等。(ng-click)
  • XHR响应事件($http)
  • 浏览器Location变更事件($location)
  • Timer事件($timeout, $interval)
  • 执行$digest()$apply()
3.数据劫持

vue.js 采用的就是数据劫持结合发布订阅模式,通过Object.defineProperty() 来劫持各个属性的 setter, getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

思路整理

  1. mvvm 会初始化两个方法 Observer - 劫持所有属性,Compile - 解析指令
  2. Compile 生成视图的同时 会订阅数据变化 new Watcher。生成更新视图的回调, (这个回调什么时候调用呢?)new Watcher 会添加订阅者到 Dep数组中,方便修改数据的时候通知变化。
  3. Observer 如果劫持到变化会通知 DepDep会运行Dep数组里面所有的通知(new Watcher)。

alt

1. Observer

我们知道 可以利用 Obeject.defineProperty() 来监听 setter, getter。

class Observer{
    constructor(data){
        this.observer(data);
    }
    observer(data){
        //要对这个data数据将原有的属性改成set和get的形式 所以必须要数组
        if(!data || typeof data !== 'object'){
            return;
        }
        //要将数据 一一劫持 先获取到 data 到 key 和 value
        Object.keys(data).forEach(key => {
            //劫持
            this.defineReactive(data,key,data[key]);
            this.observer(data[key]); //深度递归劫持
        })
    }
    //定义响应式
    defineReactive(obj,key,value){
        let that = this;
        //每个变化的数据,都会对应一个数组,这个数组是存放所有更新的操作
        let dep = new Dep();
        // 在获取某个值到时候,
        Object.defineProperty(obj,key,{
            enumerable : true,
            configurable : true,
            get(){ //当取值时调用到方法
                Dep.target && dep.addSub(Dep.target); // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
                return value;
            },
            set(newValue){ //当给data属性中设置值到时候,更改获取的属性到值
                if(newValue != value){
                    //这里的this不是实例
                    that.observer(newValue);//如果是对象,继续劫持
                    value = newValue;
                    dep.notify(); //通知所有人数据更新了
                }
            }
        })
    }
}
class Dep{
    constructor(){
        //订阅的数组
        this.subs = [];
    }
    addSub(watcher){
        this.subs.push(watcher);
    }
    notify(){
        this.subs.forEach(watcher => watcher.update());
    }
}

要点总结

  1. 利用递归 深度监听(由于Object.defineProperty 无法深度监听)
  2. get() 的时候也就是谁需要展示的时候, 要把new watcher push到数组中去(订阅),方便修改值去通知所有的订阅者(发布)
  3. set()的时候,要通知所有的订阅者,你们要修改值到视图啦(发布)

2. Compile

compile 主要做的事情就是解析模版指令,将模版中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。

alt
因为操作中需要多次操作dom节点,为了提高效率及性能,先将文档转化为文档片段 fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中

//将真实的DOM移入到内存中 fragment
//同样定义一个类
class Compile{
    constructor(el,vm){
        //el可能是 #app or dom,所以要进行判断
        this.el = this.isElementNode(el)?el:document.querySelector(el); 
        this.vm = vm;
        if(this.el){
            //如果这个元素能获取到,我们才开始编译
            //1.先把这些真实的DOM移入到内存中 fragment
            //2、编译 =》 提前想要的元素元素节点 v-model 和文本节点 {{}}
            //3、把编译好的 fragment 在塞回到页面里去

            //1.先把这些真实的DOM移入到内存中 fragment
            let fragment  = this.node2fragment(this.el);
            //2、编译 =》 提前想要的元素元素节点 v-model 和文本节点 {{}}
            this.compile(fragment);
            //3、把编译的fragment在赛回到页面中去
            this.el.appendChild(fragment);
        }
    }
    /*专门写一些辅助方法*/
    //判断是否是元素节点
    isElementNode(node){
        return node.nodeType === 1;
    }
    isDirective(name){
        return name.includes('v-');
    }
    /*核心的方法*/

    //1、需要将el中的内容全部放到内存中
    node2fragment(el){
        //文档碎片 内存中的dom节点
        let fragment = document.createDocumentFragment();
        let firstChild;
        while(firstChild = el.firstChild){
            fragment.appendChild(firstChild);
        }
        return fragment; //内存中的节点
    }
    //2、编译 =》 提前想要的元素元素节点 v-model 和文本节点 {{}}
    compile(fragment){
        //需要递归
        let childNodes = fragment.childNodes;
        //
        Array.from(childNodes).forEach(node => {
            if(this.isElementNode(node)){
                //是元素节点,还需要深入的检查
                //这里需要编译元素
                this.compileElement(node);//编译 带 v-model 的元素
                this.compile(node);
            }else{
                //文本节点
                //这里需要编译文本
                this.compileText(node);
            }
        });
    }
    compileElement(node){
        //带v-model
        let attrs = node.attributes;//取出当前节点的属性
        Array.from(attrs).forEach(attr => {
            //判断属性名字是不是包含v-
            let attrName = attr.name;
            if(this.isDirective(attrName)){
                //取到对应的值放到节点中
                let expr = attr.value;
                //解构负值,将v-model中的model截取处理
                let [,type] = attrName.split('-');
                //node this.vm.$data expr v-model v-text v-html
                //todo ...
                CompileUtil[type](node,this.vm,expr);
            }
        })
    }
    compileText(node){
        //带{{}}
        let expr = node.textContent;//取文本中的内容
        let reg = /\{\{([^}]+)\}\}/g; //{{a}}、{{b}}、{{c}}
        if(reg.test(expr)){
            // node this.vm.$data text
            //todo ...
            CompileUtil['text'](node,this.vm,expr);
        }
    }
}

CompileUtil = {
    //获取示例上对应的示例
    getVal(vm,expr){ 
        expr = expr.split('.');
        return expr.reduce((prev,next) => {
            return prev[next];
        },vm.$data);
    },
    getTextVal(vm,expr){
        return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            return this.getVal(vm,arguments[1]);
        });
    },
    text(node,vm,expr){ //文本处理
        let updateFn = this.updater['textUpdater'];
        //{{message.a}} => 'hello,123获取编译文本后的结果
        let value = this.getTextVal(vm,expr);
        expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{ // arguments ["{{message.a}}", "message.a", 9, "↵        {{message.a}}↵    "]
            new Watcher(vm,arguments[1],(newValue)=>{
                //如果数据变化了,文本节点需要重新依赖的属性更新文本中的内容
                updateFn && updateFn(node,this.getTextVal(vm,expr));
            })
            return arguments[1];
        });
        updateFn && updateFn(node,value);
    },
    setVal(vm,expr,value){ //[message,a]
        expr = expr.split('.');
        //收敛
        return expr.reduce((prev,next,currentIndex)=>{
            if(currentIndex === expr.length-1){
                return prev[next] = value;
            }
            return prev[next];
        },vm.$data)
    },
    model(node,vm,expr){ //输入框处理
        let updateFn = this.updater['modelUpdater'];
        //这里应该加一个监控,数据变化了 应该调用这个watch的callback
        new Watcher(vm,expr,(newValue)=>{
            //当值变化后会调用 cb,将新的值传递过去 ()
            updateFn && updateFn(node,this.getVal(vm,expr));
        });
        
        node.addEventListener('input',(e)=>{
            let newValue = e.target.value;
            this.setVal(vm,expr,newValue)
        })

        updateFn && updateFn(node,this.getVal(vm,expr));

    },
    updater:{
        //文本更新
        textUpdater(node,value){
            node.textContent = value;
        },
        //输入框更新
        modelUpdater(node,value){
            node.value = value;
        }
    }
};

总结

先放入代码片段里面,在用 compile方法遍历元素节点,解析文本节点, 而且在遍历节点的同时,会 new watcher 添加回调来接受数据变化的通知。

3. Watcher

Watcher订阅者作为Observer和Compile之间通信的桥梁,先看代码

// 观察者的目的就是给需要变化的那个元素增加一个观察这,
//当数据变化后,执行对应的方法
//目的:用新值和老值进行比对,如果发生变化,就调用更新方法
class Watcher{
    constructor(vm,expr,cb){
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        //先获取一下老的值
        this.value = this.get();
    }
    //获取示例上对应的示例
    getVal(vm,expr){
        expr = expr.split('.')
        return expr.reduce((prev,next) => {
            return prev[next];
        },vm.$data);
    }
    get(){
       Dep.target = this;
       let value =  this.getVal(this.vm,this.expr);
       Dep.target = null;
       return value;
    }
    //对外暴露的方法
    update(){
         let newValue = this.getVal(this.vm,this.expr);
         let oldValue = this.value;
         if(newValue != oldValue){
             this.cb(newValue); //调用watch的callback
         }
    }
}

总结

  1. 在自身实例化的时候, 往Dep 里面 push 自身
  2. 自身有个 update() 方法 以供调用 更新视图回调
  3. 在 dep.notice() 通知的时候,能调用自身的update()方法,并且触发Compile中绑定的回调。

4. mvvm

//因为 MVVM 可以 new,所以 MVVM 肯定是一个类
//用 es6写法定义
class MVVM{
    //在类里面接受参数,例如,el,和data
    constructor(options){
        //首先,先把可用的东西挂载在实例上
        this.$el = options.el;
        this.$data = options.data;
        //然后,判断如果有要编译的模版再进行编译
        if(this.$el){
            //数据劫持,就是把对想的所有属性 改成 get 和 set 方法
            new Observer(this.$data);
            this.proxyData(this.$data);
            //用 元素 和 数据 进行编译
            new Compile(this.$el,this);
        }
    }
    proxyData(data){
        Object.keys(data).forEach(key=>{
            Object.defineProperty(this,key,{
                get(){
                    return data[key]
                },
                set(newValue){
                    data[key] = newValue;
                }
            })
        })
    }
}

主要是还是用Object.defineProperty 方法来劫持数据,这边使用代理,实现 this.xxx 代替 this.data.xxx 的效果。

总结

本文主要是参考 vue源码 ,来写的一个mvvm 小demo, 相信文中肯定有一些不严谨的思考和错误, 希望大家指出来,和大家共同进步。