我們都知道,Vue
中使用 v-model
可以實現雙向綁定,先看一個小栗子:
<template>
<div id="app">
<p>{{ city }}</p>
<input type="text" v-model="city" />
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
city: '北京'
}
},
};
</script>
city
的值通過 v-model
綁定到 input
上,因此它會根據 input
輸入框的值進行動態變化。根據官方文檔的解釋,v-model
其實是一個語法糖,它會自動的在元素或者組件上面解析為 :value = " "
和 @input = " "
。所以我們可以將上述代碼進行拆解:
<template>
<div id="app">
<p>{{ city }}</p>
<input type="text" :value="city" @input="city = $event.target.value" />
</div>
</template>
不使用 v-model
,我們也可以得到相同的效果。上述代碼中,當在 input
輸入框輸入內容時,會自動的觸發 input
事件,更新綁定的 city
值為當前 input
框輸入的值。所以,了解這些后,我們是否可以在自己寫的組件上面實現 v-model
的效果呢?
官方文檔:自定義組件的 v-model。這里我們將官網的栗子改造一下拿過來瞅瞅:
// 父組件
<template>
<div id="app">
<p>{{ name }}</p>
<!--自定義組件上使用 v-model,name 的值會傳入到子組件 model-prop-text 中-->
<CustomVModel v-model="name" />
</div>
</template>
<script>
import CustomVModel from './components/CustomVModel'
export default {
name: "App",
components: { CustomVModel },
data() {
return {
name: '張三'
}
},
};
</script>
// 子組件 CustomVModel.vue
<template>
<input
type="text"
:value="text"
@input="$emit('change', $event.target.value)"
/>
</template>
<script>
export default {
model: {
prop: 'text', // 可任意定義的變量,用來接收父組件 v-model 中傳遞過來的值
event: 'change'
},
props: {
text: String, // 對自己定義的變量的類型和默認值進行聲明,必須和自己定義的變量名相同
default() {
return ''
}
}
}
</script>
根據官方文檔給出的說法:父組件中的 name
的值將會傳入子組件中名為 text(該名稱可自己定義)
的 prop
中,同時子組件的 input
如果觸發 @input
事件并附帶一個新的值的時候,父組件中 name
的值也會被重新更新。當然,我們還是需要在子組件的 props 選項里聲明 text
這個 prop
。
雙向綁定的實現原理
上述我們了解了 v-model
可以實現雙向綁定,但是 Vue
實現雙向綁定的原理是啥呢?我們先來看一下通過控制臺輸出一個定義在 Vue
初始化數據上的對象是個什么東西。
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12"></script>
<script>
const vm = new Vue({
data() {
return {
obj: {
a: 1
}
}
},
created() {
console.log(this.obj)
}
})
</script>
打印結果:
我們可以看到屬性
a
有兩個相對應的 get
和 set
方法,為什么會多出這兩個方法呢?因為 Vue
是通過 Object.defineProperty()
來實現數據劫持的。
好吧,Object.defineProperty( )
是用來做什么的?它可以來控制一個對象屬性的一些特有操作,比如讀寫權、是否可以枚舉,這里我們主要先來研究下它對應的兩個描述屬性 get()
和 set()
,該方法的詳細用法參考 MDN。根據官方文檔給出的案例,我們先來了解它的基礎用法:
const Book = {}
let name = ''
Object.defineProperty(Book, 'name', {
set: function (value) {
name = value
console.log('你取了一本書名叫做' + value)
},
get: function () {
return `《${name}》`
}
})
Book.name = '老人與海' // 你取了一本書名叫做老人與海
console.log(Book.name) //《老人與海》
我們通過 Object.defineProperty( )
設置了對象 Book
的 name
屬性,對其 get()
和 set()
進行重寫操作,顧名思義,get()
就是在讀取 name
屬性這個值觸發的函數,set()
就是在設置 name
屬性這個值觸發的函數,所以當執行 Book.name = '老人與海'
這個語句時,控制臺會打印出 "你取了一個書名叫做老人與海",緊接著,當讀取這個屬性時,就會輸出 "《老人與海》",因為我們在 get()
里面對該值做了加工了。
大概了解了 Object.defineProperty( )
的基本用法和 get()、set()
之后,有沒有覺得抓住了一些什么,趁熱打鐵,我們來寫一個監聽器,用于監聽所有屬性。
// 觸發更新視圖
function updateView() {
console.log('視圖更新')
}
// 重新定義屬性,監聽起來
function defineReactive(target, key, value) {
// 使用遞歸進行深度監聽
observer(value)
// 核心 API
Object.defineProperty(target, key, {
get() {
return value
},
set(newVal) {
if (newVal !== value) {
// 設置新值
value = newVal
// 觸發更新視圖
updateView()
}
}
})
}
// 監聽對象屬性
function observer(target) {
if (typeof target !== 'object' || target === null) {
// 不是對象或數組
return target
}
// 重新定義各個屬性(for in 也可以遍歷數組)
for (let key in target) {
defineReactive(target, key, target[key])
}
}
// 準備數據
const data = {
name: 'zhangsan',
age: 20,
info: {
address: '北京' // 需要深度監聽
}
}
// 監聽數據
observer(data)
// 測試
data.name = 'lisi' // 視圖更新
data.info.address = '上海' // 如果不在監聽執行函數中進行遞歸調用,無法監聽到數據的變化
上述代碼中我們定義了一個監聽器的入口函數 observer
和一個監聽執行函數 defineReactive
,我們在每次監聽到屬性變化的時候觸發 updateView
函數。如果只是進行 "一維對象" 進行值改變的監聽,我們很容易就能通過 updateView
進行響應,但是原數組中 info
屬性的值又是一個對象,所以我們需要在監聽執行函數里面使用遞歸,這樣就可以正確響應。
但是我們如果對 data
進行新增或者刪除操作,updateView
函數仍然無法正確響應,如下栗子:
data.sex = '男' // 新增屬性,監聽不到 ----- 所以有 Vue.set
delete data.name // 刪除屬性,監聽不到 ----- 所以有 Vue.delete
相信小伙伴們在項目開發中都用過 Vue.set
方法,其實根本原因就是 Object.defineProperty( )
無法監聽到對象屬性的新增和刪除。所以 Vue
才專門針對這兩種情況設置了兩個對應的 API
。其實這里還有一個問題,就是 Object.defineProperty( )
也無法對數組的改變進行監聽....當然,小伙伴們肯定不信,因為 Vue
實際開發中是可以對數組的變化進行響應的。我們先將上述代碼改造整理一下:
const data = {
name: 'zhangsan',
age: 20,
info: {
address: '北京' // 需要深度監聽
},
nums: [10, 20, 30] // 新增屬性 nums,它的值時數組
}
data.nums.push(40) // 新增一個值 ----- updateView 函數未觸發,監聽不到
通過代碼可以看到我們確實監聽不到數組的變化,那么 Vue
中是如何實現的呢?我們一起來研究研究
// 重新定義數組原型
const oldArrayProperty = Array.prototype
// 創建新對象,原型指向 oldArrayProperty,再擴展新的方法不會影響原型
const arrProto = Object.create(oldArrayProperty)
我們首先定義了 oldArrayProperty
來讓它等于 Array
原型上的方法,然后通過 Object.create()
創建了一個新的對象,讓其原型指向 oldArrayProperty
。為什么我們要做這一步呢?我們可以先打印一下 arrProto
console.log(arrProto)
結果如下圖:
arrProto
為一個空的對象,但是它的 __proto__
上面有所有 Array
的 prototype
上的方法。這樣我們就可以在 arrProto
上面擴展自己的方法,并且并不會污染 __proto__
上的方法,如下栗子:
arrProto.push = function () {
console.log(100)
}
console.log(arrProto.push) // ? () { console.log(100) }
console.log(arrProto.__proto__.push) // ? push() { [native code] }
這樣我們是不是可以聲明一個數組方法名的集合,如果我們通過 arrProto
去調用這些方法名,那么就可以直接在我們自己定義的數組原型上讓這些方法真正觸發。如下栗子:
// 為什么是這7個數組名,而沒有其它是有講究的哦~~~
const useArr = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
// 將這些數組中的方法名追加到 arrProto 上,然后它執行時觸發數組原型上的對應方法
useArr.forEach(methodName => {
arrProto[methodName] = function () {
updateView() // 觸發視圖更新
oldArrayProperty[methodName].call(this, ...arguments)
}
})
這樣我們就完成了對數組變化的響應監聽,有沒有感覺很巧妙~~~最后匯總所有代碼:
// 觸發更新視圖
function updateView() {
console.log('視圖更新')
}
// 重新定義數組原型
const oldArrayProperty = Array.prototype
// 創建新對象,原型指向 oldArrayProperty,再擴展新的方法不會影響原型
const arrProto = Object.create(oldArrayProperty)
const useArr = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
useArr.forEach(methodName => {
arrProto[methodName] = function () {
updateView() // 觸發視圖更新
oldArrayProperty[methodName].call(this, ...arguments)
}
})
// 重新定義屬性,監聽起來
function defineReactive(target, key, value) {
// 使用遞歸進行深度監聽
observer(value)
// 核心 API
Object.defineProperty(target, key, {
get() {
return value
},
set(newVal) {
if (newVal !== value) {
// 設置新值
value = newVal
// 觸發更新視圖
updateView()
}
}
})
}
// 監聽對象屬性
function observer(target) {
if (typeof target !== 'object' || target === null) {
// 不是對象或數組
return target
}
// 如果這個對象是數組,那么就將其原型指向我們生成的新對象上
if (Array.isArray(target)) {
target.__proto__ = arrProto
}
// 重新定義各個屬性(for in 也可以遍歷數組)
for (let key in target) {
defineReactive(target, key, target[key])
}
}
// 準備數據
const data = {
name: 'zhangsan',
age: 20,
info: {
address: '北京' // 需要深度監聽
},
nums: [10, 20, 30]
}
// 監聽數據
observer(data)
// 測試'
data.nums.push(40) // 視圖更新
當然上述代碼其實還有坑留下,在 Vue
實現響應式時,把無法監聽數組的情況通過重寫數組的部分方法來實現響應式,但是只局限在以下 7 種方法:
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
這也是為什么我代碼中只羅列了那七種方法,到底是不是這樣呢?我們可以通過代碼來瞅瞅:
// 請引入 vue.js 哦
const vm = new Vue({
data() {
return {
obj: {
a: 1,
nums: [10, 20, 30]
}
}
},
created() {
// this.obj.nums.push(40)
// console.log(this.obj.nums) [10, 20, 30, 40, __ob__: we]
const arr = [1, 2, 3]
this.obj.nums.concat(arr)
console.log(this.obj) // [10, 20, 30, __ob__: we]
}
})
我只用了 push
和 concat
來進行演示,可以看到使用 push
確實能正確監聽響應,但是使用 concat
無法正確監聽。我們觀察發現 vue
支持直接響應的數組 API
基本上都是有返回值且都是直接改變原數組的 API
,所以可以直接監聽響應。而像 concat
、slice
、map
、filter
等這些方法都并不會改變原數組而是返回一個新數組,所以無法正確監聽響應。(forEach
方法返回值是 undefined
)。此理解僅供個人參考~~~
總結 Object.defineProperty
的缺點:
1 - 復雜對象的深度監聽需要遞歸到底,一次性計算量大
2 - 無法監聽新增屬性/刪除屬性(Vue.set Vue.delete)
3 - 無法原生監聽數組,需要特殊處理
當然瑕不掩瑜,畢竟 Vue
作為如今最優秀的前端框架之一,肯定是考慮到了這些問題在日常開發中所用到的場景極其少,并且也給出了自己的一些解決方法(如:Vue.set
、Vue.delete
)。作為前端框架的代表之一,Vue
也在 3.0
的版本中放棄了使用 Object.defineProperty
來進行雙向綁定從而啟用 Proxy
。這里我們先不深究 Proxy
,畢竟它的兼容性還沒有那么好,Vue 3.0
盡管正式版本已經出來,但是很長一段時間我們仍然還是會用 Vue 2.0
,畢竟也要等周邊生態的完善以及更好的兼容性和穩定性,當然學習 Vue 3.0
和 TypeScript
也是迫在眉睫。
如果文中有不對的地方或者理解有誤的地方歡迎大家提出并指正。每一天都要相對前一天進步一點,加油!!!