跳至主要內容
Skip to content

Vue 組件的 data 為什麼必須是函數

這是一道非常經典的 Vue 面試題,尤其常出現在 Vue 2.x / Options API 的脈絡裡。它表面上是在問 data 寫法,實際上是在考你是否理解:元件是可重複建立的,每個元件實例都應該擁有彼此獨立的狀態。

一句話回答:組件的 data 必須是函數,是為了讓每次建立元件實例時都能回傳一份全新的資料物件,避免多個實例共用同一個引用型別狀態。


一、 必考觀念

1. 元件不是單例,而是模板

Vue 元件可以被使用很多次:

vue
<template>
  <CounterCard />
  <CounterCard />
  <CounterCard />
</template>

這裡不是只有一個 CounterCard,而是建立了三個 CounterCard 實例。

每個實例都應該有自己的狀態:

text
CounterCard instance A -> count = 0
CounterCard instance B -> count = 0
CounterCard instance C -> count = 0

如果其中一個按了加一,不應該影響另外兩個。

2. 如果 data 是物件會發生什麼?

假設允許這樣寫:

javascript
const sharedData = {
  count: 0,
}

export default {
  data: sharedData,
}

這代表所有元件實例都會拿到同一個物件引用。

任何一個實例改了 count

javascript
this.count++

本質上都是在改同一個物件。

結果就是:

text
點 A 的 +1
-> A count 變 1
-> B count 也像是變 1
-> C count 也像是變 1

這就是狀態污染。

3. data 是函數如何解決?

正確寫法:

javascript
export default {
  data() {
    return {
      count: 0,
    }
  },
}

每次 Vue 建立新元件實例時,都會呼叫一次 data()

text
create instance A -> data() -> { count: 0 }
create instance B -> data() -> { count: 0 }
create instance C -> data() -> { count: 0 }

每次呼叫都回傳新的物件,所以狀態彼此隔離。

這和 JavaScript 函數每次執行都能建立新的區域物件是一樣的道理。

4. 為什麼根實例以前可以是物件?

在 Vue 2 裡,根實例常會看到:

javascript
new Vue({
  el: '#app',
  data: {
    count: 0,
  },
})

這是因為根實例通常只會建立一次,不會像組件那樣被重複復用。

但組件不同:

javascript
Vue.component('counter-card', {
  data() {
    return {
      count: 0,
    }
  },
})

組件可能被建立很多份,所以必須用函數回傳新狀態。

TIP

在 Vue 3 的 Options API 中,組件 data 也應該寫成函數。Composition API 則通常在 setup() 中宣告 ref / reactive,天然就是每個實例執行一次。


二、 原理拆解

1. Vue 建立元件實例時會初始化狀態

簡化理解:

javascript
function createComponentInstance(Component) {
  const instance = {}

  if (typeof Component.data === 'function') {
    instance.data = Component.data.call(instance)
  }

  return instance
}

如果 data 是函數,Vue 每次建立實例都能拿到新物件。

如果 data 是同一個物件,Vue 每次都會拿到同一個引用,實例狀態就會共享。

2. 問題的根源是引用型別

JavaScript 中,物件和陣列是引用型別。

javascript
const a = {
  count: 0,
}

const b = a

b.count++

console.log(a.count) // 1

ab 指向同一個物件,所以改 b 也會影響 a

Vue 組件的 data 如果直接共用物件,也會遇到同樣問題。

3. 為什麼 methods 不需要每個實例回傳一次?

因為 methods 通常是函數行為,不是每個實例自己的可變狀態。

javascript
export default {
  methods: {
    increment() {
      this.count++
    },
  },
}

increment 可以被多個實例共用。真正不同的是 this 指向不同實例,所以改的是各自的 count

data 是狀態容器。如果狀態容器共用,問題就很大。

4. Composition API 為什麼沒有這個問題?

Composition API 通常這樣寫:

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

const count = ref(0)
</script>

<script setup> 會被編譯成元件的 setup()。每次建立元件實例時,setup() 都會執行一次,所以 count 也是每個實例各自建立。

等價理解:

javascript
export default {
  setup() {
    const count = ref(0)

    return {
      count,
    }
  },
}

所以 Options API 的 data() 和 Composition API 的 setup(),在「每個實例建立獨立狀態」這件事上,心智模型是相通的。


三、 追問題庫

Q1:如果 data 回傳的物件裡有巢狀物件,會不會共享?

正常不會,因為每次 data() 都會建立一份新的外層物件,也通常會建立新的巢狀物件。

javascript
data() {
  return {
    user: {
      name: 'Hirimu',
    },
  }
}

每次呼叫都會建立新的 user 物件。

但如果你把巢狀物件抽到外面,就會共享:

javascript
const defaultUser = {
  name: 'Hirimu',
}

export default {
  data() {
    return {
      user: defaultUser,
    }
  },
}

這時所有實例的 user 都指向同一個 defaultUser,仍然有污染風險。

