從數組入手淺析Vue響應式原理

  最近在用Vue開發一個後臺管理的demo,有一個很是常規的需求。然而這個常規的需求中,包含了大量的知識點。有一個產品表格,用來顯示不一樣產品的信息。而後表格要有一個內嵌編輯的功能,點擊操做欄的編輯按鈕,對應行的信息列就變成輸入框。初版的代碼大體上像這樣。javascript

<template>
    <!-- ... -->
    <el-input v-show="scope.row.edit" size="small" v-model="scope.row.description"></el-input>
    <span v-show="!scope.row.edit">{{scope.row.description}}</span>
    <!-- ... -->
    <el-button @click="scope.row.edit = !scope.row.edit" type="text" size="small">編輯</el-button>
    <!-- ... -->
</template>

<script lang="ts">
import { Vue, Component} from "vue-property-decorator";

@Component
export default class Project extends Vue {
  products = [];
  mounted() {
    this.$store.dispatch('GET_PRODUCTS').then(() => {
       this.products = this.$store.getters.products
       this.products.map((item: any) => {
         item.edit = false;
         return item;
       });
    });
  }
}
</script>

  邏輯很簡單,我在表格數據數組中,給每個對象都加入一個初始值爲false的屬性"edit",而後根據這個屬性的值,使用v-show來決定渲染的是文本仍是輸入框,是「編輯」仍是「保存」。
  然而運行起來以後的表現並非像我想的同樣,事實上,點擊編輯按鈕後,對應產品的「產品描述」並無變成輸入框,編輯按鈕也沒有變成保存按鈕。而我經過vue-devtool查看數據發現,事實上對應的edit屬性確實已經變了,只是頁面上的組件沒有正確渲染。這讓我很困惑,說好的雙向綁定呢,爲何model層上的變化沒有響應到view層上呢。html

緣由分析

  首先,因爲頁面初始顯示是正確的,把edit的初始值改爲true後,也會有輸入框出現,因此確定不是代碼邏輯的問題。當我試着把v-show的判斷條件改爲數組中的對象本來就有的屬性時,發現編輯狀態的切換忽然變得正常了。而一旦我把判斷條件改回後來插入的edit時,一切又變得不正常了。所以我推測,必定是數據綁定出了什麼問題。
  我在網上查了一下,有些相似的問題,大多數的解決方案是,el-table加上一個隨機數key值:key="Math.random()"。試了一下,發現真的有用。之因此有用是由於,每次對這個表格有操做,key值都會變,這就至關於產生了一個新的table,瀏覽器就會根據model層的數據從新渲染,這時候顯示固然就正確了。但可想而知,這樣也會形成極大的性能浪費,並且這也沒有解決數據綁定的問題。
  我又試着對代碼作了一些修改。我把map和賦值操做放到了同一句裏面去,代碼變成了這樣vue

this.$store.dispatch(GET_PRODUCTS).then(() => {
       this.products = this.$store.getters.products.map((item: any) => {
         item.edit = false;
         return item;
       });
    });

神奇的事發生了,竟然一切都恢復正常了。那麼我就知道了,問題出在了數組和map函數上。java

響應式原理

  爲了探究這一切的緣由,我再次點開了Vue的官網。在官網很下面的位置,找到了關於響應式原理的說明。這張圖很好地說明了Vue實現雙向綁定的原理。
