手把手教你怎麼實現一個vue雙向繫結

2022-03-03 22:00:12
vue怎麼實現雙向繫結?本篇文章就手把手教你寫一個的雙向繫結,讓大家可以更好的理解雙向繫結的邏輯走向,希望對大家有所幫助!

本文主要是一個寫坑和填坑的過程,讓觀眾更好的理解如何去實現雙向繫結,雙向繫結的邏輯走向,至此一步步來重頭實現一個雙向繫結。這是一個類教學文章,只要跟著文章走,仔細觀察每個類其實也並不難實現。

開始

開局法師?帶一條狗!!

好像不對

重來,開局一張圖

1.png

從圖上可以看出new Vue()分為了兩步走

  • 代理監聽所有資料,並與Dep進行關聯,通過Dep通知訂閱者進行檢視更新。【相關推薦:】

  • 解析所有模板,並將模板中所用到的資料進行訂閱,並繫結一個更新函數,資料發生改變時Dep通知訂閱者執行更新函數。

接下里就是分析如何去實現,並且都需要寫什麼,先看一段vue的基礎程式碼,我們從頭開始分析

<div id="app">
  <input v-model="message" />
  <p>{{message}}</p>
</div>
let app = new Vue({
    el:"#app",
    data:{
      message:"測試這是一個內容"
    }
})

從上面程式碼我們可以看到new Vue的操作,裡面攜帶了eldata屬性,這算是最基礎的屬性,而在html程式碼中我們知道<div id="app">是vue渲染的模板根節點,所以vue要渲染頁面就要去實現一個模板解析的方法Compile類,解析方法中還需要去處理{{ }}v-model兩個指令,除了解析模板之後我們還需要去實現資料代理也就是實現Observer

實現 Vue 類

如下程式碼所示,這就寫完了Vue類,夠簡單吧,如果對class關鍵字不熟悉的,建議先去學習一下,從下面我們可能看到,這裡範例化了兩個類,一個是代理資料的類,一個是解析模板的類。

class Vue {
  constructor(options) {
    // 代理資料
    new Observer(options.data)
    // 繫結資料
    this.data = options.data
    // 解析模板
    new Compile(options.el, this)
  }
}

接著往下我們先寫一個Compile類用於解析模板,我們再來分析一波,解析模板要做什麼事

  • 我們要解析模板不可能直接對dom繼續操作,所以我們要建立一個檔案片段(虛擬dom),然後將模板DOM節點複製一份到虛擬DOM節點中,對虛擬DOM節點解析完成之後,再將虛擬DOM節點替換掉原來的DOM節點

  • 虛擬節點複製出來之後,我們要遍歷整個節點樹進行解析,解析過程中會對DOM的atrr屬性進行遍歷找到Vue相關的指令,除此之外還要對 textContent節點內容進行解析,判斷是否存在雙花括號

  • 將解析出來所用到的屬性進行一個訂閱

實現模板解析 Compile 類

下面我們將逐步實現

  • 構建Compile類,先把靜態節點和Vue範例獲取出來,再定義一個虛擬dom的屬性用來儲存虛擬dom

class Compile {
  constructor(el, vm) {
    // 獲取靜態節點
    this.el = document.querySelector(el);
    // vue範例
    this.vm = vm 
    // 虛擬dom
    this.fragment = null 
    // 初始化方法
    this.init()
  }
}
  • 實現初始化方法init(),該方法主要是用於建立虛擬dom和呼叫解析模板的方法,解析完成之後再將DOM節點替換到頁面中

class Compile { 
  //...省略其他程式碼

  init() {
    // 建立一個新的空白的檔案片段(虛擬dom)
    this.fragment = document.createDocumentFragment()
  	// 遍歷所有子節點加入到虛擬dom中
    Array.from(this.el.children).forEach(child => {
      this.fragment.appendChild(child)
    })
    // 解析模板
    this.parseTemplate(this.fragment)
    // 解析完成新增到頁面
    this.el.appendChild(this.fragment);
  }
}
  • 實現解析模板方法parseTemplate,主要是遍歷虛擬DOM中的所有子節點並進行解析,根據子節點型別進行不同的處理。

class Compile { 
  //...省略其他程式碼

  // 解析模板 
  parseTemplate(fragment) {
    // 獲取虛擬DOM的子節點
    let childNodes = fragment.childNodes || []
    // 遍歷節點
    childNodes.forEach((node) => {
      // 匹配大括號正規表示式 
      var reg = /\{\{(.*)\}\}/;
      // 獲取節點文字
      var text = node.textContent;
      if (this.isElementNode(node)) { // 判斷是否是html元素
        // 解析html元素
        this.parseHtml(node)
      } else if (this.isTextNode(node) && reg.test(text)) { //判斷是否文位元組點並帶有雙花括號
        // 解析文字
        this.parseText(node, reg.exec(text)[1])
      }

      // 遞迴解析,如果還有子元素則繼續解析
      if (node.childNodes && node.childNodes.length != 0) {
        this.parseTemplate(node)
      }
    });
  }
}
  • 根據上面的程式碼我們得出需要實現兩個簡單的判斷,也就是判斷是否是html元素和文字元素,這裡通過獲取nodeType的值來進行區分,不瞭解的可以直接看一下 傳送門:Node.nodeType,這裡還擴充套件了一個isVueTag方法,用於後面的程式碼中使用

