寫 Vue 的時候,我們很習慣這樣做:
state.count++
改完資料,畫面上的數字就跟著變了。我們沒有呼叫任何「更新畫面」的函式,Vue 卻知道資料被改了。
它到底是怎麼知道的?
這個問題的答案,其實不在 Vue 裡,而在 JavaScript 本身。今天先把 Vue 放一邊,來認識 Vue 3 響應式系統的地基:Proxy。
假設有一個普通物件:
const user = { name: '小明', age: 18 }
需求是這樣的:每次有人讀取或修改 user 的屬性,就要印出一行 log。
條件是不能要求大家改寫成 user.getName() 這種函式呼叫,就是要照常寫 user.name。
你會怎麼做?可以先停下來想一下。
在 Proxy 出現之前,比較常見的做法是用 Object.defineProperty,替屬性裝上 getter 和 setter:
const user = { name: '小明', age: 18 }
let _name = user.name
Object.defineProperty(user, 'name', {
get() {
console.log('讀取 name')
return _name
},
set(value) {
console.log('修改 name')
_name = value
}
})
user.name // 讀取 name
user.name = '小華' // 修改 name
能用,但仔細想想會發現幾個麻煩:
age 也要監聽的話,就要再寫一次。user.hobby = '畫畫' 完全不會觸發,因為當初沒有包到它。delete user.name、'name' in user。問題的根源在於:這個做法是「改造物件上的每個屬性」。
那如果換個思路,不去改物件,而是在整個物件前面放一個守門人呢?
這就是 Proxy。
const proxy = new Proxy(target, handler)
只需要兩樣東西:
| 名詞 | 意思 |
|---|---|
target |
原本的物件,真正存放資料的地方 |
handler |
一個物件,裡面放各種攔截規則 |
| trap | handler 裡的每個方法,例如 get、set,名稱是固定的 |
可以把它想成:target 是倉庫,proxy 是站在倉庫門口的守門人,handler 是守門人手上的規則手冊。
先看一個什麼規則都沒有的例子:
const user = { name: '小明' }
const p = new Proxy(user, {})
p.name = '小華'
console.log(user.name) // '小華'
handler 是空的時候,所有操作都會直接轉交給 target,就像守門人什麼都不管,全部放行。
這裡有一個很重要的觀念:要透過 proxy 操作,才會被攔截。 如果你直接改 user,等於從後門進倉庫,守門人完全不知道。
一般物件讀取不存在的屬性,會默默拿到 undefined。打錯字的時候很難發現:
user.nmae // undefined,不會報錯
如果我們想在讀到不存在的屬性時給個提示,可以用 get trap:
const user = { name: '小明', age: 18 }
const p = new Proxy(user, {
get(target, key) {
if (key in target) {
return target[key]
}
return `沒有「${String(key)}」這個屬性`
}
})
p.name // '小明'
p.nmae // '沒有「nmae」這個屬性'
每次透過 p 讀屬性,JS 都會呼叫 get,並把「原物件」和「屬性名稱」傳進來。回傳什麼,讀取的人就拿到什麼。
有一個小細節值得記住:key 永遠是字串或 Symbol。 就算你寫 p[0],trap 收到的 key 也是字串 '0'。
接著來做資料驗證。規則是:age 必須是 0 到 150 的整數。
const p = new Proxy(user, {
set(target, key, value) {
if (key === 'age') {
if (!Number.isInteger(value) || value < 0 || value > 150) {
throw new TypeError('age 要是 0~150 的整數')
}
}
target[key] = value
return true
}
})
p.age = 20 // 成功
p.age = -5 // TypeError: age 要是 0~150 的整數
p.age = 18.5 // TypeError: age 要是 0~150 的整數
有兩行很容易漏掉:
target[key] = value:驗證通過後,要自己把值存進去。忘了寫,資料就不會被寫入。return true:告訴 JS 寫入成功。在嚴格模式下(ES module 和 class 裡預設就是),set 回傳 falsy 值會直接丟出 TypeError。看其他資料時,你可能會發現有人寫 get(target, key),有人寫 get(target, prop),MDN 則寫 get(target, property, receiver)。
其實參數名稱是自己取的,JS 只看順序:
| trap | 第 1 個 | 第 2 個 | 第 3 個 | 第 4 個 |
|---|---|---|---|---|
get |
原物件 | 屬性名稱 | receiver | — |
set |
原物件 | 屬性名稱 | 要寫入的值 | receiver |
大家常用 key、value,只是因為物件本來就是 key: value 的結構,讀起來比較直覺。
那麼第三、第四個參數的 receiver 是什麼?這就要談到 Proxy 的好搭檔。
先看一個有 getter 的物件:
const parent = {
first: '爸爸',
get title() {
return this.first + '的物件'
}
}
現在把它包成 Proxy,並讓另一個物件 child 繼承這個 Proxy:
const p = new Proxy(parent, {
get(target, key) {
return target[key]
}
})
const child = Object.create(p)
child.first = '小孩'
child.title // ?
child.title 會是什麼?
答案是 '爸爸的物件'。
因為 target[key] 是直接對 target(也就是 parent)讀取,getter 裡的 this 就被固定成 parent,而不是真正發起讀取的 child。
這時候就輪到 receiver 上場,它代表「真正發起這次操作的物件」。搭配 Reflect.get,就能把正確的 this 傳下去:
const p = new Proxy(parent, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
}
})
const child = Object.create(p)
child.first = '小孩'
child.title // '小孩的物件'
Reflect 是 JS 內建的物件,每個 trap 都有一個同名的 Reflect 方法,參數也一模一樣。它們做的事情就是「這個操作原本的預設行為」。
所以前面的 set 可以改寫得更乾淨:
set(target, key, value, receiver) {
if (key === 'age' && (!Number.isInteger(value) || value < 0 || value > 150)) {
throw new TypeError('age 要是 0~150 的整數')
}
return Reflect.set(target, key, value, receiver)
}
Reflect.set 會幫你存值,也會回傳正確的 true 或 false,一行解決前面容易漏掉的兩件事。
寫 Proxy 的好習慣:只攔你在意的部分,其他原封不動交給 Reflect。
前面提到 Object.defineProperty 攔不到 delete 和 in,Proxy 則都有對應的 trap:
| trap | 被什麼觸發 |
|---|---|
get |
obj.x、obj[x] |
set |
obj.x = 1 |
has |
'x' in obj |
deleteProperty |
delete obj.x |
ownKeys |
Object.keys(obj)、for...in |
apply |
target 是函式時,呼叫 fn() |
而且因為守門人管的是整個物件,之後才新增的屬性一樣會被攔截。前面 defineProperty 的三個麻煩,Proxy 全部解決了。
終於可以回答開頭的問題了。
「改資料,畫面就更新」拆開來,其實是兩件事:
讀取和修改,剛好就是 get 和 set 能攔到的時機。我們來寫一個非常簡化的版本:
let activeEffect = null
const deps = new Map()
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
// 1. 讀取時:記下是誰在用這個 key
if (activeEffect) {
if (!deps.has(key)) deps.set(key, new Set())
deps.get(key).add(activeEffect)
}
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
// 2. 修改時:通知用到這個 key 的函式重新執行
deps.get(key)?.forEach(fn => fn())
return result
}
})
}
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
實際用用看:
<p id="count"></p>
<button id="btn">+1</button>
const state = reactive({ count: 0 })
effect(() => {
document.querySelector('#count').textContent = state.count
})
document.querySelector('#btn').addEventListener('click', () => {
state.count++
})
流程是這樣的:
effect 先執行一次更新畫面的函式,函式裡讀了 state.count
get,把這個函式記在 count 底下state.count++ 觸發 set
set 找出所有用到 count 的函式,重新執行,畫面就更新了整個過程中,我們沒有手動呼叫任何更新畫面的程式,只是改了資料。
當然這個版本非常陽春,只能處理單一物件、沒有處理巢狀物件,也沒有批次更新。但 Vue 3 的 reactive() 核心概念就是這樣:用 get 收集依賴,用 set 觸發更新。
Object.defineProperty 是改造單一屬性,Proxy 是在整個物件前面放守門人new Proxy(target, handler),handler 裡的 trap 決定要攔哪些操作Reflect 方法,並記得傳 receiver
明天 Day 4 會正式進入 Vue:既然有了 reactive,為什麼 Vue 還需要 ref?