Vue 中如何自定義 v-model 及雙向綁定的實現原理

我們都知道,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 有兩個相對應的 getset 方法,為什么會多出這兩個方法呢?因為 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( ) 設置了對象 Bookname 屬性,對其 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__ 上面有所有 Arrayprototype 上的方法。這樣我們就可以在 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]
  }
})

我只用了 pushconcat 來進行演示,可以看到使用 push 確實能正確監聽響應,但是使用 concat 無法正確監聽。我們觀察發現 vue 支持直接響應的數組 API 基本上都是有返回值且都是直接改變原數組的 API,所以可以直接監聽響應。而像 concatslicemapfilter 等這些方法都并不會改變原數組而是返回一個新數組,所以無法正確監聽響應。(forEach 方法返回值是 undefined)。此理解僅供個人參考~~~

總結 Object.defineProperty 的缺點:

1 - 復雜對象的深度監聽需要遞歸到底,一次性計算量大
2 - 無法監聽新增屬性/刪除屬性(Vue.set Vue.delete)
3 - 無法原生監聽數組,需要特殊處理

當然瑕不掩瑜,畢竟 Vue 作為如今最優秀的前端框架之一,肯定是考慮到了這些問題在日常開發中所用到的場景極其少,并且也給出了自己的一些解決方法(如:Vue.setVue.delete)。作為前端框架的代表之一,Vue 也在 3.0 的版本中放棄了使用 Object.defineProperty 來進行雙向綁定從而啟用 Proxy。這里我們先不深究 Proxy,畢竟它的兼容性還沒有那么好,Vue 3.0 盡管正式版本已經出來,但是很長一段時間我們仍然還是會用 Vue 2.0,畢竟也要等周邊生態的完善以及更好的兼容性和穩定性,當然學習 Vue 3.0TypeScript 也是迫在眉睫。

如果文中有不對的地方或者理解有誤的地方歡迎大家提出并指正。每一天都要相對前一天進步一點,加油!!!

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。