vue双向绑定原理略知一二

356 阅读12分钟

双向绑定不神秘,神秘的是认知

都快2020年了,我想国内做前端开发的没有没用过vue的吧,但是真知道vue的最具特色的双向绑定原理的可能真的不多,知道的可能也就是能答个订阅者模式,观察者模式云云。。。(我曾经也面试过很多应聘者,当然我以前出去面试也是这样答的。。。),因为没有深入理解,所以只能大概回答,有些人觉得会很神秘,其实神秘的是自己的认知。

深入原理-我自己先来一个

废话不多讲,直接进正题,我一直好奇一个问题,双向绑定为什么不给每一个需要双向绑定的DOM加一个检测事件不就搞定了吗?

<!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>
    <h1>我来接受信息!</h1>
    <input type="text" class="text"  onkeyup="changeInput(this)">
    <div style="font-size: 24px;padding-top: 30px;">标题变我就变!</div>
</body>
</html>
<script>
    var _h1 = document.querySelector('h1')
    var _div = document.querySelector('div')
    var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

    //input变化
     function changeInput(e){
        let text = e.value;
        console.log(text)
        _h1.innerHTML = text
     }
     // 配置观察选项:  
    var config = { attributes: true, childList: true, characterData: true }
     // 创建观察者对象  
    var observer = new MutationObserver(function(mutations) {  
        mutations.forEach(function(mutation) {  
            //发生变化我就更新
            _div.innerHTML = _h1.innerHTML
        });      
    });
    
    // 传入目标节点和观察选项  
    observer.observe(_h1, config); 
</script>
  • 首先监控数据输入者,这个简单就是用onkeyup事件,只要你输入信息我就去改DOM里的信息。

  • 然后 new 了一个MutationObserver ,这个不知道的可以去看下API,套路就是设置个目标节点,和对于它的一些配置 config,然后调用observe方法,把节点和信息传进去,接收变化触发内部事件。

  • 图一

  • 图二

看起来很好用啊,好了,双向绑定实现了,再见(我是来搞笑的。。。)

其实进一步推敲我发现事情不是我想的那么简单,当我更深入理解vue原理后,我发现自己实现的是很粗糙,本质原因还是思维的维度问题,vue监控的是变量的变化,而我监控的是DOM的变化! 大家都清楚像vue和react等一些运用虚拟DOM实现的框架,是数据驱动视图,就是说数据的变化才是他最关心的,数据的变化才是核心!

深入原理-仿照一个

那么vue具体怎么实现呢?还是先上代码。

demo.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>
</head>
<body>
        <h1 id="name"></h1>
        <input type="text" class="input_1">
        <input type="button" value="改变data内容" onclick="changeInput()">
</body>
</html>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script>
    
    //定义容器
     function myVue (data, el, exp) {
        this.data = data;
        observable(data);                      //将数据变的可观测
        el.innerHTML = this.data[exp];           // 初始化模板数据的值
        new Watcher(this, exp, function (value) {
            el.innerHTML = value;
        });
        return this;
    }



    var ele = document.querySelector('#name');
    var input_1 = document.querySelector('.input_1');
    
    var myVue = new myVue({
        name: 'hello world',
    }, ele, 'name');
    
    //改变输入框内容
    input_1.oninput = function (e) {
        myVue.data.name = e.target.value
    }

    function changeInput(){
        myVue.data.name = '点我干嘛!'
    }

</script>

