5分钟教你实现Vue双向绑定

2,522 阅读7分钟

前言

很多人在面试过程中都有问到Vue双向绑定的原理和实现,这是一个老生常谈的面试题了,虽然网上也有很多实现双向绑定的文章,但是我看后觉得对于大多数前端小白来说,不是很容易理解,所以,这篇文章我就用最简单的代码教大家怎么实现一个Vue的双向绑定。

双向绑定的原理

用过Vue框架的都知道,页面在初始化的时候,我们可以把data里的属性渲染到页面上,改动页面上的数据时,data里的属性也会相应的更新,这就是我们所说的双向绑定,所以,简单来说,我们要实现一个双向绑定要实现以下3点操作:

  1. 首先需要在Vue实例化的时候,解析代码中v-modle指令和{{}}指令,然后把data里的属性绑定到相应的指令上,所以我们要实现一个解析器Compile,这是第一点;
  2. 接着我们在改变页面的属性的时候,要知道哪个属性改变了,这时候我们需要用到Object.defineProperty中的gettersetter方法对属性进行劫持,这里我们要实现一个监视器Observer,这是二点;
  3. 我们在知道具体哪个属性改变后,要执行相应的函数,更新视图,这里我们要实现一个消息订阅,在页面初始化的时候订阅每个属性,并且在Object.defineProperty数据劫持的时候接收属性改变通知,更新视图,所以我们要实现一个订阅者Watcher,这是第三点。

1. 实现Compile

首先,我们从最基本的解析指令开始,话不多说,先上代码:

我们在写Vue的时候,用了v-model{{}}指令,但是页面渲染的时候,我们在浏览器看到的节点是这样的。

我们从上面的图片可以看到,代码里写的指令都消失了,但是data里的属性都正常渲染到页面上了, 原理其实很简单,在Vue实例化的时候,Vue便利循环,扫描和解析每个节点的相关指令,然后再根据对应的指令赋值,最后把相应的指令替换删除,再重新渲染页面。 所以,接下来我们要实现一个解析器Compile,先从解析v-model{{}}开始。 话不多说,上代码:

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

<body>
    <div id="app">
        <input type="text" v-model="text">
        <div>{{text}}</div>
    </div>
</body>
<script type="text/javascript">
    var vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world',
        }
    })

    function Vue(options) {
        this.data = options.data;
        var id = options.el;
        var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文档片段)
        document.getElementById(id).appendChild(dom); //将处理好的DocumentFragment重新添加到Dom中
    }

    function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        while (child = node.firstChild) {
            compile(child, vm);
            flag.appendChild(child)
        }
        return flag
    }
    //解析节点
    function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //判断是否有子节点
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(function (node) {
                compile(node, vm)
            })
        } else {
            //解析v-model
            if (node.nodeType === 1) {
                var attr = node.attributes;
                for (var i = 0; i < attr.length; i++) {
                    if (attr[i].nodeName == "v-model") {
                        var name = attr[i].nodeValue;
                        node.value = vm.data[name]; //将data里的值赋给node
                        node.removeAttribute('v-model'); //移除v-model属性
                    }
                };
            }
            //解析{{}}
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 获取匹配到的字符串
                    name = name.trim();
                    node.nodeValue = vm[name]
                }
            }
        }
    }
</script>

</html>

上面这段代码就是解析指令的简单方法,我来简单解释一下:

  1. document.createDocumentFragment()
    document.createDocumentFragment() 相当于一个空的容器, 是用来创建一个虚拟的节点对象,在这里我们要做的就是:在遍历节点的同时对相应指令进行解析,解析完一个指令将其添加到createDocumentFragment中,解析完后再重新渲染页面,这样的好处就是减少页面渲染dom的次数,详细内容可参考文档 createDocumentFragment()用法总结
  2. function compile (node, vm)
    compile()方法里面我们对每个节点进行判断,首先判断节点是否包含有子节点,有的话继续调用compile()方法进行解析。没有的话就判断节点类型,我们主要是判断element元素类型文本text元素类型,然后分别对这两种类型进行解析。

完成了以上步骤后,我们的代码就可以正常显示在页面上了, 但是,有一个问题,我们页面上绑定了data里的属性,但是在改变input框里的数据的时候,相应的data里面的数据没有同步更新。所以,接下来我们要对数据的更新进行劫持,通过Object.defineProperty()劫持data里的对应属性变化。

2. 实现Observer

要实现数据的双向绑定,我们需要通过Object.defineProperty()来实现数据劫持,监听属性的变化。 所以,接下来我们先通过一个简单的例子来了解Object.defineProperty()的工作原理。

