Vue Computed 計算屬性

Computed 計算屬性(Computed Properties)是用來根據其他響應式資料計算出衍生值的方式。它會自動追蹤依賴,並且只有在依賴改變時才會重新計算,具有快取機制。

基本用法

使用 computed() 函式建立計算屬性:

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// 計算屬性
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})
</script>

<template>
  <p>{{ fullName }}</p>
</template>

為什麼需要 Computed?

比較:在模板中直接寫表達式

<template>
  <!-- 不推薦:模板中放太多邏輯 -->
  <p>{{ firstName + ' ' + lastName }}</p>
  <p>{{ items.filter(item => item.active).length }}</p>
</template>

雖然可以在模板中直接寫表達式,但這樣有幾個問題:

  1. 模板變得複雜難讀
  2. 如果多處使用同一個邏輯,會重複寫
  3. 沒有快取,每次渲染都會重新執行

比較:使用方法(Methods)

<script setup>
import { ref } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// 方法
function getFullName() {
  return `${firstName.value} ${lastName.value}`
}
</script>

<template>
  <!-- 每次渲染都會呼叫 -->
  <p>{{ getFullName() }}</p>
</template>

方法每次渲染都會執行,沒有快取機制。

使用 Computed

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// 計算屬性 - 有快取
const fullName = computed(() => {
  console.log('計算 fullName')  // 只有依賴改變時才會執行
  return `${firstName.value} ${lastName.value}`
})
</script>

<template>
  <!-- 多次使用,只計算一次 -->
  <p>{{ fullName }}</p>
  <p>{{ fullName }}</p>
  <p>{{ fullName }}</p>
</template>

Computed 的快取機制

Computed 會根據其響應式依賴進行快取。只有當依賴改變時,才會重新計算:

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const unrelated = ref('hello')

const doubled = computed(() => {
  console.log('計算 doubled')
  return count.value * 2
})

function incrementCount() {
  count.value++  // 會觸發 doubled 重新計算
}

function changeUnrelated() {
  unrelated.value = 'world'  // 不會觸發 doubled 重新計算
}
</script>

<template>
  <p>Count: {{ count }}</p>
  <p>Doubled: {{ doubled }}</p>
  <p>Unrelated: {{ unrelated }}</p>
  <button @click="incrementCount">+1</button>
  <button @click="changeUnrelated">改變 unrelated</button>
</template>

可寫的 Computed

預設情況下,computed 是唯讀的。如果需要可寫,可以提供 getter 和 setter:

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  // getter
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  // setter
  set(newValue) {
    const names = newValue.split(' ')
    firstName.value = names[0]
    lastName.value = names[names.length - 1]
  }
})
</script>

<template>
  <input v-model="fullName">
  <p>First Name: {{ firstName }}</p>
  <p>Last Name: {{ lastName }}</p>
</template>

當你修改 fullName.value 時,setter 會被呼叫,並更新 firstNamelastName

雖然可以建立可寫的 computed,但應該謹慎使用。Computed 主要用途是衍生資料,過多的副作用會讓程式碼難以理解。

常見使用情境

過濾與排序列表

<script setup>
import { ref, computed } from 'vue'

const items = ref([
  { id: 1, name: 'Apple', price: 30, inStock: true },
  { id: 2, name: 'Banana', price: 20, inStock: false },
  { id: 3, name: 'Orange', price: 25, inStock: true }
])

const searchQuery = ref('')
const showInStockOnly = ref(false)
const sortBy = ref('name')

const filteredItems = computed(() => {
  let result = items.value

  // 過濾:搜尋
  if (searchQuery.value) {
    const query = searchQuery.value.toLowerCase()
    result = result.filter(item =>
      item.name.toLowerCase().includes(query)
    )
  }

  // 過濾:只顯示有庫存
  if (showInStockOnly.value) {
    result = result.filter(item => item.inStock)
  }

  // 排序
  return [...result].sort((a, b) => {
    if (sortBy.value === 'name') {
      return a.name.localeCompare(b.name)
    } else if (sortBy.value === 'price') {
      return a.price - b.price
    }
    return 0
  })
})
</script>

<template>
  <input v-model="searchQuery" placeholder="搜尋...">
  <label>
    <input type="checkbox" v-model="showInStockOnly">
    只顯示有庫存
  </label>
  <select v-model="sortBy">
    <option value="name">依名稱排序</option>
    <option value="price">依價格排序</option>
  </select>

  <ul>
    <li v-for="item in filteredItems" :key="item.id">
      {{ item.name }} - ${{ item.price }}
      <span v-if="!item.inStock">(無庫存)</span>
    </li>
  </ul>
</template>

格式化顯示

<script setup>
import { ref, computed } from 'vue'

const price = ref(1234.5)
const date = ref(new Date())

// 格式化價格
const formattedPrice = computed(() => {
  return new Intl.NumberFormat('zh-TW', {
    style: 'currency',
    currency: 'TWD'
  }).format(price.value)
})

// 格式化日期
const formattedDate = computed(() => {
  return new Intl.DateTimeFormat('zh-TW', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    weekday: 'long'
  }).format(date.value)
})
</script>

<template>
  <p>價格:{{ formattedPrice }}</p>
  <p>日期:{{ formattedDate }}</p>
</template>

統計數據

<script setup>
import { ref, computed } from 'vue'

const todos = ref([
  { id: 1, text: '學習 Vue', done: true },
  { id: 2, text: '寫專案', done: false },
  { id: 3, text: '部署上線', done: false }
])

const totalCount = computed(() => todos.value.length)
const doneCount = computed(() => todos.value.filter(t => t.done).length)
const pendingCount = computed(() => totalCount.value - doneCount.value)
const progress = computed(() => {
  if (totalCount.value === 0) return 0
  return Math.round((doneCount.value / totalCount.value) * 100)
})
</script>

<template>
  <p>總計:{{ totalCount }} 項</p>
  <p>已完成:{{ doneCount }} 項</p>
  <p>待完成:{{ pendingCount }} 項</p>
  <p>進度:{{ progress }}%</p>
  <progress :value="doneCount" :max="totalCount"></progress>
</template>

Class 和 Style 綁定

<script setup>
import { ref, computed } from 'vue'

const isActive = ref(true)
const hasError = ref(false)
const activeColor = ref('blue')
const fontSize = ref(16)

const classObject = computed(() => ({
  active: isActive.value,
  'text-danger': hasError.value
}))

const styleObject = computed(() => ({
  color: activeColor.value,
  fontSize: fontSize.value + 'px'
}))
</script>

<template>
  <div :class="classObject" :style="styleObject">
    Hello Vue!
  </div>
</template>

Computed 最佳實踐

1. 保持 Getter 無副作用

Computed getter 應該只做計算,不應該有副作用:

// ❌ 不好:有副作用
const fullName = computed(() => {
  // 不要在 getter 中修改其他響應式資料
  otherState.value = 'changed'
  // 不要做 API 呼叫
  fetch('/api/log')
  return `${firstName.value} ${lastName.value}`
})

// ✅ 好:純粹的計算
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

2. 避免修改 Computed 的值

const doubled = computed(() => count.value * 2)

// ❌ 不好:直接修改 computed 的值
doubled.value = 10  // 會報錯

// ✅ 好:修改依賴的資料
count.value = 5

3. 適時使用 Computed 而非 Watch

如果你只是要根據資料變化計算出新值,使用 computed 比 watch 更簡潔:

// ❌ 不必要的 watch
const fullName = ref('')
watch([firstName, lastName], () => {
  fullName.value = `${firstName.value} ${lastName.value}`
}, { immediate: true })

// ✅ 使用 computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

4. 考慮效能

複雜的計算應該避免放在會頻繁觸發的地方:

// 如果 items 很大,這個計算可能很耗時
const expensiveComputed = computed(() => {
  return items.value
    .filter(/* 複雜條件 */)
    .map(/* 複雜轉換 */)
    .reduce(/* 複雜計算 */)
})

// 可以考慮使用 watch + debounce,或分段處理

與 TypeScript 一起使用

<script setup lang="ts">
import { ref, computed } from 'vue'

interface Todo {
  id: number
  text: string
  done: boolean
}

const todos = ref<Todo[]>([])

// 型別會自動推斷
const doneCount = computed(() => {
  return todos.value.filter(t => t.done).length
})  // computed 的型別是 ComputedRef<number>

// 也可以明確指定
const pendingTodos = computed<Todo[]>(() => {
  return todos.value.filter(t => !t.done)
})
</script>