0

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

 1 year ago
source link: https://www.cnblogs.com/lisong/p/16442185.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 = '[email protected]';
  o.email;
  console.log(o);
  o.email = '[email protected]';
  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的双向绑定原理讲清楚了,希望能帮助有缘人,😄!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK