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

本文主要借鉴sf上的一篇文章,原文写的非常清晰,可以看看

本文同时发布在简书上,代码也放在了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));

描述符同时存在的情况

configurable:属性是否可配置(重新defineProperty),是否可删除,默认false

enumerable:属性可否被遍历,默认false

writable:配合value使用,可否修改value,默认false

value默认undefined

get默认undefined

set默认undefined

一般用get、set就好不用writable和value

参考文章

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

简单理解,当前有对象 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()总是不被推荐使用,原因自行了解

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

先来实现一个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>

 

 

 

 

 

 

 

 

 

Logo

前往低代码交流专区

更多推荐