1. 程式人生 > 其它 >vue2.x-理解雙向繫結

vue2.x-理解雙向繫結

技術標籤:Vuevue

1.前言

每當被問到Vue資料雙向繫結原理的時候,大家可能都會脫口而出:Vue內部通過Object.defineProperty方法屬性攔截的方式,把data物件裡每個資料的讀寫轉化成getter/setter,當資料變化時通知檢視更新。雖然一句話把大概原理概括了,但是其內部的實現方式還是值得深究的,本文就以通俗易懂的方式剖析Vue內部雙向繫結原理的實現過程。

2.思路分析

所謂MVVM資料雙向繫結,即主要是:資料變化更新檢視,檢視變化更新資料。如下圖:
image.png

也就是說:

  • 輸入框內容變化時,data 中的資料同步變化。即 view => model 的變化。
  • data 中的資料變化時,文字節點的內容同步變化。即 model => view 的變化。

3.原理

1.vue 雙向資料繫結是通過 資料劫持 結合 釋出訂閱模式的方式來實現的, 也就是說資料和檢視同步,資料發生變化,檢視跟著變化,檢視變化,資料也隨之發生改變;

2.核心:關於VUE雙向資料繫結,其核心是 Object.defineProperty()方法;

3.介紹一下Object.defineProperty()方法
	Object.defineProperty(obj, prop, descriptor) ,這個語法內有三個引數,分別為obj(要定義其上屬性的物件),prop (要定義或修改的屬性),descriptor (具體的改變方法)
	簡單地說,就是用這個方法來定義一個值。當呼叫時我們使用了它裡面的get方法,當我們給這個屬性賦值時,又用到了它裡面的set方法

詳情可參考 MDN|Object.defineProperty()

4.具體實現

1.實現效果

先來看一下vue雙向資料繫結是如何進行的,以便我們確定好思考方向

<div id="app">
    <input type="text"  v-model="text">{{text}}
</div>
//建立一個vue例項
  var vm=new Vue({
        el:'app',
        data:{
            text:'hello world'
        }
})

2.任務拆分

拆分任務可以讓我們的思路更加清晰:
(1)將vue中的data中的內容繫結到輸入文字框和文字節點中
(2)當文字框的內容改變時,vue例項中的data也同時發生改變
(3)當data中的內容發生改變時,輸入框及文字節點的內容也發生變化

3.分佈執行任務

1.任務1-繫結data到view

我們先了解一下 DocuemntFragment(碎片化文件)這個概念,你可以把他認為一個dom節點收容器,當你創造了10個節點,當每個節點都插入到文件當中都會引發一次瀏覽器的迴流,也就是說瀏覽器要回流10次,十分消耗資源。而使用碎片化文件,也就是說我把10個節點都先放入到一個容器當中,最後我再把容器直接插入到文件就可以了!瀏覽器只回流了1次。
注意:還有一個很重要的特性是,如果使用appendChid方法將原dom樹中的節點新增到DocumentFragment中時,會刪除原來的節點。
利用DocuemntFragment的這個特性,可以通過while迴圈將需要繫結區域的內容新增到碎片化文件中

  //碎片化文件,遍歷app所有子元素,呼叫編譯函式更新model
    function nodeTofragment(node,vm) {
        let fragment=document.createDocumentFragment()
        let child
        while(child=node.firstChild){
            fragment.appendChild(child)
        }
        return fragment
    }

接下來就需要將data中的資料分別繫結到input框上和文字節點。目前閒置我們已經獲取到了div的所有子節點了,就在DocumentFragment裡面,然後對每一個節點進行處理,看是不是有跟vm例項中有關聯的內容,如果有,修改這個節點的內容。然後重新新增入DocumentFragment中。

