Vue.js 資料雙向繫結的實現

語言: CN / TW / HK

highlight: androidstudio theme: cyanosis


前言

在我們使用vue的時候,當資料發生了改變,介面也會跟著更新,但這並不是理所當然的,我們修改資料的時候vue是如何監聽資料的改變以及當資料發生改變的時候vue如何讓介面重新整理的?

當我們修改資料的時候vue是通過es5中的Object.defineProperty方法來監聽資料的改變的,當資料發生了改變通過釋出訂閱模式統計訂閱者介面發生了重新整理,這是一種設計模式

下圖,從new Vue開始建立Vue例項,會傳入el和data,data會被傳入一個觀察者物件,利用Object.definproperty將data裡資料轉化成getter/setter進行資料劫持,data裡的每個屬性都會建立一個Dep例項用來儲存watcher例項

而el則傳入compile,在compile裡進行指令的解析,當解析到el中使用到data裡的資料會觸發我們的getter,從而將我們的watcher新增到依賴當中。當資料發生改變的時候會觸發我們的setter發出依賴通知,通知watcher,watcher接受到通知後去向view發出通知,讓view去更新

image.png

資料劫持

html部分建立一個id為app的div標籤,裡面有span和input標籤,span標籤使用了插值表示式,input標籤使用了v-model

```js

內容:{{content}}

```

js部分引入了一個vue.js檔案,實現資料雙向繫結的程式碼就寫在這裡面,然後建立Vue例項vm,把資料掛載到div標籤上

js const vm=new Vue({ el:'#app', data:{ content:'請輸入開機密碼' } })

new了一個Vue例項很明顯需要用到建構函式,在vue的原始碼裡定義類是使用了function來定義的,這裡我使用ES6的class來建立這個Vue例項

然後設定constructor,形參設為obj_instance,作為new一個Vue例項的時候傳入的物件,並把傳進來的物件裡的data賦值給例項裡的$data屬性

在javascript裡物件的屬性發生了變化,需要告訴我們,我們就可以把更改後的屬性更新到dom節點裡,因此初始化例項的時候定義一個監聽函式作為呼叫,呼叫的時候傳入需要監聽的資料

```js class Vue{//建立Vue例項 constructor(obj_instance){ this.$data=obj_instance.data Observer(this.$data) } } function Observer(data_instance){//監聽函式

} ```

列印一下這個例項vm

image.png

例項已經創建出來了但是還需要為$data裡的每一個屬性進行監聽,要實現資料監聽就用到了Object.definePropertyObject.defineProperty可以修改物件的現有屬性,語法格式為Object.defineProperty(obj, prop, descriptor)

  • obj:要定義屬性的物件
  • prop:要定義或修改的屬性的名稱
  • descriptor:要定義或修改的屬性描述符

監聽物件裡的每一個屬性,我們使用Object.keys和foreach遍歷物件裡的每一個屬性並且對每一個屬性使用Object.defineProperty進行資料監聽