observer.js

 /**
     * 把一个对象的每一项都转化成可观测对象
     * @param { Object } obj 对象
     */
    function observable (obj) {
        if (!obj || typeof obj !== 'object') {
            return;
        }
        let keys = Object.keys(obj);
        keys.forEach((key) =>{
            defineReactive(obj,key,obj[key])
        })
        return obj;
    }
    /**
     * 使一个对象转化成可观测对象
     * @param { Object } obj 对象
     * @param { String } key 对象的key
     * @param { Any } val 对象的某个key的值
     */
    function defineReactive (obj,key,val) {
        let dep = new Dep();
        Object.defineProperty(obj, key, {
            get(){
                dep.depend();
                console.log(`${key}属性被读取了`);
                return val;
            },
            set(newVal){
                val = newVal;
                console.log(`${key}属性被修改了`);
                dep.notify()                    //数据变化通知所有订阅者
            }
        })
    }
    class Dep {
        
        constructor(){
            this.subs = []
        }
        //增加订阅者
        addSub(sub){
            this.subs.push(sub);
        }
        //判断是否增加订阅者
        depend () {
            // console.log(Dep.target)
            if (Dep.target) {
                //console.log(Dep.target)
                
                this.addSub(Dep.target)
                // console.log(this.subs)
            }
        }

        //通知订阅者更新
        notify(){
            this.subs.forEach((sub) =>{
                sub.update()
            })
        }
        
    }
    Dep.target = null;

watcher.js

class Watcher {
    constructor(vm,exp,cb){
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        this.value = this.get();  // 将自己添加到订阅器的操作
    }
    get(){
        console.log(this)
        Dep.target = this;  // 缓存自己
        let value = this.vm.data[this.exp]  // 强制执行监听器里的get函数
       
        Dep.target = null;  // 释放自己
        return value;
    }
    update(){
        let value = this.vm.data[this.exp];
        let oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value);
        }
    }
}

最后实现的效果

这里非常感谢博主的启发通俗易懂了解Vue双向绑定原理及实现,具体的可以参照里面写的,已经写的很具体了,但是对于我这种小白而且还喜欢刨根问底的明显还是不够,有很多点还是不懂,所以我打算从代码执行的角度把上述代码从头到尾解读一遍,希望对于你有一些启发。 到这里你可以新建个文件夹,把这3个文件丢进去跑一遍了。

深入原理-代码逐行解读

(长文慎入!)

  1. 起点
input_1.oninput = function (e) {
    myVue.data.name = e.target.value
}//不用过多解释,触发了事件。当然我们现在不触发,只是一个引子。

2.触发改变了myVue对象属性值,myvue是啥?

    function myVue (data, el, exp) {
        this.data = data;
        observable(data);                      //将数据变的可观测
        el.innerHTML = this.data[exp];           // 初始化模板数据的值
        new Watcher(this, exp, function (value) {
            el.innerHTML = value;
        });
        return this;
    }
    
      var myVue = new myVue({
        name: 'hello world',
    }, ele, 'name');

在这里,实例化一个叫myVue的函数,给他传进去三个参数,一个对象,一个DOM节点,还有一个对象的Key.

what? 什么是实例化函数? 为什么不直接调用 ? 这样有毛用?(灵魂3问)

先看个例子

 function a (){
        this.add = function(a,b){
            alert(a + b) 
        };
    }
    
    a.add(1,2);// a.add is not a function
    
    var A = new a();
    A.add(1,2);//ok

我想我不用多解释什么了吧,所谓实例化函数,就是外部可以调用函数内部定义的属性和方法, 那么对应到咱们代码作用就是,定义了一个data属性 this.data 就相当于上述代码 this.add 让这个data变得可以让外部去调用

3.继续function myVue 
            ==》
         this.data = data;
            ==》
         observable(data); //将数据变的可观测
    这时候就进入到observable这个方法里面了
     
     ==》
    function observable (obj) {
        if (!obj || typeof obj !== 'object') {
            return;
        }
        let keys = Object.keys(obj);
        keys.forEach((key) =>{
            defineReactive(obj,key,obj[key])
        })
        return obj;
    }

常规操作,拿出传进来的data,然后拿出对象的key,遍历key,循环进入defineReactive方法, 并把data , data的每一个key, data的每一个值传入。

  function defineReactive (obj,key,val) {
        let dep = new Dep(); //又来一个实例化,F***K!
        Object.defineProperty(obj, key, {
            get(){
                dep.depend();
                console.log(`${key}属性被读取了`);
                return val;
            },
            set(newVal){
                val = newVal;
                console.log(`${key}属性被修改了`);
                dep.notify()                    //数据变化通知所有订阅者
            }
        })
    }

先略过Dep这个对象,Object.defineProperty这个不了解的可以看下API,说白了就是检测对象的哪个key的value变动了, 读和写分别触发 get 和 set 方法,这也是双向绑定的核心,有了监听事件,其他的就是添砖加瓦。继续

4.到了Dep这个类了,你可以理解这就是个账本,当data里的值读或者写的时候记录一下。 上面出现了let dep = new Dep(),一样,目的就是一个=>>>暴露内部属性,让defineReactive里的方法调用。

class Dep {
        
        constructor(){
            this.subs = []
        }
        //增加订阅者
        addSub(sub){
            this.subs.push(sub);
        }
        //判断是否增加订阅者
        depend () {
            // console.log(Dep.target)
            if (Dep.target) {
                //console.log(Dep.target)
                
                this.addSub(Dep.target)
                // console.log(this.subs)
            }
        }

        //通知订阅者更新
        notify(){
            this.subs.forEach((sub) =>{
                sub.update()
            })
        }
        
    }
    Dep.target = null; //第一次给个空

(1)当data某个值读的时候,判断一下Dep.target(这是什么?这个一会就会碰到),如果有值,就把Dep.target存入到内部的定义好的this.subs里面。 (2)当data某个值改变的时候,遍历存好的subs并调用每一项的update的方法。 走到这一步,会有一些不明确的,target是什么?subs存的是什么?不急我们继续=====》

5.到了这里,我们就走完这一步了observable(data);还记得吗?

  function myVue 
            ==》
         this.data = data; //把传给直接的data转为自己内部的data
            ==》
         observable(data); //这一步走完了

先梳理一下方便继续走,第一次走到这是初始化

  • 实例化myvue ,并传入要监控的对象,改变的目标DOM,要监控的key
  • 把传入的data转为自己的data属性,然后传给监控函数observable
  • 把传入的data进行监控,并且读的时候记录下属性,写的时候在dep里面进行分发去更新

那么问题来了,怎么样记录和怎么样去更新呢? 咱们继续走

6. function myVue
        ==》
         el.innerHTML = this.data[exp];           // 初始化模板数据的值
         new Watcher(this, exp, function (value) {
            el.innerHTML = value;
        })
        
  • 把data里面key的值赋给目标DOM

  • 实例化Watcher实体类,并把this(也就是myvue这个函数,注意现在传进来的data已经是myvue的一个内部属性了,用this可以调用),要监控的key,还有一个可以改变目标DOM的函数。

    到了最后一个实体类了,他接受的三个值分别转化为 vm, exp, cb,继续

class Watcher {
    constructor(vm,exp,cb){
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        this.value = this.get();  // 将自己添加到订阅器的操作
    }
    get(){
        console.log(this)
        Dep.target = this;  // 缓存自己
        let value = this.vm.data[this.exp]  // 强制执行监听器里的get函数
       
        Dep.target = null;  // 释放自己
        return value;
    }
    update(){
        let value = this.vm.data[this.exp];
        let oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value);
        }
    }
}

constructor里面其他几个值就不多说了,就是把传过来的3个值转成自己的,然后注意这里定义了一个新的值value,其他的值都是一个值,这个不同,他调了一个自己的方法 get() , 这里面注意重点!! Dep的target有值了,watcher把自己给了Dep的target,这个时候Dep的target不在是null了,然后吧data里面key的值给了value,并且触发了get方法 ,value又给了自身的变量this.value,Dep的target的值为空结束初始化。

顺序就是:

  • 现在Dep的target有了一个值,就是wacther
  • 现在watcher的get方法里的value就是 'hello word',并触发读的操作,走了监听器的get方法
  • 现在Dep的target又空了
  • 现在内部定义的this.value = ‘hello word’