首先,我們寫一個處理每一個節點的函式,如果有input繫結v-model屬性或者有{{ xxx }}的文字節點出現,就進行內容替換,替換為vm例項中的data中的內容。

   // 編譯函式,把data資料更新給model
    function compile(node,vm) {
        let attr = node.attributes
        if(node.nodeType===1){
            for(let i=0;i<attr.length;i++){
                if(attr[i].nodeName=='v-model'){
                    let name = attr[i].nodeValue
                    node.value=vm.data[name]
                    node.removeAttribute('v-model')
                }
            }
        }
        let reg=/\{\{(.*)\}\}/
        if(node.nodeType===3){
            if(reg.test(node.nodeValue)){
                let name = RegExp.$1
                name=name.trim()
                node.nodeValue=vm.data[name]
            }
        }
    }

然後,在向碎片化文件中新增節點時,每個節點都處理一下。

 //碎片化文件,遍歷app所有子元素,呼叫編譯函式更新model
    function nodeTofragment(node,vm) {
        let fragment=document.createDocumentFragment()
        let child
        while(child=node.firstChild){
        	//新增編譯函式
            compile(child,vm)
            fragment.appendChild(child)
        }
        return fragment
    }

建立Vue的例項化函式

  // vue建構函式
    function Vue(options) {
        let id=options.el
        this.data=options.data
        let dom = nodeTofragment(document.getElementById(id),this)
        document.getElementById(id).appendChild(dom)
    }
    var vm=new Vue({
        el:'app',
        data:{
            text:'hello world'
        }
    })

效果如下圖,已經將data中的資料繫結到節點中。

CUZTOID(R5)~1$9%0DTG0DU.png

2.任務2-監聽input變化view到model

對於此任務,我們可以通過事件監聽器keyup,input等,來獲取到最新的value,然後通過Object.defineProperty將獲取的最新的value,賦值給例項vm的text,我們把vm例項中的data下的text通過Object.defineProperty設定為訪問器屬性,這樣給vm.text賦值,就觸發了set。set函式的作用一個是更新data中的text。
首先實現一個響應式監聽屬性的函式。一旦有賦新值就發生變化。

    function defineReactive(vm,key,val) {
        Object.defineProperty(vm,key,{
            get:function () {
                return val
            },
            set:function (newVal) {
                if(newVal==val){
                    return
                }
                val=newVal
            }
        })
    }

然後,實現一個觀察者,對於一個例項 每一個屬性值都進行觀察。

  //觀察者函式
    function observe(obj) {
        for(let key of Object.keys(obj)){
            defineReactive(obj,key,obj[key])
        }
    }

改寫編譯函式,注意由於改成了訪問器屬性,只要存在複製操作就行觸發set,訪問的方法也產生變化,同時添加了事件監聽器,把例項的text值隨時更新。

  // 編譯函式,把data資料更新給model
    function compile(node,vm) {
        let attr = node.attributes
        if(node.nodeType===1){
            for(let i=0;i<attr.length;i++){
                if(attr[i].nodeName=='v-model'){
                    let name = attr[i].nodeValue
                    //增加時間監聽,將結果賦值給data
                    node.addEventListener('input',function (e) {
                        vm.data[name]=e.target.value
                    })
                    node.value=vm.data[name]
                    node.removeAttribute('v-model')
                }
            }
        }
        let reg=/\{\{(.*)\}\}/
        if(node.nodeType===3){
            if(reg.test(node.nodeValue)){
                let name = RegExp.$1
                name=name.trim()
                // node.nodeValue=vm.data[name]
                new Watcher(vm,node,name)
            }
        }
    }

然後在例項函式中,觀察data中的所有屬性值,新增observe函式。

 // vue建構函式
    function Vue(options) {
        let id=options.el
        this.data=options.data
        observe(options.data)
        let dom = nodeTofragment(document.getElementById(id),this)
        document.getElementById(id).appendChild(dom)
    }

做到這一步,列印一下結果我們發現,最終我們改變input中的內容能改變data中的資料,但是頁面上的資料卻沒有重新整理。接下來就進行下一步

3.任務3-釋出訂閱model到view

