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](/Users/james/Library/Application Support/typora-user-images/image-20220628151547662.png)

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的雙向綁定原理講清楚了,希望能幫助有緣人,😄!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章