简单理解以vue为例的mvvm框架原理

时下三大框架当道,应该在国内主要是react和vue,互相借鉴,各有特点,相较之下本人可能更喜欢vue,因为确实更加简洁,尤其喜欢双向数据绑定和计算属性等语法。所以想手动实现一下其中基本原理,便于理解。
本文主要借鉴sf上的一篇文章,原文写的非常清晰,可以看看

本文同时发布在csdn博客上,代码也放在了github上。在正式敲代码之前先来做些准备

Object.defineProperty()

首先来看vue其中的一个核心语法Object.defineProperty(),在双向数据绑定和计算属性等都有用到

var obj = {
    firstname: 'a',
    lastname: 'b'
};
//定义一个新属性
Object.defineProperty(obj, 'fullname', {
    //属性描述符
    //数据描述符
    // configurable: false,     //该属性描述符能否更改,属性能否修改,默认为false
    //enumerable: false,       //可否枚举(列举,遍历),默认为false
    // value: 'b-c',        //该属性值,默认为undefined
    // writable: false,      //value能否被改变,默认为false
    //访问描述符
    get: function(){        //读取属性值
        return this.firstname + '-' + this.lastname;
    },
    set: function(value){       //监视属性值的变化
        this.firstname = value.split('-')[0];
        this.lastname = value.split('-')[1];
    }
})
 
obj.firstname = 'b';
obj.fullname = 'x-y';
//输出 x y x-y
console.log(obj.firstname, obj.lastname, obj.fullname);
//输出 ['firstname', 'lastname'],没有'fullname',因为enumerable默认为false
console.log(Object.keys(obj));

描述符同时存在的情况

20180620233524460.png

参考文章

根据字符串获取对象中的值

简单理解,当前有对象 data = {name: 'sam', age: [18, 22], obj: {a: 1, b: 2}} ,如果要得到name属性值,可以用data['name'],但data里面嵌套的obj对象的属性值却不能使用data['obj.a']得到,所以需要封装函数来实现

字符串中含有 '.'
function getValue(keyStr, data){
   var val = data;
   var keys = keyStr.split('.');
   keys.forEach(function(key){
       val = val[key];
   });
   return val;
}

以上方法只适用字符串中只用到点语法的,像data['age[0]']则无法获取,那么

使用eval()或new Function()
//eval()
console.log(eval('data.age[0]'))    //18
console.log(eval('data.obj.b'))    //2

//new Function()
function getValue (data, key) { 
    return new Function('x', 'return x.' + key)(data) 
} 
console.log(getValue(data, 'age[0]'))    //18
console.log(getValue(data, 'obj.b'))    //2

注意:eval()总是不被推荐使用,原因自行了解

正式代码开始,先来看一张流程图

132184689-57b310ea1804f_articlex.png

先来实现一个mvvm构造函数

function MVVM(options){
    this._options = options;
    this._data = options.data || {};    //配置选项中的data
    var vm = this;
    //数据代理,将data对象中的属性添加到vm实例上
    Object.keys(this._data).forEach(function(key){
        vm._proxy(vm._data, key);
    });
    //如果配置选项中有methods则将methods里的函数添加到vm实例上
    if(typeof options.methods === 'object'){
        Object.keys(options.methods).forEach(function(key){
            vm._proxy(options.methods, key);
        })
    }
    //数据劫持,观察者监视数据变化
    observe(this._data);
    //编译模板,指令与双大括号等
    new Compile(options.el ? options.el : document.body, this);
}
 
//代理数据
MVVM.prototype._proxy = function(obj, key){
    Object.defineProperty(this, key, {
        configurable: false,
        enumerable: true,
        get: function(){
            return obj[key];
        },
        set: function(value){
            obj[key] = value;
        }
    });
}

该mvvm函数原型上只写了一个 _proxy函数,主要作用是将配置选项中的data数据添加到vm实例上,便于操作。

数据劫持,观察者监视数据变化

observer.js文件

