# 1-6 條件判斷與列表渲染

終於來到指令系列的最後一篇。 在這個小節中,會為各位讀者介紹如何透過 Vue.js 的 v-if / v-show 等指令來對畫面的元素進行條件與流程的控制, 以及使用 v-for 指令來對陣列或物件類型的資料進行迭代渲染。

# 條件判斷 v-if / v-show

如果要顯示或隱藏模板中的元素, Vue.js 提供了兩個指令來幫助我們完成任務, 分別是 v-showv-if。 兩者的使用方式大同小異:

<div v-if="isShow">v-if</div>

<div v-show="isShow">v-show</div>
1
2
3
試一試

就這個範例上來說,這兩者看起來作用似乎一樣,不過實際上它們還是有些不同的地方。

# v-show

v-show 作用十分單純,就是值為 truthy 就顯示, falsy 則隱藏。

注意,這裡說的是「隱藏」而非移除元素。

讀者不妨開啟瀏覽器的 devtools 來觀察,當 v-if 對應的值為 falsy 時,元素會直接被移除, 而在 v-show 的情況,則是透過 CSS 的 display: none 來將元素隱藏。

v-show 示意圖

# v-if

前面說過 v-if 雖然與 v-show 同樣都是控制元素是否出現在畫面, 但除了元素的增減之外,v-if 也如同我們所熟知的程式語法般,v-if 還可以與 v-else-ifv-else 來與不同條件做搭配:

<div v-if="count === 0">Block A</div>
<div v-else-if="count < 5">Block B</div>
<div v-else>Block C</div>
1
2
3

像這樣,我們可以針對不同的條件設定 v-ifv-else-ifv-else 來判斷何時顯示對應的元素:

試一試

另外,若是有多個元素希望同時根據某個條件來切換時,如:

<!-- value === 'A' 時出現 -->
<h1>Title A</h1>
<p>Paragraph A - 1</p>
<p>Paragraph A - 2</p>

<!-- value 不為 A 時出現 -->
<h1>Title B</h1>
<p>Paragraph B - 1</p>
<p>Paragraph B - 2</p>
1
2
3
4
5
6
7
8
9

若是寫成:

<!-- value === 'A' 時出現 -->
<h1 v-if="value === 'A'">Title A</h1>
<p v-if="value === 'A'">Paragraph A - 1</p>
<p v-if="value === 'A'">Paragraph A - 2</p>

<!-- value 不為 A 時出現 -->
<h1 v-else>Title B</h1>
<p v-else>Paragraph B - 1</p>
<p v-else>Paragraph B - 2</p>
1
2
3
4
5
6
7
8
9

像這樣,由於連續三個元素都出現 v-else 指令,便會造成 Vue.js 報錯。

當然也可以針對每個標籤寫上 v-if="value !== 'A'",但又顯得囉唆。 此時若是不想在外層多包一個 <div>,則可以透過 <template> 標籤來包覆。


 
 
 
 
 


 
 
 
 
 

<!-- value === 'A' 時出現 -->
<template v-if="value === 'A'">
  <h1>Title A</h1>
  <p>Paragraph A - 1</p>
  <p>Paragraph A - 2</p>
</template>

<!-- value 不為 A 時出現 -->
<template v-else>
  <h1>Title B</h1>
  <p>Paragraph B - 1</p>
  <p>Paragraph B - 2</p>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13

像這樣, Vue.js 在模板編譯後並不會將 <template> 渲染至畫面上,也仍保有 v-if 指令的作用。

# v-ifkey 屬性

Vue.js 為了提高網頁渲染的效率,在預設情況下會選擇重複利用已經存在的元素而不是重新渲染。 這樣的做法確實能提高效能,不過在某些場景下可能會出現問題。

像是過去在 Vue 2.x 的時候,我們在 <template> 裡面加上 v-if<input>

<!-- for Vue 2.x -->
<template v-if="loginType === 'username'">
  <label>Username</label>
  <input placeholder="Enter your username">
</template>

<template v-else>
  <label>Email</label>
  <input placeholder="Enter your email address">
</template>
1
2
3
4
5
6
7
8
9
10
試一試

這個時候可以試著在輸入框隨意輸入幾個字,然後在 「Username」 與 「Email」 選項切換, 此時你會發現雖然在模板是兩組不同的 input 輸入框,但切換選項後的文字卻被保留了下來。

若我們想排除這個問題,只需要針對不同的表單元素加上 key 來表示:



 




 


<template v-if="loginType === 'username'">
  <label>Username</label>
  <input placeholder="Enter your username" key="username">
</template>

<template v-else>
  <label>Email</label>
  <input placeholder="Enter your email address" key="email">
</template>
1
2
3
4
5
6
7
8
9
試一試