走到这里就很清晰了,还记得读的get方法吗?他触发了Dep的depend方法,此刻 Dep的target有值,所以存入到自身的this.subs里面。此刻subs这个数组有值了,就一个值,就是watcher这个可访问的实体类!然后Dep的target又还原了为空! 初始化完成!!! 。。。。。。。累。。。。。。。。。。。。。。。。

7.相信能仔细看到这,并自己动动手的估计都应该明白了这个流程了,我们还是先停下,到目前为止初始化完成,其实就是完成了三件事。

  • 实例化一个myVue函数,并传参。
  • 开始监控传入的变量。
  • 通过强制调用监控的get方法,把wacther实体类存到Dep里面

8。改变变量会怎么样?剩下的流程水到渠成了,有了上几部的认知,我们继续走就豁然开朗。还是回到第一步改变我们的input

input_1.oninput = function (e) {
    myVue.data.name = e.target.value
}

这个时候改变myVue的属性就已经不是单纯的改变了。这时候会触发defineReactive里的set

set(newVal){
                val = newVal;
                console.log(`${key}属性被修改了`);
                dep.notify()                    //数据变化通知所有订阅者
            }

set 又触发了dep里的notify这个方法

//通知订阅者更新
        notify(){
            this.subs.forEach((sub) =>{
                sub.update()
            })
        }

根据上面的初始化,这时候subs有一个值,记得吗?对就是watcher这个已经被实体化的类。

//watcher的方法
  update(){
        let value = this.vm.data[this.exp];
        let oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value);
        }
    }

好了到这一步要明确vm是什么,this.value是什么?this.cb是啥?vm就是已经实体化的函数myVue,这时候myVue里定义好的data已经被外部的myVue.data.name = e.target.value所改变,所以let value = '最新的值',那么this.value是什么呢?当然还是上一次初始化存进去的this.value = 'hello word',当然对比下,如果不一样的,那么就把最新的在存进this.value里面,然后this.cb就是传进来的方法:

//再看一下
 new Watcher(this, exp, function (value) {
            el.innerHTML = value;
    });

然后call了一下,不明白call和apply的可以自行去脑补一下,就是改变了指针,脱离现在环境把this指向myVue这个实体类,然后把最新的value传进去,然后在当前环境下去改变DOM的值,当然这里你也可以不用去call也是可以的,因为内部没有用到this,但是如果后续要在里面增加逻辑,里面的this可就不是myVue会变成watcher这个实体类。 至此目标DOM得到了更新,等一下好像还漏掉一个东西,对,这个时候el.innerHTML = value;因为value现在是谁,他可不是单纯的值了,现在他是this.vm.data[this.exp],对没错,他就是被监控的this.data里面的一个值,this.data初始化说过,他已经就是传过来的参数data,他们的同指向一个堆栈 {name: 'hello world'},所以很自然被监控了,并且触发了监控里的get方法。你又被读了一遍,最后就像这样。

深入原理-写在最后的话

至此整个流程结束,还是回归一下我自己做的例子,有一个明显的区别就是,只有当我改变input的值才能去改变数据,从而触发数据绑定,但vue提供的思路是先给dom绑定上,然后改变我所定义的数据,就算dom不去触发或者其他操作都可以实现视图更新,只要我改变数据的值就能更新视图,不难发现,虽然我做的比较简单但是他很适合jq时代对dom操作时的要求,但随着业务量及前端的工作量和复杂性大大提高,操作dom变得很繁琐,所以操作数据变成的了主流,反正你的视图也是要跟着我数据去变. 最后还是希望读完会给你有所帮助,在面试中有底气有思路,而不是在面试中不知其里泛泛的回答一通,当然最最最希望的是能提供一个思路然后用到实际工作中,毛主席不是说过吗,实践是检验真理的唯一标准,共勉。