到目前為止,我們的 effect 會在依賴的資料發生變化時,會立刻重新執行。 這種簡單直接的模式在很多情況下都有效,但當遇到密集且連續性的資料變更時,它可能會引發不必要的效能問題。
為什麼需要 Effect 調度器?
1const count = ref(0)2effect(() => {3console.log('渲染元件:', count.value)4//複雜的 DOM 操作5})// 連續修改6count.value = 1 // 觸發渲染7count.value = 2 // 又觸發渲染8count.value = 3 // 再次觸發渲染
在上方案例我們可以看到,如果有複雜的 DOM 操作,造成連續觸發重新渲染三次,但其實我們只要最後一次,這時候我們就需要調度器處理。
什麼是 Effect 調度器?
調度器是一個控制 effect 執行時機的機制:
- 沒有調度器:資料變化 → 立即執行 effect
- 有調度器:資料變化 → 調度器決定何時/如何執行
特性
避免同步連續觸發多次更新
1// 避免同步連續觸發多次更新2const scheduler = (job) => {3 Promise.resolve().then(job) // 下一個微任務執行4}5
6effect(() => {7 console.log(count.value)8}, { scheduler })9
10count.value = 1 // 不會立即執行11count.value = 2 // 不會立即執行12count.value = 3 // 只有最後一次會在微任務中執行
Vue 元件更新調度
1effect(() => {2 // 元件渲染邏輯3}, {4 scheduler: queueJob // 加入更新隊列,而不是立即更新5})
防抖、節流
1const debounceScheduler = debounce((job) => job(), 100)2
3effect(() => {4 // 高頻觸發的邏輯5}, { scheduler: debounceScheduler })
調度器用法
1import { ref, effect } from '../dist/reactivity.esm.js'2
3const count = ref(0)4
5effect(()=>{6 console.log('Effect', count.value)7},{8 scheduler(){9 console.log('觸發調度器')10 }11})12
13setTimeout(()=>{14count.value = 115}, 1000)
目前效果
1Effect 02Effect 1
預期效果
使用調度器:
setTimeout
賦值不再輸出
1Effect 02// 觸發調度器,因此一秒後不輸出 'Effect', 1
Class 類別知識補充
要實現這個選用的調度器,我們需要利用 JavaScript Class 的特性。
我們先來補充這個知識:
- 一般的類別
1class Person{2 constructor(name){3 this.name = name4 }5
6 sayHi(){7 console.log('我是原型方法', this.name)8 }9}10
11const p = new Person('張三')12
13p.sayHi()14// 輸出:我是原型方法 張三
建立實例屬性被覆蓋
- 先建立實例
p
sayHi
方法重新賦予,於是被覆蓋- 因為實例屬性的方法優於原型屬性的方法
- 如果沒有實例方法,才會往原型鏈去找原型方法
1class Person{2 constructor(name){3 this.name = name4 }5
6 sayHi(){7 console.log('我是原型方法', this.name)8 }9}10
11const p = new Person('張三')12
13p.sayHi = function(){14 console.log('我是實例屬性', this.name)15}3 collapsed lines
16
17p.sayHi()18// 輸出:我是實例屬性 張三
實作調度器
要求
- 更新時,觸發
scheduler
- 沒有傳入
scheduler
,仍然要執行run()
實作思路
- 使用者傳入 scheduler 方法,此為可選方法
- 因此 scheduler 有可能會被覆蓋(可參考剛剛提到的 Class 類別知識補充)
- 為了保證觸發更新可以正常實作,新建一個
notify
方法
1export let activeSub;2
3export class ReactiveEffect {4 constructor(public fn){5
6 }7
8 run(){9 const prevSub = activeSub10 activeSub = this11
12 try{13 return this.fn()14
15 }finally{44 collapsed lines
16 activeSub = prevSub17 }18 }19
20 /*21 * 如果依賴資料發生變化,通知更新。22 */23 notify(){24 this.scheduler()25 }26
27 /*28 * 預設調用 run 方法,29 * 如果用戶傳入覆蓋調用器,那以用戶的為主30 * 因為實例屬性優於原型屬性31 */32
33 scheduler(){34 this.run()35 }36
37}38
39export function effect(fn, options){40
41 const e = new ReactiveEffect(fn)42 // scheduler43 Object.assign(e, options)44 e.run()45
46 /*47 * 綁定 this48 * 也可替換為 const runner = () => e.run()49 * 但不能使用 const runner = e.run()50 * 會遺失 this51 */52 const runner = e.run.bind(e)53
54 //將 effect 實例,放入函式屬性55 runner.effect = e56
57 return runner58
59}
propagate
函式中更改為執行 notify
方法,確保可以執行
1export function propagate (subs){2 ....3 // 更改為執行 notify 方法4 // 因為 scheduler 方法有可能會被覆蓋5 // 因此使用 notify 確保可以執行6 queuedEffect.forEach(effect => effect.notify())7}
為什麼回傳 runner 時需要 e.run.bind(e)
這麼處理?
如果直接回傳e.run
會發生什麼?這就涉及到了 this 指向問題。
遺失 this 是指什麼
請參考下方範例:
1export function effect(fn, options){2 const e = new ReactiveEffect(fn)3 Object.assign(e, options)4 e.run()5
6 return e.run //遺失 this7}8
9// 使用時10const runner = effect(() => console.log('effect'))11runner() // Error! this 是 undefined 或 window
圖解 notify()
執行步驟
執行原型方法(左)
effect 的預設行為,當我們像這樣使用它時:
effect(() => { ... })
-
資料變化 →
propagate
: 當響應式資料的值被修改時,會觸發其 setter,最終由propagate
函式開始遍歷依賴資料的effect。
-
propagate
→effect.notify()
在propagate
的迴圈裡面,我們統一呼叫effect.notify()
,讓它作為更新的固定入口點。 -
effect.notify()
→scheduler()
notify()
內部會去呼叫this.scheduler()
。在這個情況下,因為我們建立effect
時沒有提供任何 options,所以effect
實例上並不存在自己的scheduler
屬性。 -
scheduler()
→run()
根據 JavaScript 的原型鏈規則,它會回去尋找 ReactiveEffect 原型上的scheduler()
方法。 我們預設的scheduler()
方法,就是直接呼叫this.run()
。因此,effect
的核心邏輯被立即執行。
使用調度器(右)
-
資料變化 →
propagate
→effect.notify()
前三個步驟跟原形狀況完全相同:資料變更,propagate
遍歷並呼叫effect.notify()
。 -
effect.notify()
→scheduler()
notify()
內部會呼叫this.scheduler()
。但關鍵在於,這次我們在建立effect
時,透過options
傳入了一個自訂的scheduler
函式。
因此 Object.assign(e, options)
這個會把函式作為一個實例屬性附加到 effect
物件上。
scheduler()
→ 使用者的調度器邏輯 根據 JavaScript 的優先級,實例屬性大於原型方法。因此,JavaScript 在 effect 實例上直接找到這個自訂的 scheduler 並執行。
今天,我們透過引入調度器,將 effect 的核心邏輯以及執行策略進行了分離,當中包含 fn
做了什麼,以及 scheduler
決定什麼時候做。