class Compile { 
  //...省略其他程式碼

	// 判斷是否攜帶 v-
  isVueTag(attrName) {
    return attrName.indexOf("v-") == 0
  }
  // 判斷是否是html元素
  isElementNode(node) {
    return node.nodeType == 1;
  }
  // 判斷是否是文字元素
  isTextNode(node) {
    return node.nodeType == 3;
  }
}
  • 實現parseHtml方法,解析html程式碼主要是遍歷html元素上的attr屬性

class Compile {
  //...省略其他程式碼

  // 解析html
  parseHtml(node) {
    // 獲取元素屬性集合
    let nodeAttrs = node.attributes || []
    // 元素屬性集合不是陣列,所以這裡要轉成陣列之後再遍歷
    Array.from(nodeAttrs).forEach((attr) => {
      // 獲取屬性名稱
      let arrtName = attr.name;
      // 判斷名稱是否帶有 v- 
      if (this.isVueTag(arrtName)) {
        // 獲取屬性值
        let exp = attr.value;
        //切割 v- 之後的字串
        let tag = arrtName.substring(2);
        if (tag == "model") {
          // v-model 指令處理方法
          this.modelCommand(node, exp, tag)
        }
      }
    });
  }
}
  • 實現modelCommand方法,在模板解析階段來說,我們只要把 vue範例中data的值繫結到元素上,並實現監聽input方法更新資料即可。

class Compile {
	//...省略其他程式碼
  
   // 處理model指令
  modelCommand(node, exp) {
    // 獲取資料
    let val = this.vm.data[exp]
    // 解析時繫結資料
    node.value = val || ""

    // 監聽input事件
    node.addEventListener("input", (event) => {
      let newVlaue = event.target.value;
      if (val != newVlaue) {
        // 更新data資料
        this.vm.data[exp] = newVlaue
        // 更新閉包資料,避免雙向繫結失效
        val = newVlaue
      }
    })
  }
}
  • 處理Text元素就相對簡單了,主要是將元素中的textContent內容替換成資料即可

class Compile {
	//...省略其他程式碼
  
  //解析文字
  parseText(node, exp) {
    let val = this.vm.data[exp]
    // 解析更新文字
    node.textContent = val || ""
  }
}

至此已經完成了Compile類的初步編寫,測試結果如下,已經能夠正常解析模板

2.png

下面就是我們目前所實現的流程圖部分

3.png

坑點一:

  • 在第6點modelCommand方法中並沒有實現雙向繫結,只是單向繫結,後續要雙向繫結時還需要繼續處理

坑點二:

  • 第7點parseText方法上面的程式碼中並沒有去訂閱資料的改變,所以這裡只會在模板解析時繫結一次資料

實現資料代理 Observer 類

這裡主要是用於代理data中的所有資料,這裡會用到一個Object.defineProperty方法,如果不瞭解這個方法的先去看一下檔案傳送門:

檔案:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

Observer類主要是一個遞迴遍歷所有data中的屬性然後進行資料代理的的一個方法

defineReactive中傳入三個引數data, key, val

datakey都是Object.defineProperty的引數,而val將其作為一個閉包變數供Object.defineProperty使用

// 監聽者
class Observer {
  constructor(data) {
    this.observe(data)
  }
  // 遞迴方法
  observe(data) {
    //判斷資料如果為空並且不是object型別則返回空字串
    if (!data || typeof data != "object") {
      return ""
    } else {
      //遍歷data進行資料代理
      Object.keys(data).forEach(key => {
        this.defineReactive(data, key, data[key])
      })
    }
  }

  // 代理方法
  defineReactive(data, key, val) {
    // 遞迴子屬性
    this.observe(data[key])
    Object.defineProperty(data, key, {
      configurable: true,  //可設定的屬性
      enumerable: true, //可遍歷的屬性
      get() {
        return val
      },
      set(newValue) {
        val = newValue
      }
    })
  }
}

下面我們來測試一下是否成功實現了資料代理,在Vue的建構函式輸出一下資料

class Vue {
  constructor(options) {
    // 代理資料
    new Observer(options.data)
    console.log(options.data)
    // 繫結資料
    this.data = options.data
    // 解析模板
    new Compile(options.el, this)
  }
}

結果如下,我們可以看出已經實現了資料代理。

4.png

對應的流程圖如下所示

5.png

