淺談 Vue2 雙向綁定的概念與實作

前言

這一篇會來簡單紀錄一下 Vue 是如何實作 v-model 以及如果不要使用 Vue 又該如何實作出該類似雙向綁定的作法,而本篇也會以 Vue2,所以可能會有點長,但比較主要著重於實作方面。

Vue MVVM

要講到 v-model 就不得不提到 MVVM 的概念,MVVM 分別由 Model、View 以及 ViewModel 組成(其中 MVC 觀念可詳見 這一篇 文章)

MVVM

但是這邊要注意 Vue 自己官方在介紹時,有說到自己並不是 MVVM 框架,而是受到 MVVM 的啟發而設計的,這一段可以詳見官方文件底下說明:

虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例

所以我們以往常看到的 const vm = this; 就是在講 ViewModel。

這邊也稍微稍微提一下由於 Vue 3 區分成了 Option API 與 Composition API 兩種寫法,而 HTMl 撰寫方式並沒有任何改變:

1
2
3
4
5
<div id="app">
我的名字是:{{ myName }}
<br>
<input type="text" v-model="myName">
</div>

但是在 Vue 3 Option API 就是原本 Vue 2 的開發模式:

1
2
3
4
5
6
var app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
});

只是 Vue 3 之後 Option API 是有一點點小差異的改變:

1
2
3
4
5
6
7
8
Vue.createApp({
el: '#app',
data() {
return {
myName: 'Ray',
};
},
}).mount('#app');

而 Composition API 則是變成了以下:

1
2
3
4
5
6
7
8
Vue.createApp({
setup() {
const myName = Vue.ref('Ray');
return {
myName,
}
}
}).mount('#app');

這邊可以注意到我不是寫 const myName = ref('Ray'); 而是 const myName = Vue.ref('Ray'); 這樣才能夠正常綁定

那我們這邊只是稍微提到兩種版本的寫法差異,所以大概知道一下就可以了。

Vue2 defineProperty

接下來聊聊 Vue2 v-model 是如何實作的,Vue2 的 v-model 實作最主要關鍵技術在於以下:

  • Object.defineProperty
  • GetterSetter

但是這邊就不太過深入說明,因為在先前的文章已經有詳細說過了,所以如果對於這兩個不熟悉的話,建議可以閱讀我先前寫的 文章 都有介紹到。

那麼這一段該如何自己實作呢?讓我們來試著製作看看,首先來看看我們 HTMl 結構,當然要模仿當然要模仿得像一點:

1
2
3
4
5
<div id="app">
我的名字是:<span v-model="myName"></span>
<br>
<input type="text" v-model="myName">
</div>

那麼基於 MVVM 概念,我們會有一個存放資料的地方與指定元素的值:

1
2
3
4
5
6
7
// Model
var model = {
el: '#app',
data: {
myName: ''
},
}

接下來是關於 ViewModel 的部分,一開始我們必須先選取元素,並選取有綁定 v-model 的 DOM 作為雙向綁定到畫面上:

1
2
3
4
5
6
7
8
9
10
// ViewModel
var data = model.data;
var app = document.querySelector(model.el);
var VModel = app.querySelectorAll('[v-model]');

VModel.forEach((element) => {
element.addEventListener('input', function () {
data[this.getAttribute('v-model')] = this.value;
})
})

這邊的邏輯可能相對比較複雜一點,這邊首先我透過 var app = document.querySelector(model.el); 取得綁定在畫面上的 DOM 父元素頂層,接下來再透過父元素頂層來使用 app.querySelectorAll('[v-model]'); 將所有在 #app 底下有綁定 v-modal 屬性的元素取出來,而這邊由於是使用 querySelectorAll 語法,所以會是一個類陣列,這邊就直接使用 forEach 來執行:

1
2
3
VModel.forEach((element) => {
...
})

接下來要針對每一個取出來有綁定 v-modal 屬性的 DOM 元素設置監聽行為,這邊我們是搭配輸入框,因此要監聽 input 事件,當使用者有輸入行為的時候,就要觸發取得值並寫回到 data 物件中,這邊注意要用傳統函式會比較好,否則會沒有 this(詳情可見 此篇) :

1
2
3
4
5
VModel.forEach((element) => {
element.addEventListener('input', function () {
...
})
})

那麼剛剛有提到,由於當使用者在 input 輸入框輸入之後要觸發寫回到 data 的行為,所以就要這樣子將 inputvalue 值取出來在寫回到 data 中:

1
2
3
4
5
VModel.forEach((element) => {
element.addEventListener('input', function () {
data[this.getAttribute('v-model')] = this.value;
})
})