function observe(data){
    if(!data || typeof data !== 'object'){      //如果没有数据或者数据不是对象则不用递归
        return;
    }
    Object.keys(data).forEach(function(key){
        hijackData(data, key, data[key]);
    })
}
 
//数据劫持
function hijackData(data, key, val){    
    var dep = new Dep();    //管理订阅者
    observe(data[key]);     //递归观察嵌套对象
    Object.defineProperty(data, key, {
        configurable: false,
        enumerable: true,
        get: function(){
            Dep.target && dep.addSub(Dep.target);//如果Dep.target有值则证明当前是订阅者在取值,这时添加订阅者            
            return val;
        },
        set: function(value){
            val = value;        //这里value不能直接赋值给data[key],否则会报错
            dep.notify();       //当值改变时通知订阅者
        }
    })
}
 
//依赖(管理订阅者)
function Dep() {
    this.subs = [];
}
Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};

上面代码主要实现,将vm实例上的 _data对象属性重新定义,注意内部嵌套对象(像 data: {obj: {a:1}})的属性也要重新定义,然后在定义属性的get()中添加订阅者,在set()通知订阅者。这里要注意的是,要添加一个标识(这里用到Dep.target)来判断当前是订阅者在取值才添加订阅者

订阅者

watcher.js文件

function Watcher(vm, exp, cb){
    this.$vm = vm;      //vm实例
    this.exp = exp;     //取值表达式
    this.cb = cb;       //回调
    this.value = this.getValue();
}
 
Watcher.prototype.getValue = function(){
    Dep.target = this;      //作为是订阅者取值的标识
    var value = this.getVMValue(this.exp, this.$vm);
    Dep.target = null;      //添加订阅者之后移除标识
    return value;
}
 
Watcher.prototype.update = function(){
    var newValue = this.getVMValue(this.exp, this.$vm);
    //如果新旧值不相等则执行回调函数重新渲染
    if(newValue !== this.value){
        this.value = newValue;
        this.cb();
    }
}
 
//获取vm实例上的数据,对象嵌套取值
Watcher.prototype.getVMValue = function(keyStr, vm){
    return new Function('vm', 'return vm.' + keyStr)(vm);
}

订阅者第一次取值的时候将该订阅者实例(watcher)赋值给Dep.target,然后观察者将该watcher添加到subs数组中,当监视的数据变化时dep实例会通知watcher调用update方法,然后判断值是否改变再执行更新dom的回调函数。

模板编译,指令与双大括号

compile.js

function Compile(el, vm){
    this.$vm = vm;      //需要vm实例
    this.$el = this.isElementNode(el) ? el : document.querySelector(el);    //根节点
    this.$fragment = this.nodeToFragment(this.$el);     //文档碎片
    this.init(this.$fragment);      //编译模板
    this.$el.appendChild(this.$fragment);       //挂载到dom元素上
}
 
//创建文档碎片
Compile.prototype.nodeToFragment = function(el){
    var fragment = document.createDocumentFragment();
    while(el.firstChild){
        fragment.appendChild(el.firstChild);
    }
    return fragment;
}
 
//编译模板
Compile.prototype.init = function(node){
    var nodes = node.childNodes;
    if(nodes.length){       //如果有子节点
        for(var i = 0; i < nodes.length; i++){
            if(this.isElementNode(nodes[i])){       //如果是元素节点
                this.compileElementNode(nodes[i]);      //编译元素节点
                this.init(nodes[i]);        //递归
            }else if(this.isTextNode(nodes[i])){     //如果是文本节点
                this.compileTextNode(nodes[i]);     //编译文本节点
            }
        }
    }
}
 
//编译文本节点
Compile.prototype.compileTextNode = function(node){
    var text = node.textContent;
    var reg = /\{\{.+\}\}/;     //双大括号{{}}的正则
    if(reg.test(text)){     //如果存在{{}}
        updater.braces(node, text, this.$vm);
        var me = this;      //回调函数内部this指向问题
        text.replace(/\{\{(.+?)\}\}/g, function(){
            var keyStr = arguments[1].trim();       //正则子匹配的值
            new Watcher(me.$vm, keyStr, function(){
                updater.braces(node, text, me.$vm);
            })
            return me.getVMValue(keyStr, me.$vm);
        })
    }
}
 
