在我們建構響應式系統的過程中,雖然對於原生 JavaScript 物件的處理已經算蠻完善,但陣列 (Array) 與普通物件的屬性不同,陣列的 length
屬性與其數值索引之間有緊密的聯動關係。
最直接改變陣列長度的方式就是手動賦值 。雖然這在日常開發中不被鼓勵,但一個健全的響應式系統必須能正確處理這種情況 。
<body>
<div id="app"></div>
<script type="module">
// import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'
const state = reactive(['a', 'b', 'c','d'])
effect(() => {
console.log(state.length)
})
setTimeout(() => {
state.length = 2
}, 1000)
</script>
</body>
在上述範例中,我們直接修改了陣列長度,它會觸發更新(通常我們會避免直接更改陣列長度的做法)。
像這樣直接更改陣列長度,多餘長度的數值會被刪除,如下圖:
<body>
<div id="app"></div>
<script type="module">
// import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'
const state = reactive(['a', 'b', 'c','d'])
effect(() => {
console.log(state[3])
})
setTimeout(() => {
state.length = 2
}, 1000)
</script>
</body>
執行這段程式碼,控制台會先輸出 d
,這符合預期。然而,一秒後當state.length
被修改為 2 時,console.log
並沒有再次執行 。
然而,問題出在哪裡?
我們的 effect
依賴的是 state[3]
。當 state.length
被修改為 2 時,索引為 3 的元素實際上已經被刪除了。
因此,這個 effect
所依賴的 key ('3')
後續不會再發生任何 set
行為,導致它再也沒有機會被重新觸發。依賴關係因此遺失。
在進行「刪除」操作,僅觸發了 state
物件 length
屬性的 set
,並未觸發索引 '3'
的 set
。
所以我們需要做的是,當 length
被短時,我們要找出所有依賴「被刪除索引」的 effect
,並通知它們重新執行 :
//dep.ts
export function trigger(target, key) {
const depsMap = targetMap.get(target)
// 如果 depsMap 不存在,表示沒有收集過依賴,直接返回
if (!depsMap) return
const targetIsArray = Array.isArray(target)
if(targetIsArray && key === 'length') {
depsMap.forEach((dep, depKey) => {
if(depKey >=length || depKey === 'length') {
// 通知訪問大於等於 length 的 effect 以及 訪問了 length 的 effect 重新執行
propagate(dep.subs)
}
})
}else{
// 如果不是陣列,並且更新的不是length,則直接取得依賴
const dep = depsMap.get(key)
// 如果依賴不存在,表示這個 key 沒有在effect中被使用過,直接返回
if (!dep) return
// 找到依賴,觸發更新
propagate(dep.subs)
}
}
state.length = 2
時,effect
會被重新觸發,現在看我們的範例程式碼,會發現觸發更新結果是 undefined
,因為state[3]
不存在。
會影響陣列長度除了直接賦值之外,像是我們常用的陣列方法:pop
、push
、shift
等等,都會隱性地影響到陣列長度:
<body>
<div id="app"></div>
<script type="module">
// import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'
const state = reactive(['a', 'b', 'c','d'])
effect(() => {
console.log(state.length)
})
setTimeout(() => {
state.push('e')
}, 1000)
</script>
</body>
這邊可以看到我增加了一個索引,但是依賴 length
的 effect
並沒有觸發更新:
那麼,當 push
這類方法被呼叫時,我們如何偵測到 length
的隱性變更呢?
關鍵在於攔截 set
操作。push('e')
的底層操作,除了在索引 4
上設置新值,也會修改 length
屬性。
我們可以在 set
代理中,比較操作前後的陣列長度,如果不一致,就主動觸發 length
屬性的依賴更新:
//baseHandlers.ts
export const mutableHandlers = {
...
...
set(target, key, newValue, receiver) {
const oldValue = target[key]
const targetIsArray = Array.isArray(target)
// 如果 target 是陣列,取得其舊長度
const oldLength = targetIsArray ? target.length : 0
const res = Reflect.set(target, key, newValue, receiver)
if(isRef(oldValue) && !isRef(newValue)){
oldValue.value = newValue
// 改了 ref 的值,會通知 sub 更新
// 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次
return res
}
if(hasChanged(newValue, oldValue)){
// 如果舊值不等於新值,則觸發更新
// 觸發更新:通知之前收集的依賴,重新執行effect
trigger(target, key)
}
// 如果 target 是陣列,取得其新長度
const newLength = targetIsArray ? newValue.length : 0
// 如果 target 是陣列,並且新長度不等於舊長度,並且 key 不是 length,則觸發更新
if(targetIsArray && newLength !== oldLength && key !== 'length'){
trigger(target, 'length')
}
return res
}
}
在觸發更新前,我們取得新值跟舊值:
今天我們聚焦於陣列 length
屬性的特殊性。透過分別在 trigger
函式和 set
代理中增加特殊的處理邏輯:
trigger
中:處理了手動縮短 length
時,對已刪除索引的依賴觸發。set
中:處理了陣列方法隱性改變 length
時,對 length
屬性的依賴觸發。同步更新《嘿,日安!》技術部落格