2017年5月14日

[掘竅] 為什麼畫面沒有隨資料更新 - Vue 響應式原理(Reactivity)

之前在在一開始接觸框架的時候,不是很清楚響應式原理到底是什麼,一直會把它和 responsive 這個東西搞混,因為兩個在中文翻起來都是響應式,官網雖然有提及這個部分的說明,但小時候不懂事就給它忽略過去了。
最近在用 Vue 做一些東西的時候,才慢慢瞭解到 Reactivity 的意思和重要性,在使用 Vue 的過程中,我們會發現當我們修改 JavaScript 的資料時,畫面就會自動的更新產生變化,之所以能這麼方便操作,都是由於 Vue 背後的響應式系統(reactivity system),可見 Reactivity 在 Vue 中有多重要!!
也因此,錯誤的使用可能會導致 Vue 的 data 不會有即時更新的效果。
在 Vue 當中,每個組件實例都有相對應的 watcher 實例物件 ,它會紀錄組件渲染過程中所有被觸碰到的屬性,當這些屬性的 setter 被觸發時,就會通知 watcher,進而促使組件重新渲染。
然而,有些情況下你可能會發現即使已經為 JavaScript 中的資料重新設值時,畫面卻沒有重新渲染,這有可能是受限於 JavaScript 物件底層的一些限制,讓我們來看看什麼情況下會導致資料更新了,畫面卻沒有重新渲染:

物件部分:一開始沒有被註冊到的物件不會響應式更新

這是一開始在使用 Vue 或搭配 AJAX 取得資料時很容易疏忽的部分。假設我們現在要做一個計數器,分別記錄不同動物的數量,當我點擊按鈕的時候,該動物的數量就會增加,template 的部分像這樣:
<!-- template -->
<button @click="addCount('dog')">addDogs</button>
<button @click="addCount('cat')">addCats</button>
<button @click="addCount('penguin')">addPenguin</button>
<p>dog {{ counter.dog }}, cat {{ counter.cat }}, penguin {{ counter.penguin }}</p>
在一開始的時,我們在 datacounter 屬性中註冊了 counter.dogcounter.cat 這兩個屬性,但是並沒有把 penguin 給註冊進去,我們可能想說等在 created Hook 中再把資料送進去(通常因為是要透過 AJAX 取的資料內容),順便建立 penguin 屬性就好,因此這時候 JavaScript 的部分像這樣:
new Vue({
  el: '#unregister-object',
  data: {
    // 一開始沒有註冊 penguin,只註冊了 dog 和 cat
    counter: {
      dog: 0,
      cat: 0
    }
  },
  methods: {
    addCount(name) {
      this.counter[name] ++
    }
  },
  created() {
    // 在 created 的時候才建立 penguin 順便設值為 0
    this.counter.penguin = 0
  },
  updated() {
    // 讓我們可以知道組件有被更新
    console.log('view updated')
  }
})
可以點這裡操作範例 @ Codepen
這時候你會發現一個現象,從 console 中我們可以看到,當你按了 addDogsaddCats 時,這個組件會立即的更新。可是當你按 addPenguin 時,畫面卻毫無反應,也不會更新。你可能會以為資料沒有被設定進去,可是如果你又回去點addDogsaddCats 讓畫面更新時,你會發現其實 penguin 的資料有被設定進去,只是沒有產生響應式的變化重新渲染畫面:
所以碰到這樣的問題,我們可以怎麼做呢?

方法一:在 data 中要把所有需要響應式的資料設定進去

如果你希望你的資料能夠在變更時讓畫面重新渲染,達到響應式的效果(Reactive),那麼最簡單的方式是在一開始的 data 中就把資料設定進去,在這裡我們就只要把 penguin 加到 data 中就可以,像是這樣:
data: {
  counter: {
    dog: 0,
    cat: 0,
    penguin: 0       // 一開始就把要響應式變化的資料設定進來
  }
}

方法二:動態添加新的響應式屬性

