在我們建構響應式系統的過程中,雖然對於原生 JavaScript 物件的處理已經算蠻完善,但陣列 (Array) 與普通物件的屬性不同,陣列的 length
屬性與其數值索引之間有緊密的聯動關係。
手動變更陣列長度
最直接改變陣列長度的方式就是手動賦值 。雖然這在日常開發中不被鼓勵,但一個健全的響應式系統必須能正確處理這種情況 。
1<body>2 <div id="app"></div>3 <script type="module">4 // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive, effect } from '../dist/reactivity.esm.js'6
7 const state = reactive(['a', 'b', 'c','d'])8
9 effect(() => {10 console.log(state.length)11 })12
13 setTimeout(() => {14 state.length = 215 }, 1000)2 collapsed lines
16 </script>17</body>
在上述範例中,我們直接修改了陣列長度,它會觸發更新(通常我們會避免直接更改陣列長度的做法)。
像這樣直接更改陣列長度,多餘長度的數值會被刪除,如下圖:
1<body>2 <div id="app"></div>3 <script type="module">4 // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive, effect } from '../dist/reactivity.esm.js'6
7 const state = reactive(['a', 'b', 'c','d'])8
9 effect(() => {10 console.log(state[3])11 })12
13 setTimeout(() => {14 state.length = 215 }, 1000)2 collapsed lines
16 </script>17</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
,並通知它們重新執行 :
1export function trigger(target, key) {2 const depsMap = targetMap.get(target)3 // 如果 depsMap 不存在,表示沒有收集過依賴,直接返回4 if (!depsMap) return5
6 const targetIsArray = Array.isArray(target)7
8 if(targetIsArray && key === 'length') {9
10 depsMap.forEach((dep, depKey) => {11 if(depKey >=length || depKey === 'length') {12 // 通知訪問大於等於 length 的 effect 以及 訪問了 length 的 effect 重新執行13 propagate(dep.subs)14 }12 collapsed lines
15 })16 }else{17 // 如果不是陣列,並且更新的不是length,則直接取得依賴18 const dep = depsMap.get(key)19 // 如果依賴不存在,表示這個 key 沒有在effect中被使用過,直接返回20 if (!dep) return21
22 // 找到依賴,觸發更新23 propagate(dep.subs)24 }25
26}
state.length = 2
時,effect
會被重新觸發,現在看我們的範例程式碼,會發現觸發更新結果是 undefined
,因為state[3]
不存在。
陣列方法導致長度變更
會影響陣列長度除了直接賦值之外,像是我們常用的陣列方法:pop
、push
、shift
等等,都會隱性地影響到陣列長度:
1<body>2 <div id="app"></div>3 <script type="module">4 // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { reactive, effect } from '../dist/reactivity.esm.js'6
7 const state = reactive(['a', 'b', 'c','d'])8
9 effect(() => {10 console.log(state.length)11 })12
13 setTimeout(() => {14 state.push('e')15 }, 1000)2 collapsed lines
16 </script>17</body>
這邊可以看到我增加了一個索引,但是依賴 length
的 effect
並沒有觸發更新:
那麼,當 push
這類方法被呼叫時,我們如何偵測到 length
的隱性變更呢?
關鍵在於攔截 set
操作。push('e')
的底層操作,除了在索引 4
上設置新值,也會修改 length
屬性。
我們可以在 set
代理中,比較操作前後的陣列長度,如果不一致,就主動觸發 length
屬性的依賴更新:
1export const mutableHandlers = {2 ...3 ...4 set(target, key, newValue, receiver) {5 const oldValue = target[key]6 const targetIsArray = Array.isArray(target)7 // 如果 target 是陣列,取得其舊長度8 const oldLength = targetIsArray ? target.length : 09 const res = Reflect.set(target, key, newValue, receiver)10 if(isRef(oldValue) && !isRef(newValue)){11 oldValue.value = newValue12
13 // 改了 ref 的值,會通知 sub 更新19 collapsed lines
14 // 所以要 return 不然下方 trigger 又會觸發 trigger 更新 會觸發兩次15 return res16 }17 if(hasChanged(newValue, oldValue)){18 // 如果舊值不等於新值,則觸發更新19 // 觸發更新:通知之前收集的依賴,重新執行effect20 trigger(target, key)21 }22
23 // 如果 target 是陣列,取得其新長度24 const newLength = targetIsArray ? newValue.length : 025
26 // 如果 target 是陣列,並且新長度不等於舊長度,並且 key 不是 length,則觸發更新27 if(targetIsArray && newLength !== oldLength && key !== 'length'){28 trigger(target, 'length')29 }30 return res31 }32}
在觸發更新前,我們取得新值跟舊值:
- 當依賴是陣列類型
- 更新前的陣列長度跟更新後陣列長度不同
- 並且 key 不是 length,就觸發更新:避免重複觸發,因為我們剛剛在 trigger 函式已經寫了觸發更新。
今天我們聚焦於陣列 length
屬性的特殊性。透過分別在 trigger
函式和 set
代理中增加特殊的處理邏輯:
- 在
trigger
中:處理了手動縮短length
時,對已刪除索引的依賴觸發。 - 在
set
中:處理了陣列方法隱性改變length
時,對length
屬性的依賴觸發。