Vue双向数据绑定原理(分析与代码)

1,027 阅读6分钟

Vue双向数据绑定这一块看了好久,也找了很多博客掘金啊之类的看,总想整理出来,一直拖到现在,写写吧。

Vue双向数据绑定介绍

vue 双向数据绑定也就是说数据和视图同步。即数据发生变化,视图跟着变化;视图变化,数据也随之发生改变。即Vue中V-model v-html之类的。

Vue双向数据绑定原理

通过 数据劫持结合发布订阅模式的方式来实现的,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
第1步:需要observe的数据对象进行遍历,包括子属性对象的属性,都加上setter和getter。这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化;
第2步:compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图;
第3步:Watcher订阅者是Observer和Compile之间通信的桥梁,主要做的事情是:
(1)在自身实例化时往属性订阅器(dep)里面添加自己
(2)自身必须有一个update()方法
(3)待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。
第4步:MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。

原理核心:Object.defineProperty()函数

从这里说起:假定一个对象:

var dreamapple = {
    firstName: 'dream',
    lastName: 'apple'
};

为了给dreamapple一个fullName属性,并且当firstNamelastName发生变化,fullName属性也要变化。在Vue.js使用计算属性(computed)实现,即设置fullName属性,通过set/get方法实现该功能。即:

// ...
computed: {
  fullName: {
    // getter
    get: function () {
      return this.firstName + ' ' + this.lastName
    },
    // setter
    set: function (newValue) {
      var names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[names.length - 1]
    }
  }
}
// ...

Vue.js是一个js框架,当然可以用原生js实现上述需求。通过给dreamapple这个对象设置了属性fullName的getter和setter方法实现。即

var dreamapple = {
    firstName: 'dream',
    lastName: 'apple',
    get fullName() {
        return this.firstName + ' ' + this.lastName;
    },
    set fullName(fullName) {
        var names = fullName.trim().split(' ');
        if(2 === names.length) {
            this.firstName = names[0];
            this.lastName = names[1];
        }
    }
};

dreamapple.firstName = 'Dream';
dreamapple.lastName = 'Apple';
console.log(dreamapple.fullName); // Dream Apple

dreamapple.fullName = 'Jams King';
console.log(dreamapple.firstName); // Jams
console.log(dreamapple.lastName); // King  

这里呢,我们可以考虑一种更好的方法实现,即本节的主题:Object.defineProperty()。这个方法的作用就是直接在一个对象上定义一个新属性,或者修改一个已经存在的属性,并返回这个对象;
Object.defineProperty(obj, prop, descriptor)
其中参数obj表示的是需要定义属性的那个对象,参数prop表示需要被定义或者修改的属性名,参数descriptor就是我们定义的那个属性prop的描述。
(1)value 该属性对应的值,可以是任何有效的JavaScript值(数值,对象,函数等),默认为undefined. 一个小例子说明参数descriptor:
(2) writable 当且仅当仅当该属性的writable为true时,该属性才能被赋值运算符改变;它的默认值为false. (3) enumerable这个特性决定定义的属性是否是可枚举,默认是false;把它设置为true时这属性才可使用for(prop in obj)和Object.keys()中枚举出. (4) configurable 这个特性决定对象的属性是否可被删除,以及除writable特性外的其它特性是否可被修改;并且writable特性值只可以是false
get 一个给属性提供getter的方法,如果没有getter则为undefined;该方法返回值被用作属性值,默认为undefined.
set一个给属性提供setter的方法,如果没有setter则为undefined;该方法将接受唯一参数,并将该参数的新值分配给该属性,默认为undefined. 下面解决该问题

Object.defineProperty(dreamapple, 'fullName', {
    enumerable: true,
    get: function () {
        return this.firstName + ' ' + this.lastName;
    },
    set: function (fullName) {
        var names = fullName.trim().split(' ');
        if (2 === names.length) {
            this.firstName = names[0];
            this.lastName = names[1];
        }
    }
});   

手写最简单的双向数据绑定实现代码

(1)首先通过compile()函数渲染数据(登录页面显示input数据),然后给input添加一个事件监听器,将改变后的数据赋值给$data.data; (2)然后定义函数defineReactive()获取数据的变化返还给input;实际进行这一步骤策略是defineProperty()

<body>
    <div id="app">
        <input type="text" v-model="messege">
    </div>
</body>
<script src="./vue.js"></script>
<script>
    // 目标:一打开页面就能渲染input数据;修改input,messege也自动改变;修改messege,input也自动改变。
    const app = new Vue({
        el: '#app',
        data: {
            messege: 'shawn'
        }
    })
</script>

vue.js文件