//编译元素节点
Compile.prototype.compileElementNode = function(node){
    var attrs = node.attributes;    //标签属性对象集合
    var me = this;
    Array.prototype.slice.call(attrs).forEach(function(attr){
        var attrName = attr.name;
        if(attrName.indexOf('v-') === 0){       //如果元素标签属性函数'v-'即为指令
            var attrValue = node.getAttribute(attrName);
            if(attrName.indexOf('on') > 0){     //指令属性键名含有'on'即为事件指令
                me.eventDirective(node, attrName, attrValue, me.$vm);       //事件指令                
            }else{      //否则为一般指令
                var directive = attrName.slice(2);      //指令名                
                if(directiveUtil[directive]){       //如果存在该指令才执行
                    directiveUtil[directive](node, me.$vm, attrName, attrValue);
                }
            }
        }
    })
}
 
//是否元素节点
Compile.prototype.isElementNode = function(node){
    return node.nodeType === 1;
}
//是否文本节点
Compile.prototype.isTextNode = function(node){
    return node.nodeType === 3;
}
 
//获取vm实例上的数据,对象嵌套取值
Compile.prototype.getVMValue = function(keyStr, vm){
    return new Function('vm', 'return vm.' + keyStr)(vm);
}
 
//事件指令
Compile.prototype.eventDirective = function(node, attrName, attrValue, vm){
    var eventName = attrName.split(':')[1];
    var fn = this.getVMValue(attrValue, vm);
    if(fn){
        node.addEventListener(eventName, fn.bind(vm), false);   //元素监听事件,回调函数内部指向vm实例
        node.removeAttribute(attrName);
    }
}
 
//指令工具
var directiveUtil = {
    //v-text
    text: function(node, vm, attrName, attrValue){
        this.bind(node, vm, attrName, attrValue, 'text');
    },
    //v-html
    html: function(node, vm, attrName, attrValue){
        this.bind(node, vm, attrName, attrValue, 'html');       
    },
    //v-class
    class: function(node, vm, attrName, attrValue){
        this.bind(node, vm, attrName, attrValue, 'class');
    },
    //v-model
    model: function(node, vm, attrName, attrValue){
        this.bind(node, vm, attrName, attrValue, 'model');
        node.addEventListener('input', function(){
            var value = node.value;
            // 用new Function()来执行表达式字符串
            new Function('vm', 'value', 'console.log(vm.'+attrValue+'= value)')(vm, value);            
        }, false);
    },
    //用于给指令添加订阅者
    bind: function(node, vm, attrName, attrValue, funName){
        //初始化界面
        updater[funName] && updater[funName](node, vm, attrValue);  //如果存在则执行
        var exp = attrValue;
        if(attrValue.indexOf('?') > 0 && attrValue.indexOf(':') > 0){   //如果标签属性值是三元表达式
            exp = attrValue.slice(0, attrValue.indexOf('?')).trim();    //表达式?前的变量
        }
        //添加订阅者
        new Watcher(vm, exp, function(){
            updater[funName] && updater[funName](node, vm, attrValue);  //如果监视的值变了才执行
        });
        //移除html指令属性
        node.removeAttribute(attrName);
    }
}
 
