美文网首页让前端飞Web前端之路
设计一个简单mvvm例子

设计一个简单mvvm例子

作者: 子皙丶 | 来源:发表于2020-03-05 00:11 被阅读0次

    1. 引言

    学习vue有段时间了,mvvm在vue中是个典型应用,最近参考了参考网上一些资料,整理了一下,也加入了自己的理解,实现一个简单版的demo,也方便有些面试的同学遇到设计一个mvvm的面试题。

    2. 逻辑结构

    mvvm的设计模式是“发布与订阅者”模式(observe/watcher),主要步骤有三步:

    • observe来劫持并监听所有的属性(也就是vue中的data)
    • 给每一个需要监听的属性,绑定一个订阅者(watcher)
    • 当observe监听到属性变化时,通知watcher去更新视图

    ok,步骤讲完了,接下来就开始实现每一步

    3. observe

    observe的主要功能:

    • 劫持和监听数据
    • 当数据更新时,触发通知(后面的dep会讲,这里跳过)
      那么observe劫持和监听数据的呢?用Object.defineProperty来实现

    先看一个例子:

    function observe (obj) {
       var keys = Object.keys(obj)
       keys.forEach(function(key){
         var val = void 0;
         Object.defineProperty(obj, key, {
            enumerable : true, 
            configurable : true,
            get : function () {
               console.log('这个属性是', key)
               return val;
             },
            set : function ( newValue ) {
               val = newValue
               console.log('属性' + key + '已经被监听了,此时的值是:' + newValue)
             }
         })
       })
    }
    var book = {page : 300}
    observe(book)
    var b = book.page   // 后台会打印 这个属性是page
    book.page = 400    // 后台会打印  属性page已经被监听,此时的值是400
    

    在这里我们就实现了属性的监听,上述例子中,我们用Object.defineProperty重写了set和get函数,使得属性的值变化时可以被我们监听到(如果不了解Object.defineProperty的,可以查阅Object.defineProperty

    ok,原理我们清楚了,接下来就开始写observe了

    //data对象,key和val分别是data的键值对
    function defineReactive(data, key, val){
        observe(val)  //递归调用data中的子对象 
        Object.defineProperty(data, key, {
            enumerable : true,  //可枚举,可在for in 和 Object.keys中得到
            configurable : true,
            get : function () {
                return val;
            },
            set : function ( newValue ) {
                if(val === newValue){
                    return;
                }
                val = newValue
                console.log('属性' + key + '已经被监听了,此时的值是:' + newValue)
            }
        })
    }
    //观察者,用来监听数据
    function observe (obj) {
        if(!obj || typeof obj !== 'object'){
            return;
        }
        Object.keys(obj).forEach(function(key){
            defineReactive(obj, key, obj[key])
        })
    }
    
    • defineReactive函数的三个参数,分别是要注册的对象,对象的key,以及对象的值
    • defineReactive中调用observe,目的是递归调用所有的属性

    3. watcher

    observe写完了,接下来我们就要看watcher了,因为每个属性都绑定一个watcher,所以可能会有很多的watcher,因此我们需要一个调度中心(暂时定义为Dep),来统一指挥watcher

    Dep的主要功能:

    • 将每个watcher都push进去
    • 当接收到observe的属性更新通知时,通知对应的watcher来更新视图

    接下来上代码

    //订阅器,用来收集订阅者,并且通知订阅者更新函数
    function Dep(){
        this.subs = []
    }
    Dep.prototype = {
        addSub : function (sub){
            this.subs.push(sub)
        },
        notify : function (){
            this.subs.forEach(function(sub){
                sub.update()
            })
        }
    }
    

    Dep已经定义好了,接下来我们需要改一下observe,将dep加进去,这样我们就实现了在get函数中将属性注册一个watcher再push进dep中,并且set函数中数据更新时通dep,dep会再通知watcher去更新视图

    function defineReactive(data, key, val){
        observe(val)  //递归调用data中的子对象 
        var dep = new Dep();
        Object.defineProperty(data, key, {
            enumerable : true,  //可枚举,可在for in 和 Object.keys中得到
            configurable : true,
            get : function () {
                // 这里目的是定义一个flag,用来判断什么时候需要push一个sub
                //因为不能每次调用属性都push一个sub,只有在第一次时才需要push
                if(Dep.target){   
                    dep.addSub(Dep.target)
                }
                return val;
            },
            set : function ( newValue ) {
                if(val === newValue){
                    return;
                }
                val = newValue
                console.log('属性' + key + '已经被监听了,此时的值是:' + newValue)
                dep.notify()
            }
        })
    }
    

    到这里Dep调度中心就完成了,接下来我们实现watcher

    watcher的主要功能:

    • observe中get函数只是定义了一个watcher,但是触发这个get函数需要在这里,这样就完成了注册
    • 接到dep的更新通知后,调用更新函数

    ok,先实现代码:

    function Watcher (vm, exp, cb) {
        this.vm = vm
        this.exp = exp
        this.cb = cb
        this.value = this.get()
    }
    Watcher.prototype = {
        update : function(){
            this.run()
        },
        run : function (){
            var value = this.vm.data[this.exp]
            var oldVal = this.value
            if (value !== oldVal) {
                this.value = value
                this.cb.call(this.vm, value, oldVal)
            }
        },
        get:function(){
            Dep.target = this //这个暂时不知道干嘛
            var value = this.vm.data[this.exp]
            Dep.target = null 
            return value
        }
    }
    
    • Watcher的三个参数,分别是vue,要订阅的属性,以及回调函数(触发更新时调用的函数)
    • this.value = this.get()这行代码就是初始化就去获取这个属性值,这样就会调用observe中的get函数,然后将watcher加入到Dep中去。
    • Dep.target = this 就是上文中提到的只有target有值时才会将watcher加入到Dep中

    ok,到这里最简易版本的mvvm已经完成了
    然后我们定义一个vue:

    // data 是所有的属性,el是绑定的元素节点(#app),exp是绑定的属性
    function dVue (data, el, exp) {
        this.data = data;
        observe(data);
        el.innerHTML = this.data[exp];  // 初始化模板数据的值
        new Watcher(this, exp, function (value) {
            el.innerHTML = value;
        });
        return this;
    }
    

    在html中调用

    <body>
        <h1 id="app">{{math}}</h1>
    </body>
    <script src="./js/observer.js"></script>
    <script src="./js/watcher.js"></script>
    <script src="./js/index.js"></script>
    <script type="text/javascript">
        var ele = document.querySelector('#app');
        var dVue = new dVue({
            math : '1'
        }, ele, 'math');
     
        setInterval(function () {
            dVue.data.math = Math.random() * 100
        }, 1000);
     
    </script>
    

    OK,接下来我们完善一下,实现vue中的{{ }}绑定


    4. compile

    compile主要功能:

    • 获取模板,并且解析模板,将数据替换模板,完成初始化视图
    • 给模板中绑定的属性,new初始化一个watcher(之前是在dVue函数中完成的,现在移到这里)
     function Compile(el, vm){
        this.vm = vm
        this.el = document.querySelector(el)
        this.fragment = null
        this.init()
     }
     Compile.prototype = {
        init : function(){
            if(this.el){
                this.fragment = this.nodeToFragment(this.el)
                this.compileElement(this.fragment)
                this.el.appendChild(this.fragment)
            }else{
                console.log('节点不存在')
            }
        },
        nodeToFragment : function(el){
            //创建一个虚拟的文档片段,用来操作dom节点,因为这个片段是存在于内存中
            //所以相对于直接操作dom,性能会更好一点
            var fragment = document.createDocumentFragment()
            var child = el.firstChild
            while(child){
                fragment.appendChild(child)
                child = el.firstChild
            }
    
            return fragment
        },
        compileElement :function(el){
            var childNodes = el.childNodes
            var self = this;
            Array.prototype.slice.call(childNodes).forEach(function(node){
                var reg = /\{\{\s*(.*?)\s*\}\}/
                var text = node.textContent;
                //判断该节点是否含有{{ }}这个指令
                if(self.isTextNode(node) && reg.test(text)){
                    self.compileText(node, reg.exec(text)[1])
                }
                if(node.childNodes && node.childNodes.length){
                    self.compileElement(node)
                }
            })
        },
        compileText :function(node, exp){
            var self = this
            var initText = this.vm[exp]
            this.updateText(node, initText)  //初始化视图
            new Watcher(this.vm, exp, function(value){
                self.updateText(node, value)
            })
        },
        updateText : function(node, value){
            node.textContent = typeof value == 'undefined' ? '' : value
        },
        isTextNode : function(node){
            return node.nodeType == 3
        }
     }
    
    • nodeToFragment是在内存建立一个虚拟的节点,然后将模板赋值给它,再继续操作模板,这样可以提升性能,参考文档nodeToFragment
    • compileElement这个函数,解析模板,找到{{ }}指令的文本节点,然后运行核心函数compileText,解析文本节点
    • compileText这个函数中,做了两件事,第一件事是初始化视图,也就是调用updateText函数,第二件事就是给这个文本节点绑定一个watcher,用于订阅该属性,当属性值改变时,会调用里面的回调函数

    到这里compile就完成了,这样我们需要把dVue重新修改一下

    function dVue (options) {
        var self = this
        this.data = options.data
        this.vm = this
    
        Object.keys(this.data).forEach((key)=>{
            this.proxyKeys(key)
        })
        //重写所有的data属性的set和get方法,用于劫持监听数据
        observe(this.data)
        //编译模板,得到绑定的节点,初始化视图,并且给该节点所绑定的属性注册一个watcher
        new Compile(options.el, this)
        return this
    }
    //代理一下属性,这样的话 dVue.name = dVue.data.name ,不用每次都带着data了
    //相当于把data的所有属性都注册到了dVue上
    dVue.prototype = {
        proxyKeys : function(key){
            var self = this
            Object.defineProperty(this, key, {
                enumerable : false,
                configurable : true,
                get : function(){
                    return self.data[key]
                },
                set :function(val){
                    self.data[key] = val
                }
            })
        }
    }
    

    到这里就基本结束了,我们在html中调用一下

    <html>
    <head>
        <title>实现一个简单的mvvm</title>
    </head>
    <body>
    <div id = "app">
        <div>{{name}}</div>
        <div>{{age}}</div>
        <div>{{like}}</div>
    </div>
    
    <script type="text/javascript" src="./observe.js"></script>
    <script type="text/javascript" src="./watcher.js"></script>
    <script type="text/javascript" src="./compile.js"></script>
    <script type="text/javascript" src="./dVue.js"></script>
    <script>
        let dVueInit = new dVue({
            el : '#app',
            data:{
                name : 'ding',
                age : 14,
                like : '读书'
            }
        })
    </script>
    </body>
    </html>
    

    到此结束!

    参考文章:
    https://www.cnblogs.com/libin-1/p/6893712.html
    https://github.com/canfoo/self-vue/tree/master/v2

    相关文章

      网友评论

        本文标题:设计一个简单mvvm例子

        本文链接:https://www.haomeiwen.com/subject/ppwilhtx.html