1. 前言
每當被問到Vue數據雙向綁定原理的時候,大家可能都會脫口而出:Vue內部通過Object.defineProperty
方法屬性攔截的方式,把data
對象里每個數據的讀寫轉化成getter
/setter
,當數據變化時通知視圖更新。雖然一句話把大概原理概括了,但是其內部的實現方式還是值得深究的,本文就以通俗易懂的方式剖析Vue內部雙向綁定原理的實現過程。
2. 思路分析
所謂MVVM數據雙向綁定,即主要是:數據變化更新視圖,視圖變化更新數據。如下圖:
也就是說:
- 輸入框內容變化時,data 中的數據同步變化。即 view => model 的變化。
- data 中的數據變化時,文本節點的內容同步變化。即 model => view 的變化。
要實現這兩個過程,關鍵點在於數據變化如何更新視圖,因為視圖變化更新數據我們可以通過事件監聽的方式來實現。所以我們着重討論數據變化如何更新視圖。
數據變化更新視圖的關鍵點則在於我們如何知道數據發生了變化,只要知道數據在什么時候變了,那么問題就變得迎刃而解,我們只需在數據變化的時候去通知視圖更新即可。
3. 使數據對象變得“可觀測”
數據的每次讀和寫能夠被我們看的見,即我們能夠知道數據什么時候被讀取了或數據什么時候被改寫了,我們將其稱為數據變的‘可觀測’。
要將數據變的‘可觀測’,我們就要借助前言中提到的Object.defineProperty
方法了,關於該方法,MDN上是這么介紹的:
Object.defineProperty() 方法會直接在一個對象上定義一個新屬性,或者修改一個對象的現有屬性, 並返回這個對象。
在本文中,我們就使用這個方法使數據變得“可觀測”。
首先,我們定義一個數據對象car
:
let car = {
'brand':'BMW',
'price':3000
}
我們定義了這個car
的品牌brand
是BMW
,價格price
是3000。現在我們可以通過car.brand
和car.price
直接讀寫這個car
對應的屬性值。但是,當這個car
的屬性被讀取或修改時,我們並不知情。那么應該如何做才能夠讓car
主動告訴我們,它的屬性被修改了呢?
接下來,我們使用Object.defineProperty()
改寫上面的例子:
let car = {}
let val = 3000
Object.defineProperty(car, 'price', {
get(){
console.log('price屬性被讀取了')
return val
},
set(newVal){
console.log('price屬性被修改了')
val = newVal
}
})
通過Object.defineProperty()
方法給car
定義了一個price
屬性,並把這個屬性的讀和寫分別使用get()
和set()
進行攔截,每當該屬性進行讀或寫操作的時候就會出發get()
和set()
。如下圖:
可以看到,car
已經可以主動告訴我們它的屬性的讀寫情況了,這也意味着,這個car
的數據對象已經是“可觀測”的了。
為了把car
的所有屬性都變得可觀測,我們可以編寫如下兩個函數:
/**
* 把一個對象的每一項都轉化成可觀測對象
* @param { Object } obj 對象
*/
function observable (obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) =>{
defineReactive(obj,key,obj[key])
})
return obj;
}
/**
* 使一個對象轉化成可觀測對象
* @param { Object } obj 對象
* @param { String } key 對象的key
* @param { Any } val 對象的某個key的值
*/
function defineReactive (obj,key,val) {
Object.defineProperty(obj, key, {
get(){
console.log(`${key}屬性被讀取了`);
return val;
},
set(newVal){
console.log(`${key}屬性被修改了`);
val = newVal;
}
})
}
現在,我們就可以這樣定義car
:
let car = observable({
'brand':'BMW',
'price':3000
})
car
的兩個屬性都變得可觀測了。
4. 依賴收集
完成了數據的'可觀測',即我們知道了數據在什么時候被讀或寫了,那么,我們就可以在數據被讀或寫的時候通知那些依賴該數據的視圖更新了,為了方便,我們需要先將所有依賴收集起來,一旦數據發生變化,就統一通知更新。其實,這就是典型的“發布訂閱者”模式,數據變化為“發布者”,依賴對象為“訂閱者”。
現在,我們需要創建一個依賴收集容器,也就是消息訂閱器Dep,用來容納所有的“訂閱者”。訂閱器Dep主要負責收集訂閱者,然后當數據變化的時候后執行對應訂閱者的更新函數。
創建消息訂閱器Dep:
class Dep {
constructor(){
this.subs = []
},
//增加訂閱者
addSub(sub){
this.subs.push(sub);
},
//判斷是否增加訂閱者
depend () {
if (Dep.target) {
this.addSub(Dep.target)
}
},
//通知訂閱者更新
notify(){
this.subs.forEach((sub) =>{
sub.update()
})
}
}
Dep.target = null;
有了訂閱器,再將defineReactive
函數進行改造一下,向其植入訂閱器:
function defineReactive (obj,key,val) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){
dep.depend();
console.log(`${key}屬性被讀取了`);
return val;
},
set(newVal){
val = newVal;
console.log(`${key}屬性被修改了`);
dep.notify() //數據變化通知所有訂閱者
}
})
}
從代碼上看,我們設計了一個訂閱器Dep類,該類里面定義了一些屬性和方法,這里需要特別注意的是它有一個靜態屬性 target
,這是一個全局唯一 的Watcher
,這是一個非常巧妙的設計,因為在同一時間只能有一個全局的 Watcher
被計算,另外它的自身屬性 subs
也是 Watcher
的數組。
我們將訂閱器Dep添加訂閱者的操作設計在getter
里面,這是為了讓Watcher
初始化時進行觸發,因此需要判斷是否要添加訂閱者。在setter
函數里面,如果數據變化,就會去通知所有訂閱者,訂閱者們就會去執行對應的更新的函數。
到此,訂閱器Dep設計完畢,接下來,我們設計訂閱者Watcher.
5. 訂閱者Watcher
訂閱者Watcher
在初始化的時候需要將自己添加進訂閱器Dep
中,那該如何添加呢?我們已經知道監聽器Observer
是在get
函數執行了添加訂閱者Wather
的操作的,所以我們只要在訂閱者Watcher
初始化的時候出發對應的get
函數去執行添加訂閱者操作即可,那要如何觸發get
的函數,再簡單不過了,只要獲取對應的屬性值就可以觸發了,核心原因就是因為我們使用了Object.defineProperty( )
進行數據監聽。這里還有一個細節點需要處理,我們只要在訂閱者Watcher
初始化的時候才需要添加訂閱者,所以需要做一個判斷操作,因此可以在訂閱器上做一下手腳:在Dep.target
上緩存下訂閱者,添加成功后再將其去掉就可以了。訂閱者Watcher
的實現如下:
class Watcher {
constructor(vm,exp,cb){
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get(); // 將自己添加到訂閱器的操作
},
update(){
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
},
get(){
Dep.target = this; // 緩存自己
let value = this.vm.data[this.exp] // 強制執行監聽器里的get函數
Dep.target = null; // 釋放自己
return value;
}
}
過程分析:
訂閱者Watcher
是一個 類,在它的構造函數中,定義了一些屬性:
- vm:一個Vue的實例對象;
- exp:是
node
節點的v-model
或v-on:click
等指令的屬性值。如v-model="name"
,exp
就是name
; - cb:是
Watcher
綁定的更新函數;
當我們去實例化一個渲染 watcher
的時候,首先進入 watcher
的構造函數邏輯,就會執行它的 this.get()
方法,進入 get
函數,首先會執行:
Dep.target = this; // 緩存自己
實際上就是把 Dep.target
賦值為當前的渲染 watcher
,接着又執行了:
let value = this.vm.data[this.exp] // 強制執行監聽器里的get函數
在這個過程中會對 vm
上的數據訪問,其實就是為了觸發數據對象的 getter
。
每個對象值的 getter
都持有一個 dep
,在觸發 getter
的時候會調用 dep.depend()
方法,也就會執行 this.addSub(Dep.target)
,即把當前的 watcher
訂閱到這個數據持有的 dep
的 subs
中,這個目的是為后續數據變化時候能通知到哪些 subs
做准備。
這樣實際上已經完成了一個依賴收集的過程。那么到這里就結束了嗎?其實並沒有,完成依賴收集后,還需要把 Dep.target
恢復成上一個狀態,即:
Dep.target = null; // 釋放自己
因為當前vm
的數據依賴收集已經完成,那么對應的渲染Dep.target
也需要改變。
而update()
函數是用來當數據發生變化時調用Watcher
自身的更新函數進行更新的操作。先通過let value = this.vm.data[this.exp];
獲取到最新的數據,然后將其與之前get()
獲得的舊數據進行比較,如果不一樣,則調用更新函數cb
進行更新。
至此,簡單的訂閱者Watcher
設計完畢。
6. 測試
完成以上工作后,我們就可以來真正的測試了。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<h1 id="name"></h1>
<input type="text">
<input type="button" value="改變data內容" onclick="changeInput()">
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script>
function myVue (data, el, exp) {
this.data = data;
observable(data); //將數據變的可觀測
el.innerHTML = this.data[exp]; // 初始化模板數據的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
var ele = document.querySelector('#name');
var input = document.querySelector('input');
var myVue = new myVue({
name: 'hello world'
}, ele, 'name');
//改變輸入框內容
input.oninput = function (e) {
myVue.data.name = e.target.value
}
//改變data內容
function changeInput(){
myVue.data.name = "難涼熱血"
}
</script>
</body>
</html>
observer.js
/**
* 把一個對象的每一項都轉化成可觀測對象
* @param { Object } obj 對象
*/
function observable (obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) =>{
defineReactive(obj,key,obj[key])
})
return obj;
}
/**
* 使一個對象轉化成可觀測對象
* @param { Object } obj 對象
* @param { String } key 對象的key
* @param { Any } val 對象的某個key的值
*/
function defineReactive (obj,key,val) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){
dep.depend();
console.log(`${key}屬性被讀取了`);
return val;
},
set(newVal){
val = newVal;
console.log(`${key}屬性被修改了`);
dep.notify() //數據變化通知所有訂閱者
}
})
}
class Dep {
constructor(){
this.subs = []
}
//增加訂閱者
addSub(sub){
this.subs.push(sub);
}
//判斷是否增加訂閱者
depend () {
if (Dep.target) {
this.addSub(Dep.target)
}
}
//通知訂閱者更新
notify(){
this.subs.forEach((sub) =>{
sub.update()
})
}
}
Dep.target = null;
watcher.js
class Watcher {
constructor(vm,exp,cb){
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get(); // 將自己添加到訂閱器的操作
}
get(){
Dep.target = this; // 緩存自己
let value = this.vm.data[this.exp] // 強制執行監聽器里的get函數
Dep.target = null; // 釋放自己
return value;
}
update(){
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
}
}
效果:
完整代碼,請戳這里☞ vue數據雙向綁定原理及實現
7. 總結
總結一下:
實現數據的雙向綁定,首先要對數據進行劫持監聽,所以我們需要設置一個監聽器Observer
,用來監聽所有屬性。如果屬性發上變化了,就需要告訴訂閱者Watcher
看是否需要更新。因為訂閱者是有很多個,所以我們需要有一個消息訂閱器Dep
來專門收集這些訂閱者,然后在監聽器Observer
和訂閱者Watcher
之間進行統一管理的。
(完)