坑點三:

  • 這裡雖然實現了資料代理,但是按照圖上來說,還需要引入管理器,在資料發生變化時通知管理器資料發生了變化,然後管理器再通知訂閱者更新檢視,這個會在後續的填坑過程過講到。

實現管理器 Dep 類

上面我們已經實現了模板解析到初始化檢視,還有資料代理。而下面要實現的Dep類主要是用於管理訂閱者和通知訂閱者,這裡會用一個陣列來記錄每個訂閱者,而類中也會給出一個notify方法去呼叫訂閱者的update方法,實現通知訂閱者更新功能。這裡還定義了一個target屬性用來儲存臨時的訂閱者,用於加入管理器時使用。

class Dep {
  constructor() {
    // 記錄訂閱者
    this.subList = []
  }
  // 新增訂閱者
  addSub(sub) {
    // 先判斷是否存在,防止重複新增訂閱者
    if (this.subList.indexOf(sub) == -1) {
      this.subList.push(sub)
    }
  }
  // 通知訂閱者
  notify() {
    this.subList.forEach(item => {
      item.update() //訂閱者執行更新,這裡的item就是一個訂閱者,update就是訂閱者提供的方法
    })
  }
}
// Dep全域性屬性,用來臨時儲存訂閱者
Dep.target = null

管理器實現完成之後我們也就實現了流程圖中的以下部分。要注意下面幾點

  • Observer通知Dep主要是通過呼叫notify方法
  • Dep通知Watcher主要是是呼叫了Watcher類中的update方法

6.png


實現訂閱者 Watcher 類

訂閱者程式碼相對少,但是理解起來還是有點難度的,在Watcher類中實現了兩個方法,一個是update更新檢視方法,一個putIn方法(我看了好幾篇文章都是定義成 get 方法,可能是因為我理解的不夠好吧)。

  • update:主要是呼叫傳入的cb方法體,用於更新頁面資料
  • putIn:主要是用來手動加入到Dep管理器中。
// 訂閱者
class Watcher {
  // vm:vue範例本身
  // exp:代理資料的屬性名稱
  // cb:更新時需要做的事情
  constructor(vm, exp, cb) {
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.putIn()
  }
  update() {
    // 呼叫cb方法體,改變this指向並傳入最新的資料作為引數
    this.cb.call(this.vm, this.vm.data[this.exp])
  }
  putIn() {
    // 把訂閱者本身繫結到Dep的target全域性屬性上
    Dep.target = this
    // 呼叫獲取資料的方法將訂閱者加入到管理器中
    let val = this.vm.data[this.exp]
    // 清空全域性屬性
    Dep.target = null
  }
}

坑點四:

  • Watcher類中的putIn方法再建構函式呼叫後並沒有加入到管理器中,而是將訂閱者本身繫結到target全域性屬性上而已

埋坑

通過上面的程式碼我們已經完成了每一個類的構建,如下圖所示,但是還是有幾個流程是有問題的,也就是上面的坑點。所以下面要填坑

7.png

埋坑 1 和 2

完成坑點一和坑點二,在modelCommandparseText方法中增加範例化訂閱者程式碼,並自定義要更新時執行的方法,其實就是更新時去更新頁面中的值即可

modelCommand(node, exp) {
  
  // ...省略其他程式碼
  
  // 範例化訂閱者,更新時直接更新node的值
  new Watcher(this.vm, exp, (value) => {
    node.value = value
  })
}


parseText(node, exp) {
  
  //  ...省略其他程式碼
  
  // 範例化訂閱者,更新時直接更新文字內容
  new Watcher(this.vm, exp, (value) => {
    node.textContent = value
  })
}

埋坑 3

完成坑點三,主要是為了引入管理器,通知管理器發生改變,主要是在Object.defineProperty set方法中呼叫dep.notify()方法

// 監聽方法
defineReactive(data, key, val) {
  // 範例化管理器--------------增加這一行
  let dep = new Dep()
  
  // ...省略其他程式碼
  
    set(newValue) {
      val = newValue
      // 通知管理器改變--------------增加這一行
      dep.notify()
    }

}

埋坑 4

完成坑點四,主要四將訂閱者加入到管理器中

defineReactive(data, key, val) {
  // ...省略其他程式碼
    get() {
      // 將訂閱者加入到管理器中--------------增加這一段
      if (Dep.target) {
        dep.addSub(Dep.target)
      }
      return val
    },
  // ...省略其他程式碼
}

完成了坑點四可能就會有靚仔疑惑了,這裡是怎麼加入的呢Dep.target又是什麼呢,我們不妨從頭看看程式碼並結合下面這張圖

8.png

至此我們已經實現了一個簡單的雙向繫結,下面測試一下

9.gif

完結撒花

總結

本文解釋的並不多,所以才是類教學文章,如果讀者有不懂的地方可以在評論去留言討論

10.png

(學習視訊分享:、)

以上就是手把手教你怎麼實現一個vue雙向繫結的詳細內容,更多請關注TW511.COM其它相關文章!