var obj ={};
var name="hello";
Object.defineProperty(obj,'name',{
    
    get:function(val) {//获取属性
        console.log('get方法被调用了');
        
        return name 
    },
    set:function(val) { //设置属性 
        console.log('set方法被调用了');
        name=val  
    }
})
console.log(obj.name);
obj.name='hello world'
console.log(obj.name);

运行代码,我们可以看到控制台输出:

从控制台的输出我们可以看出,我们通过Object.defineProperty( )设置了对象obj的name属性,对其get和set进行重写操作,顾名思义,get就是在读取name属性这个值触发的函数,set就是在设置name属性这个值触发的函数,关于Object.defineProperty()这里就不多说了,具体可以参考文档defineProperty()使用教程
所以,接下来我们要做的是当我们在输入框输入数据的时候,首先触发 input 事件(或者 keyup、change 事件),在相应的事件处理程序中,我们获取输入框的 value 并赋值给 vm 实例的 text 属性。话不多说,上代码。

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

<body>
    <div id="app">
        <input type="text" v-model="text">
        <div>{{text}}</div>
    </div>
</body>
<script type="text/javascript">
    var vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world',
        }
    })

    function Vue(options) {
        this.data = options.data;
        var id = options.el;
        observe(this.data,this); //初始化的时候对data里的所有属性进行监听
        var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文档片段)
        document.getElementById(id).appendChild(dom); //将处理好的DocumentFragment重新添加到Dom中
    }

    function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        while (child = node.firstChild) {
            compile(child, vm);
            flag.appendChild(child)
        }
        return flag
    }
    //解析节点
    function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //判断是否有子节点
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(function (node) {
                compile(node, vm)
            })
        } else {
            //解析v-model
            if (node.nodeType === 1) {
                var attr = node.attributes;
                for (var i = 0; i < attr.length; i++) {
                    if (attr[i].nodeName == "v-model") {
                        var name = attr[i].nodeValue;
                        node.addEventListener('input',function(e){
                            vm[name]=e.target.value;
                        })
                        node.value= vm[name];//将data里的值赋给node
                        node.removeAttribute('v-model'); //移除v-model属性
                    }
                };
            }
            //解析{{}}
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 获取匹配到的字符串
                    name = name.trim();
                    node.nodeValue = vm[name]
                }
            }
        }
    }
    function defineReactive(obj,key,val) {
        Object.defineProperty(obj,key,{
            get:function() {
                return val;
            },
            set:function(newval) {
                if(newval === val) return;
                val = newval;
                console.log(val);//打印(监听数据的修改)
                
            }
        
        })
    }
    //地递归遍历所有data属性
    function observe(obj,vm) {
        Object.keys(obj).forEach(function(key){
            defineReactive(vm,key,obj[key])
        })
    }
</script>

</html>

我们在页面初始化的时候,通过递归遍历data所有子属性,给每个属性添加一个监视器,在监听到数据变化时候,就会触发defineProperty( )里的set方法,我们可以在控制台输出看到set方法里监听到属性的变化。

从上图我们可以看到,set方法触发了,input里text的属性也变化了, 但是文本节点的内容并没有同步变化,如何让同样绑定到 text 的文本节点也同步变化呢?所以,接下来我们要实现一个之前我们说的订阅者Watcher,在set方法触发时,接受属性改变通知,更新视图。

3. 实现Watcher

很多人看过网上的其他实现MVVM实现的代码,但是都说对Watcher订阅者不是很了解,其实抛开代码,Watcher实现的功能其实很简单,就是当Vue实例化的时候,给每个属性注入一个订阅者Watcher,方便在Object.defineProperty()数据劫持中监听属性的获取(get方法),在Object.defineProperty()监听到数据改变的时候(set方法),通过Watcher通知更新,所以简单来说,Watcher就是起到一个桥梁的作用。我们上面已经通过Object.defineProperty()监听到数据的改变,接下来我们通过实现Watcher 来完成双向绑定的最后一步。

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

<body>
    <div id="app">
        <input type="text" v-model="text">
        <div>{{text}}</div>
    </div>