js function Observer(data_instance){ Object.keys(data_instance).forEach(key=>{ Object.defineProperty(data_instance,key,{ enumerable:true,//設定為true表示屬性可以列舉 configurable:true,//設定為true表示屬性描述符可以被改變 get(){},//訪問該屬性的時候觸發,get和set函式就是資料監聽的核心 set(){},//修改該屬性的時候觸發 }) }) }

Object.defineProperty前需要將屬性對應的值存起來然後在get函式裡面返回出來,不然到了get函式以後屬性的值已經沒了,返回給屬性的值就變成了undefined

js let value=data_instance[key] Object.defineProperty(data_instance,key,{ enumerable:true, configurable:true, get(){ console.log(key,value); return value }, set(){} }) 點選一下$data裡的屬性名就會觸發get函式

5.gif

然後設定set函式,為set設定形參,這個形參表示新傳進來的屬性值,然後將這個新的屬性值賦值給變數value,不需要return返回什麼,只做修改,返回是在訪問get的時候返回的,修改之後get也會訪問最新的value變數值

js set(newValue){ console.log(key,value,newValue); value = newValue }

6.gif

但是當前只為$data的第一層屬性設定了get和set,如果還有更深的一層如

js obj:{ a:'a', b:'b' } 這種的並沒有設定get和set,我們需要一層一層的往屬性裡面進行資料劫持,因此使用遞迴再次監聽自己,並在遍歷之前進行條件判斷,沒有子屬性了或者沒有檢測到物件就終止遞迴

js function Observer(data_instance){ //遞迴出口 if(!data_instance || typeof data_instance != 'object') return Object.keys(data_instance).forEach(key=>{ let value=data_instance[key] Observer(value)//遞迴-子屬性的劫持 Object.defineProperty(data_instance,key,{ enumerable:true, configurable:true, get(){ console.log(key,value); return value }, set(newValue){ console.log(key,value,newValue); value = newValue } }) }) }

還有一個細節,如果我們將$data的content屬性從字串改寫成一個物件,這個新的物件並沒有get和set

1669019095973.png

因為我們在修改的時候根本沒有設定get和set,因此在set裡要呼叫監聽函式

js set(newValue){ console.log(key,value,newValue); value = newValue Observer(newValue) }

1669019254699.png

模板解析

劫持資料後就要把Vue例項裡的資料應用帶頁面上,得要加一個臨時記憶體區域,將所有資料都更新後再渲染頁面以此減少dom操作

建立一個解析函式,設定2個引數,一個是Vue例項裡掛載的元素,另一個是Vue例項,在函式裡獲取獲取元素儲存在例項了的$el裡,獲取元素後放入臨時記憶體裡,需要用到[createDocumentFragment]建立一個新的空白的文件片段

然後把$el的子節點一個一個加到fragment變數裡,頁面已經沒有內容了,內容都被臨時存在fragment裡了

js class Vue{ constructor(obj_instance){ this.$data=obj_instance.data Observer(this.$data) Compile(obj_instance.el,this) } } function Compile(ele,vm){ vm.$el=document.querySelector(ele) const fragment=document.createDocumentFragment() let child; while (child=vm.$el.firstChild){ fragment.append(child) } console.log(fragment); console.log(fragment.childNodes); }

image.png

現在直接把需要修改的內容應用到文件碎片裡面,應用後重新渲染,只需修改了fragment的childNodes子節點的文字節點,文字節點的型別是3,可以建立一個函式並呼叫來修改fragment裡的內容

節點裡面可能還會有節點,因此判定節點型別是否為3,不是就遞迴呼叫這個解析函式

節點型別為3就進行修改操作,但也不行把整個節點的文字都修改,只需修改插值表示式的內容,因此要使用正則表示式匹配,將匹配的結果儲存到變數裡,匹配的結果是一個數組,而索引為1的元素才是我們需要提取出來的元素,這個元素就是去除了{{}}和空格得到的字串,然後就可以直接用Vue例項來訪問對應屬性的值,修改完後return出去結束遞迴

```js function Compile(ele,vm){ vm.$el=document.querySelector(ele) //獲取元素儲存在例項了的$el裡 const fragment=document.createDocumentFragment() //建立文件碎片 let child; while (child=vm.$el.firstChild){//迴圈將子節點新增到文件碎片裡 fragment.append(child) }

fragment_compile(fragment) function fragment_compile(node){ //修改文字節點內容 const pattern = /{{\s(\S)\s*}}/ //檢索字串中正則表示式的匹配,用於匹配插值表示式 if(node.nodeType===3){ const result = pattern.exec(node.nodeValue) if(result){ console.log('result[1]') const value=result[1].split('.').reduce(//split將物件裡的屬性分佈在數組裡,鏈式地進行排列;reduce進行累加,層層遞進獲取$data的值 (total,current)=>total[current],vm.$data ) node.nodeValue=node.nodeValue.replace(pattern,value) //replace函式將插值表示式替換成$data裡的屬性的值 } return } node.childNodes.forEach(child=>fragment_compile(child)) } vm.$el.appendChild(fragment) //將文件碎片應用到對應的dom元素裡面 } ``` 頁面的內容又出來了,插值表示式替換成了vm例項裡的資料

image.png

image.png

訂閱釋出者模式

雖然進行了資料劫持,和將資料應用到頁面上,但是資料發生變動還不能及時更新,還需要實現訂閱釋出者模式

首先建立一個類用來收集和通知訂閱者,生成例項的時候需要有一個數組存放訂閱者的資訊,一個將訂閱者新增到這個數組裡的方法和一個通知訂閱者的方法,呼叫這個方法就回去遍歷訂閱者的陣列,讓訂閱者呼叫自身的update方法進行更新

js class Dependency{ constructor(){ this.subscribers=[] //存放訂閱者的資訊 } addSub(sub){ this.subscribers.push(sub) //將訂閱者新增到這個數組裡 } notify(){ this.subscribers.forEach(sub=>sub.update()) //遍歷訂閱者的陣列,呼叫自身的update函式進行更新 } }

設定訂閱者類,需要用到Vue例項上的屬性,需要Vue例項和Vue例項對應的屬性和一個回撥函式作為引數,將引數都賦值給例項

然後就可以建立訂閱者的update函式,在函式裡呼叫傳進來的回撥函式

js class Watcher{ constructor(vm,key,callback){//將引數都賦值給Watcher例項 this.vm=vm this.key=key this.callback=callback } update(){ this.callback() } }

替換文件碎片內容的時候需要告訴訂閱者如何更新,所以訂閱者例項在模板解析把節點值替換內容的時候建立,傳入vm例項,exec匹配成功後的索引值1和回撥函式,將替換文字的執行語句複製到回撥函式裡,通知訂閱者更新的時候就呼叫這個回撥函式

回撥函式裡的nodeValue要提前儲存,不然替換的內容就不是插值表示式而是替換過的內容

1669085039395.png

然後就要想辦法將訂閱者儲存到Dependency例項的數組裡,我們可以在構造Watcher例項的時候儲存例項到訂閱者數組裡

Dependency.temp=this //設定一個臨時屬性temp

將新的訂閱者新增到訂閱者數組裡且還要將所有的訂閱者都進行同樣的操作,那麼就可以在觸發get的時候將訂閱者新增到訂閱者數組裡,為了正確觸發對應的屬性get,需要用reduce方法對key進行同樣的操作

32022edb035588799072d6ce8c9cf04.png

b6b1eb7db9110d36607b58ebcef28e4.png

可以看到控制檯打印出了Wathcer例項,每個例項都不同,都對應不同的屬性值

image.png

Dependency類還沒建立例項,裡面的訂閱者陣列是不存在的,所以要先建立例項再將訂閱者新增到訂閱者數組裡

1669086610689.png

1669086716726.png

修改資料的時候通知訂閱者來進行更新,在set裡呼叫dependency的通知方法,通知方法就會去遍陣列,訂閱者執行自己的update方法進行資料更新

1669087112449.png

但是update呼叫回撥函式缺少設定形參,依舊使用split和reduce方法獲取屬性值

js update(){ const value =this.key.split('.').reduce( (total,current)=>total[current],this.vm.$data ) this.callback(value) }

在控制檯修改屬性值都修改成功了,頁面也自動更新了

6.gif

完成了文字的繫結就可以繫結輸入框了,在vue裡通過v-model進行繫結,因此要判斷哪個節點有v-model,元素節點的型別是1,可以使用nodeName來匹配input元素,直接在判斷文字節點下面進行新的判斷

js if(node.nodeType===1&&node.nodeName==='INPUT'){ const attr=Array.from(node.attributes) console.log(attr); } 節點名字nodeName為v-model,nodeValue為name,就是資料裡的屬性名

1669087923831.png

因此對這個陣列進行遍歷,匹配到了v-model根據nodeValue找到對應的屬性值,把屬性值賦值到節點上,同時為了在資料更新後訂閱者知道更新自己,也要在INPUT節點裡新增Watcher例項

js attr.forEach(i=>{ if(i.nodeName==='v-model'){ const value=i.nodeValue.split('.').reduce( (total,current)=>total[current],vm.$data ) node.value=value new Watcher(vm,i.nodeValue,newValue=>{ node.value=newValue }) } })

修改屬性值,頁面也作出修改 7.gif

最後剩下用檢視改變資料,在v-model的節點上使用addEventListener增加input監聽事件就行了

js node.addEventListener('input',e=>{ const arr1=i.nodeValue.split('.') const arr2=arr1.slice(0,arr1.length - 1) const final=arr2.reduce( (total,current)=>total[current],vm.$data ) final[arr1[arr1.length - 1]]=e.target.value })

7.gif

本文正在參加「金石計劃 . 瓜分6萬現金大獎」