Vue.js 的 v-if 會根據 key 屬性的內容是否相同來決定是否重新渲染元素, 若是透過 v-show 指令來切換,則無須加上 key 屬性,因為兩個元素一直都存在,沒有重新渲染的問題。

換句話說,若對應的指定的 DOM (或元件) 的狀態需要被保留,且狀態可能頻繁更動時,用 v-show 會比 v-if 更適合, 且由於 DOM 不會被動態增加或刪減,執行時的效能更好。

另一方面,當條件判斷式的結果幾乎不變的時候,則建議使用 v-if。 像是用來判斷使用者登入與否的區塊,這種情況下,更理想的做法是減少元素的數量來降低瀏覽器渲染成本,無須將不相關的 DOM 節點全部生成。

小提醒

這個問題在 Vue 3.0 之後已經被修正,所以在使用 Vue 3.0 的情況下, <input> 可以不必加上 key 屬性。


# v-for 列表渲染

在 Vue.js 裡頭,可透過 v-for 指令在模板中,將「陣列」或「物件」類型的資料進行迭代渲染 (重複呈現在畫面上)。

# v-for 與陣列

v-for 指令會以 item in items 的語法形式來使用,如:

const vm = Vue.createApp({
  data () {
    return {
      arr: ['008', 'JS', 'is', 'awesome']
    } 
  }
}).mount('#app');
1
2
3
4
5
6
7
<div id="app">
  <ul>
    <li v-for="item in arr">{{ item }}</li>
  </ul>
</div>
1
2
3
4
5

這裡的 item 的名稱可任意命名,只要是合法的 JavaScript 變數名稱即可。

試一試

我們也可以在 v-for 指令加入索引值 index ,如:

<ul>
  <li v-for="(item, index) in arr">{{ index }} / {{ item }}</li>
</ul>
1
2
3
試一試

注意

注意,v-for 列表的索引值與陣列一樣,是從 0 開始計算的。

# v-for 與物件

v-for 也可以用來遍歷物件形式的資料:

const vm = Vue.createApp({
  data () {
    return {
      book: {
        title: '08js',
        author:  'Kuro',
        publishedAt: '2019/09'
      }
    }
  }
}).mount('#app');
1
2
3
4
5
6
7
8
9
10
11
<div id="app">
  <ul>
    <li v-for="val in book">{{ val }}</li>
  </ul>
</div>
1
2
3
4
5
試一試

當然,與陣列一樣,我們也可以將物件的 key 值一並列出:

<div id="app">
  <ul>
    <li v-for="(val, key) in book">{{ key }} / {{ val }}</li>
  </ul>
</div>
1
2
3
4
5
試一試

甚至是索引值:

<div id="app">
  <ul>
    <li v-for="(val, key, index) in book">
      {{ index }} / {{ key }} / {{ val }}
    </li>
  </ul>
</div>
1
2
3
4
5
6
7
試一試

小提醒

由於物件 (object) 在 JavaScript 本身並帶有順序的特性,物件屬性間的順序會依照瀏覽器對 Object.keys() 實作方式的不同而有所差異, 因此 v-for 在不同瀏覽器環境下,渲染物件的列表順序可能會有所不同。

如果要確保渲染順序,可先將物件轉換為陣列後再進行排序。

# v-for 與範圍

除了陣列與物件之外, v-for 也可以接受整數作為渲染的條件,數字越大重複的次數越多。




 
 
 



<ul class="pagination">
  <li class="page-item"><a class="page-link" href>&lt;</a></li>
  <!-- 表示出現十次 -->
  <li class="page-item" v-for="page in 10">
    <a class="page-link" href>{{ page }}</a>
  </li>
  <li class="page-item"><a class="page-link"href>&gt;</a></li>
</ul>
1
2
3
4
5
6
7
8

在這個範例中,我們就可以透過 v-for="page in 10" 來做到渲染頁碼的效果。

試一試

# v-for<template>

若我們有多個節點希望同時透過 v-for 來循環渲染,則可以透過包覆一層 <template> 來搭配使用, 例如 Bootstrap 裡 dropdown-menu (opens new window) 的分隔線:

<div class="dropdown-menu">
  <template v-for="i in links">
    <div class="dropdown-divider"></div>
    <a class="dropdown-item" :href="i.link">i.title</a>
  </template>
</div>
1
2
3
4
5
6

像這樣,每一個透過 v-for 渲染出的 dropdown-item 都會有分隔線,而且這個 <template> 標籤與使用 v-if 一樣不會出現在渲染後的網頁內容。

# v-for 列表的排序與過濾

有時候我們希望針對 v-for 渲染的列表來進行排序或搜尋過濾,但是很遺憾地告訴各位,v-for 指令內建並未提供這類功能。 (完)

開玩笑的,雖然 v-for 指令沒有提供這類功能,但我們可以透過前面章節介紹過的 computedmethods 屬性來產生對應的新陣列。