比較好的做法:

javascript
function createDefaultUser() {
  return {
    name: 'Hirimu',
  }
}

export default {
  data() {
    return {
      user: createDefaultUser(),
    }
  },
}

Q2:data 裡可以引用 props 嗎?

可以,但要小心語意。

常見寫法是把 prop 當作本地狀態初始值:

javascript
export default {
  props: {
    initialCount: Number,
  },
  data() {
    return {
      count: this.initialCount,
    }
  },
}

這代表 count 只取初始值。之後 initialCount 變了,count 不會自動跟著同步,除非你另外 watch。

如果你想要跟著 prop 更新,通常應該用 computed 或 watch,而不是只放進 data。

Q3:為什麼不能把共用狀態放在模組最外層?

可以,但那就不是元件本地狀態,而是模組級共享狀態。

javascript
const sharedState = reactive({
  count: 0,
})

所有 import 它的地方都會共享同一份狀態。這有時是刻意設計,例如簡單 store;但如果你以為它是每個元件獨立的,就會出問題。

真正需要跨元件共享時,應該明確使用 Pinia、provide/inject 或 composable 設計,而不是不小心共享引用。

Q4:SSR 裡為什麼也強調每次 request 建立新狀態?

原因相同:避免狀態互相污染。

元件 data() 是避免多個元件實例共享狀態;SSR 中每個 request 建立新 app/store,是避免不同使用者請求共享狀態。

如果 server 上共用單例 store,A 使用者資料可能污染到 B 使用者。

可以延伸讀:Vue SSR 的實現原理

Q5:這題只適用 Vue 2 嗎?

不是。

這題常從 Vue 2 問起,因為 Vue 2 Options API 最常見。但 Vue 3 仍然有 Options API,組件 data 一樣應該是函數。

只是 Vue 3 開發更常用 Composition API,很多本地狀態會寫在 setup()<script setup> 中。


四、 實作題

題目:修正共享狀態問題

下面這個組件有什麼問題?

javascript
const defaultState = {
  count: 0,
  logs: [],
}

export default {
  data() {
    return defaultState
  },
  methods: {
    increment() {
      this.count++
      this.logs.push(`count: ${this.count}`)
    },
  },
}

問題是 data() 每次都回傳同一個 defaultState 物件,所以多個元件實例仍然共享 countlogs

修正方式:

javascript
function createDefaultState() {
  return {
    count: 0,
    logs: [],
  }
}

export default {
  data() {
    return createDefaultState()
  },
  methods: {
    increment() {
      this.count++
      this.logs.push(`count: ${this.count}`)
    },
  },
}

重點不是「有沒有寫 function」,而是「每次是否真的回傳新物件」。


五、 資深視角

1. 這題本質是狀態歸屬

data() 必須是函數,是因為 data 表示元件本地狀態。既然是本地狀態,就應該屬於某個具體實例,而不是屬於元件定義本身。

如果某份狀態應該被多個實例共享,那它就不該假裝成 data,而應該被明確設計成:

  • Pinia store。
  • provide/inject context。
  • module-level composable state。
  • 後端資料或快取。

2. 小心 composable 裡的共享引用

Composition API 也可能不小心共享狀態。

typescript
const count = ref(0)

export function useCounter() {
  return {
    count,
  }
}

這裡的 count 在 composable 外層,所以所有使用 useCounter() 的元件都共享同一個 count

如果想要每個使用者各自一份,應該寫在函數裡:

typescript
export function useCounter() {
  const count = ref(0)

  return {
    count,
  }
}

這和 data() 必須回傳新物件是同一個思想。

3. 共享狀態要明確,不要偶然

工程上最怕的是「看起來是本地狀態,實際上是共享狀態」。

好的設計應該讓狀態共享是顯性的:

  • 看到 Pinia,就知道它是全域/跨頁狀態。
  • 看到 provide/inject,就知道它是上下文。
  • 看到 props/emit,就知道它是父子資料流。
  • 看到 data/setup 裡建立的狀態,就知道它屬於目前實例。

總結

問題面試回答重點
為什麼 data 必須是函數每次建立元件實例時回傳新物件,避免共享狀態
如果 data 是物件會怎樣多個實例共用同一個引用,互相污染
根實例為什麼例外Vue 2 根實例通常只建立一次,不會被復用
Composition API 對應概念setup() 每個實例執行一次
常見陷阱data() 回傳同一個外部物件仍然會共享
資深理解這題本質是狀態歸屬與實例隔離

一句完整的面試回答可以是:

Vue 組件的 data 必須是函數,是因為組件會被重複建立成多個實例,每個實例都應該有自己的本地狀態。如果 data 是物件,多個實例會共用同一個物件引用,某個實例修改狀態會影響其他實例。把 data 寫成函數後,Vue 每次建立實例都會呼叫它並取得新的資料物件,從而實現狀態隔離。這和 Composition API 裡 setup() 每個實例執行一次是同樣的思想。

延伸閱讀