美文网首页程序员
VUE双向绑定原理

VUE双向绑定原理

作者: Aleph_Zheng | 来源:发表于2018-01-30 22:51 被阅读97次

    前言

    在之前面试中,有被问到这个问题,虽然了解过是劫持Object.defineProperty方法,但是其细节并不太清楚,于是遭到了面试官的鄙视👎,只能回头认真在网上看一下。

    刚开始看了很多文章,还是没看懂。

    最后我是看这篇文章看懂的,其他的要么略过太多细节,看着有种断层感,根本不知道怎么突然到这一步了。有些要么跟着代码讲思路,有点乱。

    这篇文章已经讲解得很好了,但是作为一个小白,我还是看了老半天才懂,原因就是看的源码少,水平不够。

    所以我决定重新捋一捋里面的思想,把细节尽可能说清楚,让跟我一样没学过任何源码的人也能搞清楚。

    补充一下个人想法,对于这些精妙的思维接触不多,而这些往往是决定我们的高度的,是一个使用者还是研究者?有时候眼光的高低,决定着我们未来道路的长短。

    大致原理

    vue的响应原理可以从下面官网的分析图大致了解。

    官网的解释是这样的:

    每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新。

    image.png

    看不懂?没关系,有个大概印象就可以了。

    defineProperty是什么鬼?

    为什么要先从这里说起?因为这是众所周知vue双向绑定的原理。

    MDN解释在这里

    简单地说,就是对于我们的对象的属性,我们可以通过defineProperty来设置它的getset方法,一旦获取值,就会触发get方法,一旦修改值,就会触发set方法。

    比如下面简单的例子

    var obj = {name:'zeller'};
    
    Object.defineProperty(obj,'name',{
      get:function(){
        console.log(`你正在获取obj的name值.`)
      },
      set:function(newVal){
        console.log(`name值修改中,新的name值是${newVal}`)
      },
    })
    
    obj.name//"你正在获取obj的name值."
    obj.name = 'atoms'//"name值修改中,新的name值是atoms"
    
    image.png

    codepen在线预览

    用defineProperty实现一个极简的双向绑定例子

    既然这个方法这么有用,我们设置一个容器obj,直接在set里面渲染我们的html,然后监听input的keyup事件,当事件触发时,修改obj对应的值,从而再触发html的改变。

    既然大概思路有了,我们可以尝试一下.

    <!--html-->
    <input type="text" id="content">请输入内容
    <br><br>
    他输入的内容是:<p id="reflect" style="color:red;"></p>
    
    var obj={};
    //假设我们监听'hello'这个属性
    Object.defineProperty(obj,'hello',{
      set:function(newVal){
        var p = document.getElementById('reflect');
        p.innerHTML = newVal;
      }
    })
    
    var input = document.getElementById('content');
    input.addEventListener('keyup',function(e){
      obj.hello = e.target.value;
    })
    
    image.png

    在线预览

    分解实际任务

    虽然上面的简单演示我们貌似做出来了,但是与实际的样子却不一样。我们看看。

    image.png image.png

    实际是上面这样子调用的,所以我们需要分析一下,该如何实现。

    首先,我们要在初次渲染html能拿到data的数据

    其次,输入框输入内容变化时,data中的相应属性也能变化

    最后,data中的数据变化时,html能实时跟着变化

    所以我们大概可以分为3个任务

    • 1、输入框以及文本节点与data中的数据绑定(初始渲染)

    • 2、输入框内容变化时,data中的数据同步变化。即view => model的变化。

    • 3、data中的数据变化时,文本节点的内容同步变化。即model => view的变化。

    任务1:初始加载渲染data里的属性

    既然要加载data里的属性值,我们就要考虑两种情况,app里的子节点的类型,

    • 当childNode是文本节点,而我们匹配到{{attr}}时,我们需要去找vue里面绑定的data的attr属性,把它的值替换给文本节点.
    • 当childNode是元素节点时,比如<input v-model="attr">,我们就要去找vue.data.attr的值,并赋给childNode

    因此可以看出,我们需要先把所有子节点遍历出来,看看有没有符合以下两个规则的内容:

    • 文本节点,含有
      {{attr}}
    • 元素节点,含有v-model

    这样把值替换完我们就可以返回去了,但是考虑到多次操作dom的开销,我们用createDocumentFragment()

    它相当与创建一个仓库,每次把子节点修改完,我们不直接插入父节点(#app),而是放入仓库,最后直接把仓库里的东西替换掉就可以了。

    创建fragment仓库

    function nodeToFragment (node, vm) {
            var flag = document.createDocumentFragment();
            var child;
            // 许多同学反应看不懂这一段,这里有必要解释一下
            // 首先,所有表达式必然会返回一个值,赋值表达式亦不例外
            //child = node.firstChild返回的是赋值的node.firstChild
            //即只要firstChild存在,就把firstChild赋给child
            // 理解了上面这一点,就能理解 while (child = node.firstChild) 这种用法
            // 其次,appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除
            // 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了
            while (child = node.firstChild) {
              compile(child,vm)//讲data转化为html
              flag.appendChild(child); // 将子节点劫持到文档片段中
            }
            return flag
        }
    

    compile方法在下面解释

    替换html

    这里主要用的是正则表达式的检测方法,其中对RegExp.$1的用法不了解的同学可以Google一下,这是正则一个非常巧妙而且强大的地方。

    function compile (node, vm) {
            var reg = /\{\{(.*)\}\}/;
            // 节点类型为元素
            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; // 获取 v-model 绑定的属性名
                       node.value = vm.data[name];
                       node.removeAttribute('v-model')
                    }
                };
    
            }
            // 节点类型为 text
            if (node.nodeType === 3) {
                if (reg.test(node.nodeValue)) {
                    var name = RegExp.$1; // 获取匹配到的字符串
                    name = name.trim();
                    node.nodeValue = vm.data[name]
                }
            }
        }
    

    我们看看上面的代码,主要就是判断子节点的类型,一旦是元素节点,我们就给它的input事件绑定方法,把input的value传给vm.data[name],如果是文本节点,就直接替换.
    这里要注意,element节点我们用的是node.value,text节点我们用的是node.nodeValue,这两个写法的区别可以自行Google一下.

    最后再创建一个Vue实例


    image.png

    下面是codepen的实例


    image.png

    codepen

    任务2:响应式的数据绑定

    再来看任务二的实现思路:当我们在输入框输入数据的时候,首先触发input事件(或者keyup、change事件),在相应的事件处理程序中,我们获取输入框的value并赋值给vm实例的text属性。我们会利用defineProperty将data中的text劫持为vm的访问器属性,因此给vm.data.text赋值,就会触发set方法。在set方法中主要做两件事,第一是更新属性的值,第二留到任务三再说。

    具体怎么做呢?

    监听input事件

    input节点

    当我们触发input时,要在dom节点上绑定事件?
    怎么绑定呢?记得我们前面的nodeToFragment函数吗?就是用于遍历所有的子节点,进行节点修改的。

    而里面具体干活的是compile函数,nodeToFragment只是一个包工头。
    这样,我们就可以在v-model的标签里监听input事件

    if (attr[i].nodeName == 'v-model') {
        var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
    node.addEventListener('input', function (e) {
        // 给相应的 data 属性赋值,进而触发该属性的 set 方法
        vm.data[name] = e.target.value;
    });
    
    node.value = vm.data[name]; // 将 data 的值赋给该 node
    node.removeAttribute('v-model');
    

    我们看看逻辑,一开始就是从vm.data[name]获取value,一旦自己的内容改变了(e.target.value),就把这个值告诉(赋值)给vm.data[name]

    文本节点

    而对于文本节点,是不需要的,我们只需要从vm.data获取数据就可以了。因为它不是可以通过input改变内容的。

    node.nodeValue = vm.data[name];
    

    劫持get和set方法

    想想我们的思路,我们input触发时,是这样修改data值的

     vm.data[name] = e.target.value;
    

    我们希望触发点东西,但那是下一章的内容,无论如何,我们先劫持这些vm.data的所有属性的get和set方法。
    以后究竟要怎么搞事我们再决定。

    怎么劫持呢?

    我们只有在Vue中写入一个observe,用于遍历所有属性,进行get和set的劫持。

    function Vue (options) {
            this.data = options.data;
            var data = this.data;
    
            observe(data, this);
    
            var id = options.el;
            var dom = nodeToFragment(document.getElementById(id), this);
    
            // 编译完成后,将 dom 返回到 app 中
            document.getElementById(id).appendChild(dom);
        }
    

    接下来就是怎么写这个observe。

    首先必须遍历所有节点。
    然后用defineProperty设置get和set方法,这是我们暂且在set时打印新值,看看data是否真的改变了

    function observe (obj, vm) {
            Object.keys(obj).forEach(function (key) {
                defineReactive(vm.data, key, obj[key]);
            })
    }
    
    function defineReactive (obj, key, val) {
            Object.defineProperty(obj, key, {
                get: function () {
                    return val
                },
                set: function (newVal) {
                    if (newVal === val) return
                    val = newVal;
                    console.log(obj[key])
                }
            });
        }
    

    以上就是我们的第二部分,主要实现两部分:

    1、设置观察函数observe,改写get和set
    2、监听元素节点的input,当符合条件(匹配正则)时,首先从vm.data.key获取相应属性的值,触发get。
    当input的内容发生改变时,把该值赋给vm.data.key,触发set。

    codepen完整代码在这里

    image.png

    可以看到当input的值发生改变时,vm.data.key也发生改变,这里我们先用console来判断这个值是否改变了。

    至此,第二部分已经完成。

    任务3:把data的值渲染到dom里面

    上面已经实现了值的双向传递,我们主要用了属性劫持和方法监听(input)。

    接下来想想我们该如何把data渲染进dom。

    记得我们刚开始的极简版demo吗?

    Object.defineProperty(obj,'hello',{
     set:function(newVal){
       var p = document.getElementById('reflect');
       p.innerHTML = newVal;
     }
    })
    

    我们是通过找到p元素,当data改变时,直接把新值传给p元素。

    但是有一个问题,我们这里假设已经知道p元素与data双向绑定了。

    如果我们不知道呢?
    仔细看看这句代码p.innerHTML = newVal;
    到底哪一个元素的innerHTML才是newVal?

    所以我们的关键是找到哪一个节点的对应哪一个属性(vm.data)

    这是vue最核心的部分之一

    假设我们有一个容器,当我们get内容时,那这个节点肯定与data绑定了,此时我们把这个节点push进这个容器,这样只要每次data改变,我们遍历所有的节点不就可以了吗?

    vue管这个容器叫"依赖"(dep),或许表示所有dep里的节点都会依赖这个容器dep。

    这么说有点绕口,比如这样,我们在每个属性上绑定一个容器dep,容器上有个数组subs,当有节点get这个属性的值时,我们就记录下这个节点,push进subs。

    而当我们的data改变时,就可以遍历所有的节点,让他们更新dom了。

    意思就是连接节点和data的基本思路。具体怎么实现呢?

    首先我们每个属性各自都需要一个依赖dep,我们可以写一个构造函数Dep,实例对象维护一个数组,用于存放节点。

    function Dep () {
            this.subs = []
    }
    

    这个依赖还必须有两个功能,添加和更新。
    有节点绑定了,就把它添加到数组。
    有内容(data)更新了,就”告诉“所有节点去更新dom

    所以原型还需要添加这两个方法:

    Dep.prototype = {
       addSub: function(sub) {
           this.subs.push(sub);
       },
    
       notify: function() {
           this.subs.forEach(function(sub) {
               sub.update();
           });
       }
    }
    

    这个dep是跟着属性走的,所以我们需要在遍历属性时创建。

    function defineReactive (obj, key, val) {
       var dep = new Dep();
    
       Object.defineProperty(obj, key, {
           get: function () {
               // 添加订阅者 watcher 到主题对象 Dep
               if (添加一个条件) dep.addSub();
               return val
           },
           set: function (newVal) {
               if (newVal === val) return
               val = newVal;
               // 作为发布者发出通知
               dep.notify();
           }
       });
    

    这里的get我们应该把节点push进容器数组,但是想一想,是不是连接建立后我们才要把这个节点push进去呢?怎么判断是不是建立连接了呢?

    记得我们的compile函数吗?

    if (attr[i].nodeName == 'v-model') {
        var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
        node.addEventListener('input', function (e) {
           // 给相应的 data 属性赋值,进而触发该属性的 set 方法
           vm.data[name] = e.target.value;
        });
        node.value = vm.data[name]; // 将 data 的值赋给该 node
        node.removeAttribute('v-model');
    }
    

    此时是不是通过判断节点是否有”v-model“,但有时,从data里获取v-model绑定的属性值?

    这是连接建立的关键,所以再这之后,我们可以判断可以把节点push进去了。

    但是想想,光是节点够吗?我们是否还需要更新的函数?能否写在一起?
    所以我们可以建立一个Watcher函数,用于更新dom,这样当有data改变时,只要dep告诉我们去更新所有的Watcher就可以了。

    这个Watcher就相当于一个容易,包裹了dom元素的内容还有更新方法。

    所以我们push进dep的是一个个的Watcher,有更新就调用Watcher的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.data[this.name]; // 触发相应属性的 get
            }
        }
    

    这里的Dep.target是作为节点与data绑定的标志,一旦这个存在了,说明我们要去get方法那里push Watcher了。
    之后我们要清除这个Dep.target,有其他Watcher实例对象创建时再赋值,传给dep.
    因此相当于一个临时的标志容器,且是全局的。

    现在看看上面劫持get时的if条件,应该知道怎么写了吧。
    就是Dep.target存在的时候

    get: function () {
     // 添加订阅者 watcher 到主题对象 Dep
     if (Dep.target) dep.addSub();
     return val
    }
    

    至此,我们的程序就完成了。
    测试是没有问题的。

    image.png

    下面是我画的流程图,可以帮助理解。

    image.png

    完整示例在这里

    回顾

    我们创建了一个类似vue的双向绑定机制,怎么实现的呢?

    我想从data获取数值,于是我们改变dom,通过匹配正则,符合条件的把data的值赋给dom的value或nodeValue

    我们想把内容变更传递给data,于是我们改造所有的data.
    各自给它们一个容器dep的数组subs,当连接建立(标志是)同样是正则匹配上了。

    此时新建一个watcher,用于标识dom和存放更新dom的方法。

    当input的内容改变时,触发obj的set方法,set方法命令subs更新dom,subs遍历所有watcher,让所有watcher中的方法去更新自己的dom。

    初次写这么长的文章,刚开始理解这个机制对我来说也有点吃力,但总算搞懂了。

    以上,我的解释还有许多不足,欢迎指教,感谢阅读。

    如果有可取之处,一个赞便感激不已。🙂

    相关文章

      网友评论

        本文标题:VUE双向绑定原理

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