</body>
<script type="text/javascript">

    function Vue(options) {
        this.data = options.data;
        var id = options.el;
        observe(this.data, this); //初始化的时候对data里的所有属性进行监听
        var dom = nodeToFragment(document.getElementById(id), this) //DocumentFragment(文档片段)
        document.getElementById(id).appendChild(dom); //将处理好的DocumentFragment重新添加到Dom中
    }

    function nodeToFragment(node, vm) {
        var flag = document.createDocumentFragment();
        var child;
        while (child = node.firstChild) {
            compile(child, vm);
            flag.appendChild(child)
        }
        return flag
    }
    //解析节点
    function compile(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //判断是否有子节点
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(function (node) {
                compile(node, vm)
            })
        } else {
            //解析v-model
            if (node.nodeType === 1) {
                var attr = node.attributes;
                for (var i = 0; i < attr.length; i++) {
                    if (attr[i].nodeName == "v-model") {
                        var name = attr[i].nodeValue;
                        node.addEventListener('input', function (e) {
                            vm[name] = e.target.value;
                        });
                        node.value = vm[name];//将data里的值赋给node
                        node.removeAttribute('v-model'); //移除v-model属性
                    }
                };
                new Watcher(vm, node, name, 'input');//生成一个新的Watcher,标记为input
            }
            //解析{{}}
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 获取匹配到的字符串
                    name = name.trim();
                    new Watcher(vm, node, name, 'text');//生成一个新的Watcher,标记为文本text
                }
            }
        }
    }
    //地递归遍历所有data属性
    function observe(obj, vm) {
        Object.keys(obj).forEach(function (key) {
            defineReactive(vm, key, obj[key])
        })
    }
    function defineReactive(obj, key, val) {
        var dep = new Dep();
        Object.defineProperty(obj, key, {
            get: function () {
                // 添加订阅者 watcher 到主题对象 Dep;
                if (Dep.target) dep.addSub(Dep.target);
                return val
            },
            set: function (newVal) {
                if (newVal === val) return
                val = newVal;
                // 作为发布者发出通知
                dep.notify();
            }
        });
    }
    //将所有初始化的生成的订阅者都收集到一个数组中
    function Dep() {
        this.subs = []
    }
    Dep.prototype = {
        addSub: function (sub) {
            this.subs.push(sub)
        },
        notify: function () {
            this.subs.forEach(function (sub) {
                sub.update();
            })
        }
    }
    //订阅者Watcher
    function Watcher(vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
    }

    Watcher.prototype = {
        //执行对应的更新函数
        update: function () {
            this.get();
            if (this.nodeType == 'text') {
                this.node.nodeValue = this.value;
            }
            if (this.nodeType == 'input') {
                this.node.value = this.value;
            }
        },
        // 获取 data 中的属性值
        get: function () {
            this.value = this.vm[this.name]; // 触发相应属性的 get
        }
    }
</script>
<script type="text/javascript">
    var vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world',
        }
    })
</script>
</html>

我们在第二步的代码基础上,加了一个订阅者Watcher和一个消息收集器Dep,接下来我就跟大家说说他们都做了什么。 首先:

function Watcher(vm, node, name, nodeType) {
        Dep.target = this;
        this.name = name;
        this.node = node;
        this.vm = vm;
        this.nodeType = nodeType;
        this.update();
        Dep.target = null;
    }

    Watcher.prototype = {
        //执行对应的更新函数
        update: function () {
            this.get();
            if (this.nodeType == 'text') {
                this.node.nodeValue = this.value;
            }
            if (this.nodeType == 'input') {
                this.node.value = this.value;
            }
        },
        // 获取 data 中的属性值
        get: function () {
            this.value = this.vm[this.name]; // 触发相应属性的 get
        }
    }

Watcher()方法接收的参数为vm实例,node节点对象,name传入的节点类型的名称,nodeType节点类型。
首先,将自己赋给了一个全局变量 Dep.target;

其次,执行了 update 方法,进而执行了 get 方法,get 的方法读取了 vm 的访问器属性,从而触发了访问器属性的 get 方法,get 方法中将该 watcher 添加到了对应访问器属性的 dep 中;

再次,获取属性的值,然后更新视图。

最后,将 Dep.target 设为空。因为它是全局变量,也是 watcher 与 dep 关联的唯一桥梁,任何时刻都必须保证 Dep.target 只有一个值。

在实例化的时候,我们针对每个属性都添加一个Watcher()订阅者,在observe()的监听属性赋值的时候,将每个属性绑定的订阅者存储在Dep数组中,在set方法触发的时候,调用dep.notify()方法通知Watcher()更新数据,最后实现了视图的更新。

4. 结语

以上就是Vue双向绑定的基本实现原理及代码,当然,这只是基本的实现代码,简单直观的展现给大家看,如果大家想更深入了解的话,推荐大家去阅读这篇文章 vue的双向绑定原理及实现

好啦,以上就是本次的分享,希望对大家理解Vue双向绑定的理解有所帮助,也希望大家有什么不懂或者建议,可以留言互动。