注意,這邊一定要用 data[this.getAttribute('v-model')] 語法,否則是無法正確對應上要正確寫回的物件屬性,因此這邊觸發之後程式碼類似會變成這樣:

1
model.data.myName = this.value; // this.getAttribute('v-model') 取出來是 myName = 'Ray';

這樣子我們就可以達到正確的將 input 的值取出來並寫入到 model 中,但是這時候你應該會說「為什麼畫面沒有跟著渲染出來呢?」因為這邊還缺少了剛剛講的兩大關鍵技術:

  • Object.defineProperty
  • GetterSetter

以上都只是單純的設置監聽行為與取值,接下來才是關鍵重點,首先我們先使用 Object.defineProperty 選取 data 特定屬性進行攔截/挟持。

1
2
3
Object.defineProperty(data, 'myName', {
...
});

所謂的攔截/挟持是什麼意思呢?你可以把它想像成我正要把值寫入的時候我先把這個寫入行為擋下來,然後在做一些處理後再放行,概念就像我們上高速公路收費站一樣,你要過站必須先給過站費,但是由於我們可能不只這個屬性,因此會建議改寫成以下:

1
2
3
4
5
Object.keys(data).forEach((key) => {
Object.defineProperty(data, key, {
...
});
})

接下來是針對 GetterSetter 的設置撰寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Object.keys(data).forEach((key) => {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
return data[key];
},
set(val) {
var elementKeys = app.querySelectorAll(`[v-model=${key}]`);
elementKeys.forEach((item) => {
item.value = val; // 針對 input 發生變化時也寫入
item.innerText = val; // 渲染到畫面上
})
}
});
})

因此這邊完整寫法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
console.clear();
var model = {
el: '#app',
data: {
myName: '',
},
};

var data = model.data;
var app = document.querySelector(model.el);
var VModel = app.querySelectorAll('[v-model]');

VModel.forEach((element) => {
element.addEventListener('input', function() {
data[this.getAttribute('v-model')] = this.value;
})
});

Object.keys(data).forEach((key) => {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
set(val) {
var elementKeys = app.querySelectorAll(`[v-model=${key}]`);
elementKeys.forEach((item) => {
item.value = val; // 針對 input 發生變化時也寫入
item.innerText = val; // 渲染到畫面上
})
}
});
});

在上面的寫法其實少了一個東西就是 Getter,如果你的 Getter 是這樣寫的話:

1
2
3
get() {
return data[key];
},

是會會出現「InternalError: too much recursion」錯誤,主要原因在於 return data[key]; 本身會針對自己物件本身的屬性取值,因此會導致 get 在回傳前要先取得資料,而這一段又會再次觸發 get 觸發導致無限迴圈,因此就會出現 InternalError: too much recursion 錯誤,要避免該問題的話,只需要建立一個 cache 區給它儲存值就可以了:

雙向綁定

而在 Vue 本身也會將物件本身額外拷貝一份出來當作放置出,所以我們也可以這樣子模仿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
console.clear();
var model = {
el: '#app',
data: {
myName: 'Ray',
},
};

var data = model.data;
var _data = {};
var app = document.querySelector(model.el);
var VModel = app.querySelectorAll('[v-model]');

VModel.forEach((element) => {
element.addEventListener('input', function() {
data[this.getAttribute('v-model')] = this.value;
})
});

Object.keys(data).forEach((key) => {
_data[`_${key}`] = data[key];
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
return _data[`_${key}`];
},
set(val) {
var elementKeys = app.querySelectorAll(`[v-model=${key}]`);
elementKeys.forEach((item) => {
item.value = val; // 針對 input 發生變化時也寫入
item.innerText = val; // 渲染到畫面上
})
_data[`_${key}`] = val
}
});
});

setTimeout(() => {
console.log(data);
console.log(_data);
}, 5000)

底下範例程式碼:

以上是一個最簡單的 defineProperty 雙向綁定實作。

Liker 讚賞 (拍手)

如果這一篇筆記文章對你有幫助,希望可以求點支持或 牡蠣 鼓勵 (ノД`)・゜・。

Liker 是一個按讚(拍手)的讚賞機制,每一篇文章最多可以按五下拍手,過程你只需要登入,如果你願意按個讚,對於創作者來講是一個莫大的鼓勵與支持。

Google AD

撰寫一篇文章其實真的很花時間,如果你願意「關閉 Adblock (廣告阻擋器)」來支持我的話,我會非常感謝你 ヽ(・∀・)ノ