除了上面的方式,我們也可以透過 vm.$set(object, key, value) 動態新增響應式的屬性,那麼我們只需要在原本的 created(){...} 中改成:
// 也可以使用 Vue.set(object, key, value)
created() {
  // 在 created 的時候才建立 penguin 順便設值為 0
  // 使用 $set 動態新增響應式組件
  this.$set(this.counter, 'penguin', 0)
}
如此當 penguin 被點擊時,一樣會更新組件,重新渲染頁面。

方法三:建立新的物件

另一種當我們要一次更改或新增較多的屬性時,可以透過 Object.assign() 的方法重新建立一個新的物件讓 Vue 去監控,因此,在 created 中可以寫成:
created () {
  // 透過建立新的物件來新增物件的屬性
  this.counter = Object.assign({}, this.counter, {
    penguin: 0,
    dog: 5
  })
}

陣列部分:利用陣列索引直接設值時

剛剛提到的主要是物件的部分,陣列同樣的也會在某些情況下沒有辦法產生響應式的變化,因此如果我們希望畫面會隨著資料能夠有響應式的變化時,應該特別留意和避免。
Template 如下,主要是列出一個動物清單,當我按下 Change Animal 時,它會根據我所輸入的內容以陣列索引的方式變更陣列中的資料內容。另外我們多寫了一個 forceUpdate 的按鈕,點下去之後可以強制更新 vue 組件。
<!-- template -->
<div id="change-by-array-index" v-cloak="v-cloak">
  <h3>利用索引值變更 index 0 的值</h3>
  <input type="text" v-model="animal"/>
  <button v-on:click="changeAnimal">Change Animal</button>
  <ul>
    <li v-for="item in animals" v-bind:key="item">{{ item }}</li>
  </ul>
  <button v-on:click="$forceUpdate()">forceUpdate</button>
</div>
JS 的部分有 changeAnimal 這個 function 會把使用者填寫的資料以陣列索引的方式代入 animals[0]
new Vue({
  el: '#change-by-array-index',
  data: {
    animal: '',
    animals: ['<YOURAnimal>', 'cat', 'penguin', 'bird', 'rabbit']
  },
  methods: {
    changeAnimal () {
      // 當利用陣列索引直接設置值時
      this.animals[0] = this.animal
    }
  }
})
可以點這裡操作範例 @ Codepen
這時候會碰到跟剛剛物件時一樣的問題,就是當我填完 input 內容,按下 changeAnimal 的按鍵時,畫面沒有任何反應,這是因為 以陣列索引值的方式修改資料內容時,Vue 無法監測到 ,因此雖然資料已經代進去了,但是 Vue 不會更新。
和剛剛類似,你可以透過按下 forceUpdate 這個按鈕來讓 Vue 強制更新,強制更新後你就會發現,畫面更新成你剛剛填入的資料內容,表示其實剛剛是有把資料設定到 Vue 當中,只是 Vue 沒有監測到,因此沒有觸發組件去做 update

方法一:使用 Vue 可觀察到的陣列方法

在 Vue 中包含一組可以觀察陣列的方法,而這些方法將能促使畫面重新渲染。這些方法包含:push()pop()shift()unshift()splice()sort()reverse()
因此,回到剛剛的例子上,我們可以使用 arr.splice(startIndex, deleteCount, addItem) 這個方法來把陣列中的內容抽換掉,因此我們可以把 methods 改成:
methods: {
  changeAnimal () {
    // 利用 arr.splice(startIndex, deleteCount, addItem)
    this.animals.splice(0, 1, this.animal)
  }
}

方法二:使用 vm.$set

和物件類似,我們也可以使用 vm.$set(array, index, value) 來達到響應式修改資料內容,我們可以把 methods 改成:
methods: {
  changeAnimal () {
  // 利用 vm.$set(array, index, value) 方法
    this.$set(this.animals, 0, this.animal)
  }
}
如此一樣可以達到響應式變換陣列的資料內容。

瞭解 Vue Reactivity 的原理

See the Pen [Demo] Vue Reactivity in Vanilla JavaScript by PJCHEN (@PJCHENder) on CodePen.

總結

當你對於 Vue 的響應式原理(Reactivity System)有了更多的瞭解後,相信你可以少踩到一些莫名其妙或不必要的坑。
操作範例:

參考資料

0 意見:

張貼留言