如:

data () {
  return {
    numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  }
},
computed: {
  // 只顯示偶數
  evenNumbers () {
    return this.numbers.filter(number => number % 2 === 0);
  }
}
1
2
3
4
5
6
7
8
9
10
11
<div class="block" v-for="i in evenNumbers">{{ i }}</div>
1

由於 Array.filter 會回傳一個新的陣列,所以沒問題。

試一試

但若是在進行陣列排序的時候要小心,由於 JavaScript 的 sort 會直接影響原始陣列:

computed: {
  sortedNumbers () {
    // 注意! 若在此直接執行 .sort 會直接改變 this.numbers 的順序!
    return this.numbers.sort((a, b) => b - a);
  }
}
1
2
3
4
5
6

比較好的做法是先複製一份新的陣列再進行排序,如:

computed: {
  sortedNumbers() {
    // 先透過 [...this.numbers] 複製一份新陣列再排序
    return [...this.numbers].sort((a, b) => b - a);
  }
}
1
2
3
4
5
6

這樣就不會影響原有陣列內容了。

# v-for 可以使用 index 當作 key 嗎?

在理解 index 可否作為 key 之前,先來介紹 keyv-for 的作用。

與前面介紹過的 v-if 一樣,v-for 為了提高網頁渲染的效率,會選擇重複利用已經存在的元素而不是重新渲染。 換句話說,當陣列的順序被改變時, Vue.js 不會移動實際 DOM 的節點,而是更新現有的 DOM 內容。

但是當 v-for 內部含有子元件或表單元素的時候,這個時候要是沒有加上 key 屬性,就可能會出現一些不可預期的錯誤。

舉個例子,這裡有個 Todo list 的列表,我們期望當這份列表內的 checkbox 被勾起時, 會自動把選項移動到 Done List 的列表:




 
 
 





 
 
 


<!-- Todo list -->
<h1>Todo</h1>
<ul>
  <li v-for="i in todoLists">
    <label><input v-model="i.isDone" type="checkbox">{{ i.title }}</label>
  </li>
</ul>

<!-- Done List -->
<h1>Todo</h1>
<ul>
  <li v-for="i in doneLists">
    <label><input v-model="i.isDone" type="checkbox">{{ i.title }}</label>
  </li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data () {
  return {
    lists: [
      {
        id: 'task001',
        title: '選項 1',
        isDone: false
      },
      {
        id: 'task002',
        title: '選項 2',
        isDone: false
      },
      {
        id: 'task003',
        title: '選項 3',
        isDone: false
      },
    ]
  }
},
computed: {
  todoLists () {
    return this.lists.filter( d => !d.isDone )
  },
  doneLists () {
    return this.lists.filter( d => d.isDone )
  },
}
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

這是在 v-for 沒有加上 key 屬性的範例。

試一試

當我們勾選「選項 3」的時候,程式的作用是正常的:

v-for-key 實際畫面-1

但是,如果我們勾選「選項 2」的時候,問題就來了, 此時會發現 Todo List 的「選項 2」確實如預期般地跑到 Done List 列表, 但是這個時候 Todo List 的「選項 3」也被勾起來了。

v-for-key 示意圖-2

這是為什麼呢?

剛剛我們說過, Vue.js 做到為了效能最佳化,會把已經渲染至畫面的 DOM 節點拿來重複利用。 當我們打勾的選項是最後一個的時候,因為 todo list 變動的元素剛好是最後一個,所以沒有問題。

v-for-key 示意圖-1

然而,當我們打勾的是「選項 2」的時候,這個時候 done list 會多出一個選項 2 。

但對 todo list 來說,長度減一,所以對應的元素少了一個, 原本在「選項 2」的文字更新,但 checkbox 卻在這個時候被拿來重複利用:

v-for-key 示意圖-2

於是就會出現明明只勾選了一個「選項 2」,卻有兩個選項都被打勾的錯誤現象。

若是要解決這個問題,只要加個「唯一的key 屬性作為識別,即可確保畫面的重新渲染:


 




 



<!-- todoLists -->
<li v-for="i in todoLists" :key="i.id">
  <label><input v-model="i.isDone" type="checkbox"> {{ i.title }}</label>
</li>

<!-- doneLists -->
<li v-for="i in doneLists" :key="i.id">
  <label><input v-model="i.isDone" type="checkbox"> {{ i.title }}</label>
</li>
1
2
3
4
5
6
7
8
9

小提醒

:keyv-bind:key 之簡寫。

試一試

那麼,回到本小節的標題, v-for 裡的 index 是否可以當作 key 來使用呢?

相信讀者看到這裡應該也能得出一樣的答案:不適合

由於 v-for 裡的 index 是隨著陣列而生成的,換句話說,當 index 沒變的時候,對 Vue.js 而言,它就是一個可以重複使用的元素 (或元件)。