繼續上一個問題 需要我們注意,當我們修改輸入框,改變了vm例項的屬性,這是1對1的。但是,我們可能在頁面中多處用到 data中的屬性,這是1對多的。也就是說,改變1個model的值可以改變多個view中的值。
這就需要我們引入一個新的知識點:
訂閱/釋出者模式
訂閱釋出模式(又稱觀察者模式)定義了一種一對多的關係,讓多個觀察者同時監聽某一個主題物件,這個主題物件的狀態發生改變時就會通知所有觀察者物件。

下面這裡舉個簡單的例子,具體的詳情可自行尋找資料:

  //訂閱釋出者模式
    class EventEmitter {
        constructor(){
            this.listener=Object.create(null)
        }
        //事件訂閱
        on=(event,listerner)=>{
            if(!event||!listerner){
                return
            }
            if(this.listener[event]){
                //如果已經存在就存入一個新的
                this.listener[event].push(listerner)
            }else{
                //沒有就建立一個新得
                this.listener[event]=[listerner]
            }
        }
        //事件釋出
        emit=(event,...args)=>{
            if(!this.hasBind(event)){
                console.log(`沒有監聽event}`)
                return
            }
            this.listener[event].forEach(listener=>{
                listener.call(this,...args)
            })
        }
        //取消訂閱
        off=(event,listener)=>{
            if(!this.hasBind(event)){
                console.log(`沒有訂閱${event}`)
                return
            }
            if(!listener){
                delete this.listener[event]
                return
            }
            this.listener[event]=this.listener[event].filter(item=>{
                item!==listener
            })
        }
        //事件訂閱狀態
        hasBind=event=>{
            return this.listener[event]&&
                    this.listener[event].length
        }
    }
    const baseEvent = new EventEmitter()
    function cb(value){
        console.log("hello "+value)
    }
    baseEvent.on("click",cb)
    baseEvent.emit("click",'2020') //打印出“hello 2020”

上面的例子只是簡單的做個參考,理解其中的意思就行。現在繼續正文,在我們的這個實現中,我們需要在Object.defineProperty中的get中訂閱我們的事情,在set中釋出事件。在編譯 HTML 的過程中,會為每個與資料繫結相關的節點生成一個訂閱者 watcher,watcher 會將自己新增到相應屬性的 dep 容器中。
我們已經實現:修改輸入框內容 => 在事件回撥函式中修改屬性值 => 觸發屬性的 set 方法。接下來我們要實現的是:發出通知 dep.notify() => 觸發訂閱者的 update 方法 => 更新檢視。這裡的關鍵邏輯是:如何將 watcher 新增到關聯屬性的 dep 中。
注意: 我把直接賦值的操作改為了 新增一個 Watcher 訂閱者

  // 編譯函式,把data資料更新給model
    function compile(node,vm) {
        let attr = node.attributes
        if(node.nodeType===1){
            for(let i=0;i<attr.length;i++){
                if(attr[i].nodeName=='v-model'){
                    let name = attr[i].nodeValue
                    node.addEventListener('input',function (e) {
                        vm.data[name]=e.target.value
                    })
                    node.value=vm.data[name]
                    node.removeAttribute('v-model')
                }
            }
        }
        let reg=/\{\{(.*)\}\}/
        if(node.nodeType===3){
            if(reg.test(node.nodeValue)){
                let name = RegExp.$1
                name=name.trim()
                // node.nodeValue=vm.data[name]
                new Watcher(vm,node,name) //這裡建立了一個訂閱者
            }
        }
    }

然後就是寫一個訂閱者函式

 function Watcher(vm,node,name) {
        Dep.target=this
        this.vm=vm
        this.node=node
        this.name=name
        this.update()
    }
    Watcher.prototype={
        get:function () {
            this.value=this.vm.data[this.name]
        },
        update:function () {
            this.get()
            this.node.nodeValue=this.value
        }
    }

首先,將自己賦給了一個全域性變數Dep.target