image
  當一個javscript對象傳入Vue實例的data中時,Vue會遍歷該對象的全部屬性,同時使用 Object.defineProperty方法將這些屬性全都轉成 getter/setter每一個組件實例都對應一個watcher實例,它會在組件渲染的過程當中把「接觸」過的數據屬性記錄爲依賴。以後當依賴項的數據發生變化,也就是setter觸發時,會通知watcher,從而使它關聯的組件從新渲染。
  而因爲javascript的限制,Vue不能檢測到對象的添加或者刪除。而且Vue在初始化實例時就對屬性執行了setter/getter轉化過程,因此屬性必須開始就在對象上,這樣才能讓Vue轉化它。而動態添加的根級別的屬性,則不會轉化成響應式的屬性。也就是說,往已經建立的實例上添加的根級別的屬性,都會是非響應式的。可是,可使用 Vue.set(object, propertyName, value) 或者vm.$set(object, propertyName, value)方法向嵌套對象添加響應式屬性。
  這裏,數組相關的注意事項被額外提了出來。因爲 JavaScript 的限制,Vue 不能檢測如下數組的變更:react

  1. 當你利用索引直接設置一個數組項時,例如:vm.items[indexOfItem] = newValue
  2. 當你修改數組的長度時,例如:vm.items.length = newLength

  解決方法也很簡單,使用上面提到的set方法就能夠解決這個問題。與此同時,官網上還有一段專門針對數組的變異方法的說明。
  所謂的變異方法,顧名思義,會改變調用了這些方法的原始數組。相比之下,也有非變異 (non-mutating method) 方法,例如 filter()、concat() 和 slice() 。它們不會改變原始數組,而老是返回一個新數組。當使用非變異方法時,能夠用新數組替換舊數組。而且,Vue還很是智能的會對於沒有變化的dom進行重用,並不會整個進行更新。
  看到這兒,我終於找到問題的關鍵在哪兒了。其實網上的各類說法都不許確,真正出問題的點在於map函數的使用上。map是一個非變異方法,方法自己並不會改變原數組,而是會返回一個新數組。所以,Vue並無對map方法進行包裝,而是建議替換原數組。然而我在用的時候並無注意到這一點,在使用的時候利用指針特性,把map方法當作變異方法來用,直接改變原數組,這天然就不會被Vue檢測到了。所以,新添加到數組中的對象中的edit屬性,就成了非響應式的屬性了,改變它天然不會讓組件從新渲染。數組

解決方法

原理都已經搞清楚了,接下來我總結了一下這類數組問題的幾種解決方法。瀏覽器

1.添加隨機數key(不建議)

  在el-table標籤上添加:key="Math.random()",無論作了什麼,都強制刷新整個表格,很是不推薦,極大的性能消耗。app

2.正確使用數組方法

  在使用數組方法的時候,分清變異方法和非變異方法,用非變異方法的時候,要用新數組替代舊數組,而不是直接變換原數組。dom

3.使用Vue.set方法(建議)

  我在'vue/src/core/observer/index.js'中找到了set方法的源碼。咱們發現set函數接收三個參數分別爲 target、key、val,其中target的值爲數組或者對象,這正好和官網給出的調用Vue.set()方法時傳入的參數參數對應上。而後往下看實現,我基本上給每一行都加上了註釋。ide

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {//判斷target的類型是否符合要求,若不符合要求,且不在生產環境下,就拋出警告。
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {//若是target是數組,且key值合法
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)//用包裝好的變異方法splice進行賦值。
    return val
  }
  if (key in target && !(key in Object.prototype)) {//若是key是target中原有的屬性,就直接賦值。
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__//響應式屬性的observer對象,有這個對象就表明是響應式的。
  if (target._isVue || (ob && ob.vmCount)) {//若是當前的target對象是vue實例對象或者是根數據對象,就拋出警告。
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {//若是不存在observer,那就不是響應式對象,直接賦值。
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)//給新屬性添加依賴,之後直接修改屬性就能從新渲染。
  ob.dep.notify()//直接觸發依賴。
  return val
}

能夠看到,set方法對於數組的處理其實很是簡單,就是調用了包裝好的splice方法。那麼再來看一下包裝Array變異方法的代碼實現,我一樣給每一行加上了註釋。其實作的事情也很少,主要就是給每一個新添加的元素都加上觀察者。

...
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]//保存原方法。
  def(arrayMethods, method, function mutator (...args) {//修改方法映射,調用數組方法的時候實際上調用的是對應的mutator方法。
    const result = original.apply(this, args)//調用原方法,先把結果求出來
    const ob = this.__ob__//獲取observer
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }//對於往數組中加元素的方法,得到添加的元素。
    if (inserted) ob.observeArray(inserted)//給添加的元素添加觀察者。
    // notify change
    ob.dep.notify()//觸發依賴。
    return result
  })
})
相關文章
相關標籤/搜索