• vue2.0 双向绑定原理分析及简单实现


    Vue用了有一段时间了,每当有人问到Vue双向绑定是怎么回事的时候,总是不能给大家解释的很清楚,正好最近有时间把它梳理一下,让自己理解的更清楚,下次有人问我的时候,可以侃侃而谈😄。

    一、首先介绍Object.defineProperty()方法

    //直接在一个对象上定义一个新属性,或者修改一个已经存在的属性, 并返回这个对象
    Object.defineProperty(obj,prop,descriptor)
    

    参数

    • obj 需要定义属性的对象。
    • prop 需被定义或修改的属性名。
    • descriptor 需被定义或修改的属性的描述符。

    1.1 属性描述符默认值

    属性 默认值 说明
    configurable false 描述属性是否可以被删除,默认为 false
    enumerable false 描述属性是否可以被for...in或Object.keys枚举,默认为 false
    writable false 描述属性是否可以修改,默认为 false
    get undefined 当访问属性时触发该方法,默认为undefined
    set undefined 当属性被修改时触发该方法,默认为undefined
    value undefined 属性值,默认为undefined
    // Object.defineProperty(对象,属性,属性描述符)
        var obj={}
        console.log('obj:',obj);
    
        Object.defineProperty(obj, 'name', {
            value: 'James'
        });
    
        console.log('obj的默认值:',obj);
        delete obj.name;
        console.log('obj删除后:', obj);
        console.log('obj枚举:', Object.keys(obj));
        obj.name = '库里';
        console.log('obj修改后:', obj);
        Object.defineProperty(obj, 'name', {value: '库里'});
    

    运行结果:

    image-20220625163934226

    从运行结果可以发现,使用Object.defineProperty()定义的属性,默认是不可以被修改,不可以被枚举,不可以被删除的。可以与常规的方式定义属性对比一下:如果不使用Object.defineProperty()定义的属性,默认是可以修改、枚举、删除的:

     const obj = {};
     obj.name = 'James';
     console.log('枚举:', Object.keys(obj));
     obj.name = ' 库里';
     console.log('修改:', obj);
     delete obj.name;
     console.log('删除:', obj);
    

    运行结果:

    image-20220625164719361

    1.2 修改属性描述符

    const o = {};
      Object.defineProperty(o, 'name', {
        value: 'James',        // name属性值
        writable: true,       // 可以被修改
        enumerable: true,     // 可以被枚举
        configurable: true,   // 可以被删除
      });
      console.log(o);
      console.log('枚举:', Object.keys(o));
      o.name = '科比';
      console.log('修改:', o);
      Object.defineProperty(o, 'name', {
        value: 'Po'
      });
      console.log('修改:', o);
      delete o.name;
      console.log('删除:', o);
    

    运行结果:

    image-20220628145317071

    结果表明,修改writable、enumerable、configurable这三个描述符为true时,属性可以被修改、枚举和删除。

    注意:

    1、如果writable为false,configurable为true时,通过o.name = "科比"是无法修改成功的,但是使用Object.defineProperty()修改是可以成功的

    2、如果writable和configurable都为false时,如果使用Object.defineProperty()修改属性值会报错:Cannot redefine property: name

    1.3 enumerable

    const o = {};
    Object.defineProperty(o, 'name', { value: 'James', enumerable: true });
    Object.defineProperty(o, 'contact', { value: (str) => { return str+' baby' }, enumerable: false });
    Object.defineProperty(o, 'age', { value: '18' });
    o.skill = '前端';
    console.log('枚举:', Object.keys(o));
    console.log('trim: ', o.contact('nihao'))
    console.log(`o.propertyIsEnumerable('name'): `, o.propertyIsEnumerable('name'));
    console.log(`o.propertyIsEnumerable('contact'): `, o.propertyIsEnumerable('contact'));
    console.log(`o.propertyIsEnumerable('age'): `, o.propertyIsEnumerable('age'));
    

    运行结果:

    image-20220628151547662

    1.4 get和set

    注:设置set或者get,就不能在设置value和wriable,否则会报错

    const o = {
        __email: ''
      };
      Object.defineProperty(o, 'email', {
        enumerable: true,
        configurable: true,
        // writable: true,    // 如果设置了get或者set,writable和value属性必须注释掉
        // value: '',         // writable和value无法与set和get共存
        get: function () {    // 如果设置了get 或者 set 就不能设置writable和value
          console.log('get', this);
          return 'My email is ' + this.__email;
        },
        set: function (newVal) {
          console.log('set', newVal);
          this.__email = newVal;
        }
      });
      console.log(o);
      o.email = 'laowang@163.com';
      o.email;
      console.log(o);
      o.email = 'laozhang@163.com';
      console.log(o);
    

    运行结果:

    image-20220628153012733

    二、原理分析

    2.1 最简单的双向绑定

    <!DOCTYPE html>
    <head>
        <title>最简单的双向绑定</title>
    </head>
    <body>
        <div>
            <input type="text" name="name" id="name" />
        </div>
    </body>
    <script>
        var data={
            __name:''
        };
    
        Object.defineProperty(data,'name',{
            enumerable: true,
            configurable: true,
            // writable: true,    // 如果设置了get或者set,writable和value属性必须注释掉
            // value: '',         // writable和value无法与set和get共存
            get: function () {    // 如果设置了get 或者 set 就不能设置writable和value
                return this.__name;
            },
            set: function (newVal) {
                this.__name=newVal;                                //更新属性
                document.querySelector('#name').value = newVal;    //更新视图
            }
        });
    		
      	//监听input事件,更新name
        document.querySelector('#name').addEventListener("input",(event)=>{
            data.name=event.currentTarget.value
        })
    
    </script>
    </html>
    
    折叠

    运行结果:

    image-20220628164512054

    文本框输入"老王",查看name属性变为"老王";修改name属性为"老张",文本框变为“老张”;

    最简单的双向绑定完成了😊

    2.2 Vue双向绑定

    vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。读完这句话是不是还有50%的懵逼,接下来继续分析。

    双向绑定的经典示例图,各位细品:

    image-20220628165953973

    分析每个模块的作用:

    Observer:数据监听器,对每个vue的data中定义的属性循环用Object.defineProperty()实现数据劫持,以便利用其中的setter和getter,然后通知订阅者,订阅者会触发它的update方法,对视图进行更新

    Compile:指令解析器,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数

    Watcher:作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图

    Dep:依赖收集,每个属性都有一个依赖收集对象,存储订阅该属性的Watcher

    Updater:更新视图

    结合原理,自定义实现Vue的双向绑定

    1、首先创建index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>2.0双向绑定原理</title>
      <script src="./Dep.js"></script>
      <script src="./MYVM.js"></script>
      <script src="./Observer.js"></script>
      <script src="./Watcher.js"></script>
      <script src="./TemplateCompiler.js"></script>
    </head>
    
    <body>
      <div id="app">
        <!--模拟vue指令绑定name属性 -->
        <span v-text="name"></span>
        <!--模拟vue指令v-model双向绑定 -->
        <input type="text" v-model="name">
        <!-- 模拟{{}} -->
        {{name}}
      </div>
      <script>
        //假设已经有MYVM对象,实例化该对象
        //params是一个对象 el是要挂载的dom  data是一个对象包含响应式属性
        var vm = new MYVM({
          el: '#app',
          data: {
            name: 'James'
          }
        })
      </script>
    </body>
    </html>
    
    折叠

    2、创建MYVM.js,主要作用是调用Observer进行数据劫持和调用TemplateCompiler进行模板解析

    function MYVM(options){
         //属性初始化
         this.$vm=this;
         this.$el=options.el;
         this.$data=options.data;
         
         //视图必须存在
         if(this.$el){
            //添加属性观察对象(实现数据挟持)
            new Observer(this.$data)
            //创建模板编译器,来解析视图
            this.$compiler = new TemplateCompiler(this.$el, this.$vm)
        }
    
    }
    

    3、创建Observer.js,实现数据劫持

    //数据解析,完成对数据属性的劫持
    function Observer(data){
        //判断data是否有效且data必须是对象
        if(!data || typeof data !=='object' ){
            return
        }else{
            var keys=Object.keys(data)
            keys.forEach((key)=>{
                this.defineReactive(data,key,data[key])
            })
        }
    }
    Observer.prototype.defineReactive=function(obj,key,val){
        Object.defineProperty(obj,key,{
            //是否可遍历
            enumerable: true,
            //是否可删除
            configurable: false,
    
            //取值
            get(){
                return val
            },
            //修改值
            set(newVal){
                val=newVal
            }
        })
    }
    
    折叠

    上面代码完成了数据属性的劫持,读取和修改属性会执行get、set,运行结果:

    image-20220629133722605

    4、给Observer.js增加订阅和发布功能,新建Dep.js,进行订阅和发布管理

    //创建订阅发布者
    //1.管理订阅
    //2.集体通知
    function Dep(){
        this.subs=[];
    }
    
    //添加订阅
    //参数sub是watcher对象
    Dep.prototype.addSub=(sub)=>{
        this.subs.push(sub)
    }
    
    //集体通知,更新视图
    Dep.prototype.notify=()=>{
        this.subs.forEach((sub) => {
            sub.update()
          })
    }
    

    5、把Dep安装到Observer.js,代码如下

    //数据解析,完成对数据属性的劫持
    function Observer(data){
        //判断data是否有效且data必须是对象
        if(!data || typeof data !=='object' ){
            return
        }else{
            var keys=Object.keys(data)
            keys.forEach((key)=>{
                this.defineReactive(data,key,data[key])
            })
        }
    }
    Observer.prototype.defineReactive=function(obj,key,val){
        //创建Dep实例
        var dep=new Dep();
        Object.defineProperty(obj,key,{
            //是否可遍历
            enumerable: true,
            //是否可删除
            configurable: false,
    
            //取值
            get(){
                //watcher创建时,完成订阅
                //检查target是否有watcher,有的话进行订阅
                var watcher = Dep.target;
                watcher && dep.addSub(watcher)
                return val
            },
            //修改值
            set(newVal){
                val=newVal
                dep.notify()
            }
        })
    }
    
    折叠

    var dep=new Dep() 创建了Dep的实例

    get的时候检查是否有watcher,有就添加到订阅数组

    set的时候通知所有的订阅者,进行视图更新

    至此属性数据劫持,订阅和发布就已经实现完了

    6、接下来实现模板编译器,首先创建TemplateCompiler.js

    // 创建模板编译工具
    // el 要编译的dom节点
    // vm MYVM的当前实例
    function TemplateCompiler(el,vm){
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        if (this.el) {
            //将对应范围的html放入内存fragment
            var fragment = this.node2Fragment(this.el)
            //编译模板
            this.compile(fragment)
            //将数据放回页面
            this.el.appendChild(fragment)
          }
    }
    
    //是否是元素节点
    TemplateCompiler.prototype.isElementNode=function(node){
        return node.nodeType===1
    }
    
    //是否是文本节点
    TemplateCompiler.prototype.isTextNode=function(node){
        return node.nodeType===3
    }
    
    //转成数组
    TemplateCompiler.prototype.toArray=function(arr){
        return [].slice.call(arr)
    }
    
    //判断是否是指令属性
    TemplateCompiler.prototype.isDirective=function(directiveName){
        return directiveName.indexOf('v-') >= 0;
    }
    
    //读取dom到内存
    TemplateCompiler.prototype.node2Fragment=function(node){
        var fragment=document.createDocumentFragment();
        var child;
        //while(child=node.firstChild)这行代码,每次运行会把firstChild从node中取出,指导取出来是null就终止循环
        while(child=node.firstChild){
            fragment.appendChild(child)
        }
        return fragment;
    }
    
    //编译模板
    TemplateCompiler.prototype.compile=function(fragment){
        var childNodes = fragment.childNodes;
        var arr = this.toArray(childNodes);
        arr.forEach(node => {
            //判断是否是元素节点
            if(this.isElementNode(node)){
                this.compileElement(node);
            }else{
                //定义文本表达式验证规则
                var textReg = /\{\{(.+)\}\}/;
                var expr = node.textContent;
                if (textReg.test(expr)) {
                    //获取绑定的属性
                    expr = RegExp.$1;
                    //调用方法编译
                    this.compileText(node, expr)
                }
            }
        });
    }
    
    //解析元素节点
    TemplateCompiler.prototype.compileElement=function(node){
        //获取节点所有属性
        var arrs=node.attributes;
        this.toArray(arrs).forEach(attr => {
            //获取属性名称
            var attrName=attr.name;
            if(this.isDirective(attrName)){
                //获取v-modal的modal
                var type = attrName.split('-')[1]
                //获取属性对应的值(绑定的属性)
                var expr = attr.value;
                CompilerUtils[type] && CompilerUtils[type](node, this.vm, expr)
            }  
        });
    }
    
     //解析文本节点
     TemplateCompiler.prototype.compileText=function(node,expr){
        CompilerUtils.text(node, this.vm, expr)
    }
    
    折叠

    TemplateCompiler的主要逻辑:

    a、dom节点读入到内存

    b、遍历所有节点,判断节点类型,元素节点和文本节点分别使用不同方法编译

    c、元素节点编译,遍历所有属性,根据指令名称称找到CompilerUtils对应的指令处理方法,执行视图初始化和订阅

    d、文本节点编译,正则匹配找到绑定的属性,使用CompilerUtils的text执行初始化和订阅

    7、创建CompilerUtils编辑工具对象,实现视图初始化和订阅

    //编译工具
    CompilerUtils = {
      	//对应视图v-modal指令,使用该方法进行视图初始化和订阅
        //params node当前节点  vm myvm对象   expr绑定的属性
        //modal方法执行一次,进行视图初始化、事件订阅,添加视图到模型的事件
        model(node, vm, expr) {
          	//节点更新方法
            var updateFn = this.updater.modelUpdater;
            //初始化,更新node的值
            updateFn && updateFn(node, vm.$data[expr])
    
            //实例化一个订阅者,添加到订阅数组
            new Watcher(vm, expr, (newValue) => {
                 //发布的时候,按照之前的规则,对节点进行更新
                 updateFn && updateFn(node, newValue)
            })
    
            //视图到模型(观察者模式)
            node.addEventListener('input', (e) => {
                //获取新值放到模型
                var newValue = e.target.value;
                vm.$data[expr] = newValue;
            })
        },
    
        //对应视图v-text指令,使用该方法进行视图初始化和订阅
        //params node当前节点  vm myvm对象   expr绑定的属性
        //text方法执行一次,进行视图初始化、事件订阅
        text(node, vm, expr) {
            //text更新方法
            var updateFn = this.updater.textUpdater;
           //初始化,更新text的值
            updateFn && updateFn(node, vm.$data[expr])
    
            //实例化一个订阅者,添加到订阅数组
            new Watcher(vm, expr, (newValue) => {
              //发布的时候,按照之前的规则,对文本节点进行更新
              updateFn && updateFn(node, newValue)
            })
        },
    
        updater: {
            //v-text数据更新
            textUpdater(node, value) {
              node.textContent = value;
            },
            //v-model数据更新
            modelUpdater(node, value) {
              node.value = value;
            }
        }
    }
    
    折叠

    CompilerUtils的主要逻辑:

    a、根据指令对节点进行数据初始化,实例化观察者Watcher到订阅数组

    b、不同的指令进行不同的逻辑处理

    8、创建Watcher.js,实现订阅者逻辑

    //声明一个订阅者
    //vm 全局vm对象
    //expr 属性名称
    //cb 发布时需要执行的方法
    function Watcher(vm, expr, cb) {
        //缓存重要属性
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
    
        //缓存当前值,为更新时做对比
        this.value = this.get()
      }
      Watcher.prototype.get=function(){
        //设置全局Dep的target为当前订阅者
        Dep.target = this;
        //获取属性的当前值,获取时会执行属性的get方法,get方法会判断target是否为空,不为空就添加订阅者
        var value = this.vm.$data[this.expr]
        //清空全局
        Dep.target = null;
        return value;
      }
      Watcher.prototype.update=function(){
        //获取新值
        var newValue = this.vm.$data[this.expr]
        //获取老值
        var old = this.value;
    
        //判断后
        if (newValue !== old) {
          //执行回调
          this.cb(newValue)
        }
      }
    
    折叠

    Watcher的主要逻辑:

    a、get 把当前订阅者添加到属性对应的依赖数组,保存值

    b、update 发布的时候执行,进行新老值对比,更新节点内容

    到此一个简单的MVVM框架就完成了,整体运行效果如下:

    iShot_2022-06-30_17.20.49

    梳理过程中参考很多大佬文章,感谢各位。看完基本能把VUE2.0的双向绑定原理讲清楚了,希望能帮助有缘人,😄!

  • 相关阅读:
    网络架构新升级:400G OSFP光模块系列产品概述
    高光时刻丨赛宁网安携前沿技术研究亮相Blackhat 2022
    WinServer 2019 AD 组策略 禁止U盘访问
    Mac安装nginx(Homebrew)
    docker安装gitlab
    Spring Boot 多环境配置
    邻接表的链表实现——链式前向星
    vue3初尝试
    java的反射
    [11]重绘与回流
  • 原文地址:https://www.cnblogs.com/lisong/p/16442185.html