<!-- 將 index 設定為 key -->
<li v-for="(i, index) in todoLists" :key="index">
  <label><input v-model="i.isDone" type="checkbox"> {{ i.title }}</label>
</li>
1
2
3
4

這個時候,即使我們為 v-for 加上了 key 屬性,它的作用也會跟沒加一樣。

試一試

小提醒

想一想,在 v-for 的列表中,加上 key 屬性與沒加上 key 屬性,哪種執行的效率高?

# [補充] 當陣列的內容變動,畫面為何沒更新? (Vue 2.x)

在本篇的最後,來談談很多新手在使用 Vue 2.x 會遇到的問題,明明陣列的內容已經變動,畫面為何沒更新?

先來看個簡單的範例:

<ul>
  <li v-for="n in items">{{ n }}</li>
</ul>

<button @click="changeVal">click</button>
1
2
3
4
5
// for Vue 2.x
const vm = new Vue({
  data () {
   return {
      items: ["Vue", "is", "Awesome"]
    }  
  },
  methods: {
    changeVal () {
      this.items[0] = '08JS';
      
      // 加上 alert 確定陣列確實已被更新
      // 但是模板內 list 的內容不會更新 
      alert(this.items);
    }
  }
}).$mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
試一試

這就要提到 Vue.js 狀態與畫面的更新機制 (Vue 2.x) - Object.defineProperty()

Vue.js 的更新機制

Vue.js 2.x 在建立實體的時候,會針對 data 裡的屬性增加 gettersetter, 其實背後原理也就是 Object.definePropertygetset function。

let title = '08JS好棒棒';
const book = {};

Object.defineProperty(book, 'title', {
  set: val => {
    title = val;
    console.log('我被更新了!', val);
  },
  get: () => {
    return title;
  },
});

// '08JS好棒棒'
console.log(book.title);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

像這樣,雖然我們沒有針對 book 物件設定 title 屬性, 但是當我們試著去取得 book.title 的時候,就會透過 Object.definePropertyget function 來拿到對應的值。

而當我們嘗試去修改 book.title 的時候,實際上是透過 Object.definePropertyset function 來更新對應的內容:

// 延續前一範例
book.title = 'Vue.js好棒棒';

// console 主控台會印出:
// 我被更新了! Vue.js好棒棒
1
2
3
4
5

這也是為什麼 Vue.js 能夠即時偵測狀態的更新的原理。

繼續前面範例,如果此時,我們將 book.title 指定為陣列,並且更新內容:

// 會觸發 setter function,印出 "我被更新了! [1, 2, 3, 4, 5]"
book.title = [1, 2, 3, 4, 5]

// 由於 book.title 並未重新指定 (re-assigned)
// 所以不會觸發 setter function,但是陣列內容被更新了
book.title[0] = '重新認識 JavaScript 好棒棒';

// 不會觸發 setter function
book.title.push('重新認識 Vue.js 也好棒棒');
1
2
3
4
5
6
7
8
9

此時我們發現,只要 book.title 在沒有被重新賦值的情況下, setter 是不會被觸發的。

Vue.js 的開發團隊當然也發現了這樣的問題,所以他們針對了 JavaScript 陣列的幾種方法進行包裝改寫:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

這裡節錄部分 Vue.js 2.x 的原始碼:

const arrayProto = Array.prototype

export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    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
  })
})
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

如上所述,我們可以看到 Vue.js 針對陣列的 prototype 進行改寫,根據 JavaScript 的原型鏈機制,找到方法或屬性後就不會再往上找。

arrayProto[method] 取得原始方法,並且由 def 重新定義在 arrayProto[method] 的那幾種方法。

所以,當開發者呼叫上述提到的陣列中這七種方法,其實是調用被 Vue.js 攔截包裝後的方法, 既增加了自訂的邏輯,同時也調用陣列原始的方法,所以不會對原本功能產生影響。

另外,除了上述七種包裝過的陣列方法外,也可以透過 vm.$set(vm.items, indexOfItem, newValue) 來強制更新內容, 像是前面的 changeVal 就可以改寫成:

methods: {
  changeVal () {
    this.$set(this.items, 0, this.text)
    alert(this.items);
  }
}
1
2
3
4
5
6
試一試

於是,透過上面幾種方法改變狀態後的陣列,也會即時反映到畫面上了。

小提醒

自 Vue.js 3.0 之後,底層狀態的更新機制將由原本的 Object.defineProperty 改為 Proxy API 來處理,此處提到的陣列更新就不會有無法偵測的問題了。

有關 Vue 3.0 後的更新機制,我們將會在後續介紹 3.0 新增的 Composition 一節中再次深入探討。

Last Updated: 12/22/2020, 11:35:01 AM