其次,執行了 update 方法,進而執行了 get 方法,get 的方法讀取了 vm 的訪問器屬性,從而觸發了訪問器屬性的 get 方法,get 方法中將該 watcher 新增到了對應訪問器屬性的 dep 中;

再次,獲取屬性的值,然後更新檢視。

最後,將 Dep.target設為空。因為它是全域性變數,也是 watcher 與 dep 關聯的唯一橋樑,任何時刻都必須保證Dep.target只有一個值。

 function defineReactive(vm,key,val) {
        var dep=new Dep()
        Object.defineProperty(vm,key,{
            get:function () {
                // 在這裡進行訂閱操作
                if(Dep.target) {
                    console.log(Dep.target)
                   dep.on(Dep.target)
                }
                return val
            },
            set:function (newVal) {
                if(newVal==val){
                    return
                }
                val=newVal
                dep.emit()
                console.log('新值'+newVal)
            }
        })
    }

然後寫一個訂閱者釋出者建構函式。

function Dep() {
        this.listener=[]
    }
    Dep.prototype={
        on:function(event) {
            this.listener.push(event)
        },
        emit:function () {
            this.listener.forEach(event=>{
                event.update()
            })

        }
    }

到這裡基本就實現了vue的雙向繫結,有點繁瑣,寫的也有點複雜。在這裡進行記錄一下,下面給上全部程式碼。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app">
    <input type="text"  v-model="text">{{text}}
</div>

<script>
    function defineReactive(vm,key,val) {
        var dep=new Dep()
        Object.defineProperty(vm,key,{
            get:function () {
                if(Dep.target) {
                    console.log(Dep.target)
                   dep.on(Dep.target)
                }
                return val
            },
            set:function (newVal) {
                if(newVal==val){
                    return
                }
                val=newVal
                dep.emit()
                console.log('新值'+newVal)
            }
        })
    }
    //觀察者函式
    function observe(obj) {
        for(let key of Object.keys(obj)){
            defineReactive(obj,key,obj[key])
        }
    }
    // 編譯函式,把data資料更新給model
    function compile(node,vm) {
        let attr = node.attributes
        if(node.nodeType===1){
            for(let i=0;i<attr.length;i++){
                if(attr[i].nodeName=='v-model'){
                    let name = attr[i].nodeValue
                    node.addEventListener('input',function (e) {
                        vm.data[name]=e.target.value
                    })
                    node.value=vm.data[name]
                    node.removeAttribute('v-model')
                }
            }
        }
        let reg=/\{\{(.*)\}\}/
        if(node.nodeType===3){
            if(reg.test(node.nodeValue)){
                let name = RegExp.$1
                name=name.trim()
                // node.nodeValue=vm.data[name]
                new Watcher(vm,node,name)
            }
        }
    }
    function Dep() {
        this.listener=[]
    }
    Dep.prototype={
        on:function(event) {
            this.listener.push(event)
        },
        emit:function () {
            this.listener.forEach(event=>{
                event.update()
            })

        }
    }
    function Watcher(vm,node,name) {
        Dep.target=this
        this.vm=vm
        this.node=node
        this.name=name
        this.update()
    }
    Watcher.prototype={
        get:function () {
            this.value=this.vm.data[this.name]
        },
        update:function () {
            this.get()
            this.node.nodeValue=this.value
        }
    }
    //碎片化文件,遍歷app所有子元素,呼叫編譯函式更新model
    function nodeTofragment(node,vm) {
        let fragment=document.createDocumentFragment()
        let child
        while(child=node.firstChild){
            compile(child,vm)
            fragment.appendChild(child)
        }
        return fragment
    }
    // vue建構函式
    function Vue(options) {
        let id=options.el
        this.data=options.data
        observe(options.data)
        let dom = nodeTofragment(document.getElementById(id),this)
        document.getElementById(id).appendChild(dom)
    }
    var vm=new Vue({
        el:'app',
        data:{
            text:'hello world'
        }
    })
</script>
</body>
</html>

本文到底就結束啦~