class Vue {
    constructor(options) {
        //挂载数据
        this.$el = options.el;
        this.$data = options.data;
        this.observe();
        this.compile();
    }
    observe() {
        Object.keys(this.$data).forEach((key) => {
            this.defineReactive(this.$data, key, this.$data[key]);
        })
    }
    //负责input=>data.messege
    compile() {
        //获取inputDom
        const divDom = document.querySelector(this.$el);
        const inputDom = divDom.children[0];
        //赋予inputDom第一次渲染的值
        inputDom.value = this.$data.messege;
        //事件监听器:当input变化时,赋值给messege
        inputDom.addEventListener('input', e => {
            this.$data.messege = e.target.value;
        })
    }
    //负责数据劫持传递给订阅者
    defineReactive(data, key, value){
        Object.defineProperty(data, 'messege',{
            get() {
                return value;
            },
            set:(newValue) => {
                value = newValue;
                //获取inputDom
                const divDom = document.querySelector(this.$el);
                const inputDom = divDom.children[0];
                //赋予inputDom第一次渲染的值
                inputDom.value = value;
                //事件监听器:当input变化时,赋值给messege
            }
        })
    }
}

刚才说了,这是最简单的方式,不可避免有一些问题:没有实现指令v-model等指令,元素并列和嵌套的双向数据绑定也没实现。但是也可以算是手写了一个双向数据绑定。

手写双向数据绑定

有了上面的基础,理解这个就不难了,下面说一下我写这个代码的思路。 首先根据Vue真实双向数据绑定效果构建vue的构造函数,给它一个init属性,然后挂载原型的一些初始化操作。然后实现_obverse函数,对data进行处理,重写data的setget函数。写一个指令类Watcher,用来绑定更新函数,实现对DOM元素的更新。接下来我们定义一个_compile函数,用来解析我们的指令(v-bind,v-model)等,并在这个过程中对viewmodel进行绑定。

<!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>
</head>

<body>
    <div id="app">
        <form>
            <input type="text" v-model="number">
            <button type="button" v-click="increment">增加</button>
        </form>
        <h3 v-bind="number"></h3>
        <form>
            <input type="text" v-model="count">
            <button type="button" v-click="incre">增加</button>
        </form>
        <h3 v-bind="count"></h3>
    </div>
</body>
<script>
    function myVue(options) {
        this._init(options);
    }
    myVue.prototype._init = function (options) {
        this.$options = options;
        this.$el = document.querySelector(options.el);
        this.$data = options.data;
        this.$methods = options.methods;

        this._binding = {};
        this._obverse(this.$data);
        this._complie(this.$el);
    }
    myVue.prototype._obverse = function (obj) {
        var _this = this;
        Object.keys(obj).forEach(function (key) {
            if (obj.hasOwnProperty(key)) {
                _this._binding[key] = {
                    _directives: []
                };
                console.log(_this._binding[key])
                var value = obj[key];
                if (typeof value === 'object') {
                    _this._obverse(value);
                }
                var binding = _this._binding[key];
                Object.defineProperty(_this.$data, key, {
                    enumerable: true,
                    configurable: true,
                    get: function () {
                        console.log(`${key}获取${value}`);
                        return value;
                    },
                    set: function (newVal) {
                        console.log(`${key}更新${newVal}`);
                        if (value !== newVal) {
                            value = newVal;
                            binding._directives.forEach(function (item) {
                                item.update();
                            })
                        }
                    }
                })
            }
        })
    }
    myVue.prototype._complie = function (root) {
        var _this = this;
        var nodes = root.children;
        for (var i = 0; i < nodes.length; i++) {
            var node = nodes[i];
            if (node.children.length) {
                this._complie(node);
            }

            if (node.hasAttribute('v-click')) {
                node.onclick = (function () {
                    var attrVal = nodes[i].getAttribute('v-click');
                    return _this.$methods[attrVal].bind(_this.$data);
                })();
            }

            if (node.hasAttribute('v-model') && (node.tagName == 'INPUT' || node.tagName == 'TEXTAREA')) {
                node.addEventListener('input', (function (key) {
                    var attrVal = node.getAttribute('v-model');
                    _this._binding[attrVal]._directives.push(new Watcher(
                        'input',
                        node,
                        _this,
                        attrVal,
                        'value'
                    ))
                    return function () {
                        _this.$data[attrVal] = nodes[key].value;
                    }
                })(i));
            }

            if (node.hasAttribute('v-bind')) {
                var attrVal = node.getAttribute('v-bind');
                _this._binding[attrVal]._directives.push(new Watcher(
                    'text',
                    node,
                    _this,
                    attrVal,
                    'innerHTML'
                ))
            }
        }
    }

    function Watcher(name, el, vm, exp, attr) {
        this.name = name;         //指令名称,例如文本节点,该值设为"text"
        this.el = el;             //指令对应的DOM元素
        this.vm = vm;             //指令所属myVue实例
        this.exp = exp;           //指令对应的值,本例如"number"
        this.attr = attr;         //绑定的属性值,本例为"innerHTML"
        this.update();
    }
    Watcher.prototype.update = function () {
        this.el[this.attr] = this.vm.$data[this.exp];
    }

    window.onload = function () {
        var app = new myVue({
            el: '#app',
            data: {
                number: 0,
                count: 0,
            },
            methods: {
                increment: function () {
                    this.number++;
                },
                incre: function () {
                    this.count++;
                }
            }
        })
    }
</script>
</html>

到此结束啦,有用请点赞哦 git: github.com/Shawn199402…