Vue.js 的響應式系統一直是框架的核心。從 Vue 2 到 Vue 3,底層從 Object.defineProperty 切換到 Proxy,不是換個 API 這麼簡單——兩種機制的攔截粒度根本不同,直接決定了哪些操作能被追蹤、哪些不能。

Vue 2:Object.defineProperty 時代

實作方式

Vue 2 在初始化元件時遞迴遍歷整個物件,對每個屬性呼叫 Object.defineProperty,掛上 getter 和 setter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
// 收集依賴(目前正在執行的 watcher)
dep.depend()
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
dep.notify() // 通知所有 watcher 重新執行
}
})
}

getter/setter 只掛在初始化時已存在的屬性上,這是 Vue 2 所有限制的根源。

優點

  • 瀏覽器相容性極佳,IE9 以上皆可使用
  • 對現有屬性的讀寫追蹤行為穩定

限制

  1. 無法偵測屬性的動態新增或刪除:後來才加進物件的屬性不會有 getter/setter,因此不具響應性。這是為什麼 Vue 2 需要 Vue.set()this.$set()

    1
    2
    3
    4
    5
    // Vue 2:這樣不會觸發更新
    this.user.newField = 'hello'

    // 必須這樣寫
    this.$set(this.user, 'newField', 'hello')
  2. 陣列的盲區是索引賦值與 length 修改:Vue 2 透過覆寫陣列原型(pushpopsplice 等變異方法)讓常見操作保有響應性,但直接以索引賦值或改 length 無法被攔截:

    1
    2
    3
    4
    5
    6
    7
    // Vue 2:以下兩種寫法不會觸發更新
    this.items[0] = 'x'
    this.items.length = 0

    // 有效的替代寫法
    this.$set(this.items, 0, 'x')
    this.items.splice(0)
  3. 初始化成本隨物件大小線性成長:遞迴遍歷整棵物件樹,大型資料結構在元件建立時就全部處理完。

Vue 3:Proxy 時代

實作方式

Vue 3 改用 Proxy 包裹整個物件,攔截發生在代理物件上的任何操作:

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
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key) // 收集依賴
const res = Reflect.get(target, key, receiver)
// 惰性:只在屬性被存取時才遞迴建立嵌套 Proxy
if (isObject(res)) return reactive(res)
return res
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (value !== oldValue) {
trigger(target, key) // 通知依賴
}
return result
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key)
trigger(target, key)
return result
}
// ... has、ownKeys 等 trap 也一併攔截
})
}

Proxy 在物件層級攔截,不需要事先知道有哪些屬性。

相較於 Vue 2 的改善

動態屬性不再是問題:因為 set trap 能攔截任何賦值,包含原本不存在的 key,不再需要 Vue.set()

1
2
// Vue 3:直接賦值即可觸發更新
state.newField = 'hello'

陣列索引賦值與 length 修改可以被偵測

1
2
3
// Vue 3:這些現在都能正確觸發更新
state.items[0] = 'x'
state.items.length = 0

注意,Vue 3 對 pushpopincludesindexOf 等方法做了額外的插樁處理,但目的各不相同:push/pop 是為了在變異時正確觸發 trigger;includes/indexOf 的插樁則是為了讓讀取操作也能追蹤依賴——reactive 陣列的元素本身也是 Proxy,直接傳入原生方法比對時會有身份不相等的問題,插樁確保這類讀取也被正確收集。

惰性初始化降低啟動成本:Vue 2 初始化時就遞迴處理整棵物件樹;Vue 3 改為惰性建立,只在屬性真正被存取時才對嵌套物件建立 Proxy。大型資料結構的元件啟動成本因此顯著降低。需要說明的是,單次屬性存取的速度 Proxy 本身並不比 defineProperty 快,某些 V8 版本甚至略慢——整體效能優勢來自「不必事先全部初始化」的設計,而非 Proxy 本身更快。

in 運算子與 for...in 也納入追蹤:透過 hasownKeys trap,key in objObject.keys(obj) 這類操作也可以建立依賴關係,Vue 2 做不到。

為什麼 Vue 2 的方式撐到了 2.x 才換?

defineProperty 在 2014 年 Vue 1.0 問世時已是最佳選項——Proxy 雖然 ES2015 就定義了,但 IE11 不支援且無法 polyfill。Vue 2 選擇 defineProperty 是為了兼顧當時的瀏覽器生態。

Vue 3 在 2020 年正式發布,明確放棄 IE11 支援。IE11 已於 2022 年 6 月 15 日正式終止支援,現代專案無需再考慮此限制。如果你的專案確實需要跑在 IE11 上,唯一選項是留在 Vue 2(Vue 2 已於 2023 年 12 月 31 日終止官方支援,但仍可使用)。

遷移時實際需要注意什麼

從 Vue 2 升上 Vue 3,響應式系統的行為差異主要體現在:

  • 移除 Vue.set() / this.$set():直接賦值即可,不再需要
  • reactive() 回傳的是 Proxy,直接把它解構或傳給需要原始物件的第三方程式庫時會失去響應性;用 toRefs()storeToRefs() 保留
1
2
3
4
5
6
// 解構後失去響應性(常見陷阱)
const { count } = reactive({ count: 0 })

// 正確做法
const state = reactive({ count: 0 })
const { count } = toRefs(state)
  • $delete 同樣不再需要,直接 delete obj.key 即可觸發更新