//更新工具,数据变化时会调用的函数
var updater = {
    //双大括号
    braces: function(node, text, vm){
        node.textContent = text.replace(/\{\{(.+?)\}\}/g, function(){
            var keyStr = arguments[1].trim();       //正则子匹配的值
            return Compile.prototype.getVMValue(keyStr, vm);
        });
    },
    //v-text
    text: function(node, vm, attrValue){
        var value = Compile.prototype.getVMValue(attrValue, vm);
        node.textContent = value;
    },
    //v-html
    html: function(node, vm, attrValue){
        var value = Compile.prototype.getVMValue(attrValue, vm);
        node.innerHTML = value;
    },
    //v-class
    class: function(node, vm, attrValue){
        var newClass = '';
        if(attrValue.indexOf('?') > 0 && attrValue.indexOf(':') > 0){   //如果是三元表达式
            var variable = attrValue.slice(0, attrValue.indexOf('?')).trim();//表达式?前的变量
            var val = Compile.prototype.getVMValue(variable, vm);       //表达式?前的变量的值
            var expression = attrValue.replace(/.+\?/, val+' ?');       //新的三元表达式
            newClass = new Function("return "+ expression)();       //得到新的类名
            expression = attrValue.replace(/.+\?/, !val+' ?');       //旧的三元表达式
            oldClass = new Function("return "+ expression)();       //得到旧的类名
        }else{
            newClass = Compile.prototype.getVMValue(attrValue, vm);     //否则类名在vm实例中找
        }
        var classNameStr = node.className;
        if(classNameStr){         //如果元素原来有类名
            var classNames = node.className.split(' ');
            if(classNames.indexOf(oldClass) >= 0){      //如果旧类名存在
                classNames.splice(classNames.indexOf(oldClass), 1);     //去除旧类名
                classNameStr = classNames.join(' ');
            }
            node.className = classNameStr + ' ' + newClass;
        }else{
            node.className = newClass;
        }
    },
    //v-model
    model: function(node, vm, attrValue){
        node.value = Compile.prototype.getVMValue(attrValue, vm);  
    }
}

模板编译也是关键的一步,这里代码有点多,虽然只是简单地实现了v-text,v-html,v-model,v-class和v-on:的事件指令以及 {{}},其中只有v-class可以使用简单的三元表达式,其他的都只能是data的属性。顺便一提的根据表达式字符串取值vue源码里面有很完善的方法实现,而我这里为了简便就直接使用new Function()。好了,以上代码就可以简单实现vue中的像数据劫持、双向数据绑定的一些核心原理。

最后再加上html文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        .green{
            color: green;
        }
        .red{
            color: red;
        }
    </style>
</head>
<body>
    <div id="app">
        <input type="text" v-model="arr[2]" v-on:input="input">
        <h2>{{msg}}---{{hello}}---{{arr[2]}}</h2>
        <p v-text="msg">哈哈哈</p>
        <p v-html="msg" v-class="isRed ? 'red': 'green'">哈哈哈</p>
        <button v-on:click="changeColor">点我</button>
    </div>
    <script src="watcher.js"></script>
    <script src="observer.js"></script>
    <script src="compile.js"></script>
    <script src="mvvm.js"></script>
    <script>
        var vm = new MVVM({
            el: '#app',
            data: {
                hello: 'hello world',
                msg: '<h2>欢迎来到自己实现的mvvm</h2>',
                obj: {
                    a:1
                },
                arr: [1,2,'hahaha'],
                isRed: true,
                green: 'green'
            },
            methods: {
                changeColor(){
                    this.isRed = !this.isRed;
                    this.msg = '<h2>看到我就证明数据发布订阅成功了</h2>'
                    // this.obj.a = 2;
                    console.log(this)
                }
            }
        })
        console.log(vm)
    </script>
</body>
</html>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容

  • vue理解浅谈 一 理解vue的核心理念 使用vue会让人感到身心愉悦,它同时具备angular和react的优点...
    ambeer阅读 24,130评论 2 18
  • 本文是lhyt本人原创,希望用通俗易懂的方法来理解一些细节和难点。转载时请注明出处。文章最早出现于本人github...
    lhyt阅读 2,211评论 0 4
  • 这方面的文章很多,但是我感觉很多写的比较抽象,本文会通过举例更详细的解释。(此文面向的Vue新手们,如果你是个大牛...
    Ivy_2016阅读 15,391评论 8 64
  • 「声动派」,专注互联网价值传播,为你分享大连接时代的一切! 本文大约11000字阅读需要12分钟 第一部分 写在前...
    声动派阅读 585评论 0 1
  • 师者,传道、受业、解惑也。这本是为人师表的职业操守。 您至少科班出身,30+,即是老师,同时也应该是家长,理论上您...
    梅语阅读 475评论 0 2