Vue 組件的 data 為什麼必須是函數
這是一道非常經典的 Vue 面試題,尤其常出現在 Vue 2.x / Options API 的脈絡裡。它表面上是在問 data 寫法,實際上是在考你是否理解:元件是可重複建立的,每個元件實例都應該擁有彼此獨立的狀態。
一句話回答:組件的 data 必須是函數,是為了讓每次建立元件實例時都能回傳一份全新的資料物件,避免多個實例共用同一個引用型別狀態。
一、 必考觀念
1. 元件不是單例,而是模板
Vue 元件可以被使用很多次:
<template>
<CounterCard />
<CounterCard />
<CounterCard />
</template>這裡不是只有一個 CounterCard,而是建立了三個 CounterCard 實例。
每個實例都應該有自己的狀態:
CounterCard instance A -> count = 0
CounterCard instance B -> count = 0
CounterCard instance C -> count = 0如果其中一個按了加一,不應該影響另外兩個。
2. 如果 data 是物件會發生什麼?
假設允許這樣寫:
const sharedData = {
count: 0,
}
export default {
data: sharedData,
}這代表所有元件實例都會拿到同一個物件引用。
任何一個實例改了 count:
this.count++本質上都是在改同一個物件。
結果就是:
點 A 的 +1
-> A count 變 1
-> B count 也像是變 1
-> C count 也像是變 1這就是狀態污染。
3. data 是函數如何解決?
正確寫法:
export default {
data() {
return {
count: 0,
}
},
}每次 Vue 建立新元件實例時,都會呼叫一次 data():
create instance A -> data() -> { count: 0 }
create instance B -> data() -> { count: 0 }
create instance C -> data() -> { count: 0 }每次呼叫都回傳新的物件,所以狀態彼此隔離。
這和 JavaScript 函數每次執行都能建立新的區域物件是一樣的道理。
4. 為什麼根實例以前可以是物件?
在 Vue 2 裡,根實例常會看到:
new Vue({
el: '#app',
data: {
count: 0,
},
})這是因為根實例通常只會建立一次,不會像組件那樣被重複復用。
但組件不同:
Vue.component('counter-card', {
data() {
return {
count: 0,
}
},
})組件可能被建立很多份,所以必須用函數回傳新狀態。
TIP
在 Vue 3 的 Options API 中,組件 data 也應該寫成函數。Composition API 則通常在 setup() 中宣告 ref / reactive,天然就是每個實例執行一次。
二、 原理拆解
1. Vue 建立元件實例時會初始化狀態
簡化理解:
function createComponentInstance(Component) {
const instance = {}
if (typeof Component.data === 'function') {
instance.data = Component.data.call(instance)
}
return instance
}如果 data 是函數,Vue 每次建立實例都能拿到新物件。
如果 data 是同一個物件,Vue 每次都會拿到同一個引用,實例狀態就會共享。
2. 問題的根源是引用型別
JavaScript 中,物件和陣列是引用型別。
const a = {
count: 0,
}
const b = a
b.count++
console.log(a.count) // 1a 和 b 指向同一個物件,所以改 b 也會影響 a。
Vue 組件的 data 如果直接共用物件,也會遇到同樣問題。
3. 為什麼 methods 不需要每個實例回傳一次?
因為 methods 通常是函數行為,不是每個實例自己的可變狀態。
export default {
methods: {
increment() {
this.count++
},
},
}increment 可以被多個實例共用。真正不同的是 this 指向不同實例,所以改的是各自的 count。
但 data 是狀態容器。如果狀態容器共用,問題就很大。
4. Composition API 為什麼沒有這個問題?
Composition API 通常這樣寫:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script><script setup> 會被編譯成元件的 setup()。每次建立元件實例時,setup() 都會執行一次,所以 count 也是每個實例各自建立。
等價理解:
export default {
setup() {
const count = ref(0)
return {
count,
}
},
}所以 Options API 的 data() 和 Composition API 的 setup(),在「每個實例建立獨立狀態」這件事上,心智模型是相通的。
三、 追問題庫
Q1:如果 data 回傳的物件裡有巢狀物件,會不會共享?
正常不會,因為每次 data() 都會建立一份新的外層物件,也通常會建立新的巢狀物件。
data() {
return {
user: {
name: 'Hirimu',
},
}
}每次呼叫都會建立新的 user 物件。
但如果你把巢狀物件抽到外面,就會共享:
const defaultUser = {
name: 'Hirimu',
}
export default {
data() {
return {
user: defaultUser,
}
},
}這時所有實例的 user 都指向同一個 defaultUser,仍然有污染風險。
比較好的做法:
function createDefaultUser() {
return {
name: 'Hirimu',
}
}
export default {
data() {
return {
user: createDefaultUser(),
}
},
}Q2:data 裡可以引用 props 嗎?
可以,但要小心語意。
常見寫法是把 prop 當作本地狀態初始值:
export default {
props: {
initialCount: Number,
},
data() {
return {
count: this.initialCount,
}
},
}這代表 count 只取初始值。之後 initialCount 變了,count 不會自動跟著同步,除非你另外 watch。
如果你想要跟著 prop 更新,通常應該用 computed 或 watch,而不是只放進 data。
Q3:為什麼不能把共用狀態放在模組最外層?
可以,但那就不是元件本地狀態,而是模組級共享狀態。
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> 中。
四、 實作題
題目:修正共享狀態問題
下面這個組件有什麼問題?
const defaultState = {
count: 0,
logs: [],
}
export default {
data() {
return defaultState
},
methods: {
increment() {
this.count++
this.logs.push(`count: ${this.count}`)
},
},
}問題是 data() 每次都回傳同一個 defaultState 物件,所以多個元件實例仍然共享 count 和 logs。
修正方式:
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 也可能不小心共享狀態。
const count = ref(0)
export function useCounter() {
return {
count,
}
}這裡的 count 在 composable 外層,所以所有使用 useCounter() 的元件都共享同一個 count。
如果想要每個使用者各自一份,應該寫在函數裡:
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()每個實例執行一次是同樣的思想。