iT邦幫忙

2025 iThome 鐵人賽

DAY 9
1
Vue.js

從零到一打造 Vue3 響應式系統系列 第 9 篇

Day 9 - Effect:調度器實作應用

  • 分享至 

  • xImage
  •  

banner

到目前為止,我們的 effect 會在依賴的資料發生變化時,會立刻重新執行。
這種簡單直接的模式在很多情況下都有效,但當遇到密集且連續性的資料變更時,它可能會引發不必要的效能問題。

為什麼需要 Effect 調度器?

const count = ref(0)
effect(() => {
console.log('渲染元件:', count.value)
//複雜的 DOM 操作
})// 連續修改
count.value = 1  // 觸發渲染
count.value = 2  // 又觸發渲染
count.value = 3  // 再次觸發渲染

在上方案例我們可以看到,如果有複雜的 DOM 操作,造成連續觸發重新渲染三次,但其實我們只要最後一次,這時候我們就需要調度器處理。

什麼是 Effect 調度器?

調度器是一個控制 effect 執行時機的機制:

  • 沒有調度器:資料變化 → 立即執行 effect
  • 有調度器:資料變化 → 調度器決定何時/如何執行

特性

避免同步連續觸發多次更新

// 避免同步連續觸發多次更新
const scheduler = (job) => {
  Promise.resolve().then(job) // 下一個微任務執行
}

effect(() => {
  console.log(count.value)
}, { scheduler })

count.value = 1 // 不會立即執行
count.value = 2 // 不會立即執行
count.value = 3 // 只有最後一次會在微任務中執行

Vue 元件更新調度

effect(() => {
  // 元件渲染邏輯
}, {
  scheduler: queueJob // 加入更新隊列,而不是立即更新
})

防抖、節流

const debounceScheduler = debounce((job) => job(), 100)

effect(() => {
  // 高頻觸發的邏輯
}, { scheduler: debounceScheduler })

調度器用法

import { ref, effect } from '../dist/reactivity.esm.js'

const count = ref(0)

effect(()=>{
  console.log('Effect', count.value)
},{
  scheduler(){
    console.log('觸發調度器')
  }
})

setTimeout(()=>{
count.value = 1
}, 1000)

目前效果

Effect 0
Effect 1

預期效果

使用調度器:

  • setTimeout 賦值不再輸出
Effect 0
// 觸發調度器,因此一秒後不輸出 'Effect', 1

Class 類別知識補充

要實現這個選用的調度器,我們需要利用 JavaScript Class 的特性。

我們先來補充這個知識:

  • 一般的類別
class Person{
  constructor(name){
    this.name = name
  }

  sayHi(){
    console.log('我是原型方法', this.name)
  }
}

const p = new Person('張三')

p.sayHi()
// 輸出:我是原型方法 張三

建立實例屬性被覆蓋

  • 先建立實例 p
  • sayHi 方法重新賦予,於是被覆蓋
  • 因為實例屬性的方法優於原型屬性的方法
  • 如果沒有實例方法,才會往原型鏈去找原型方法
class Person{
  constructor(name){
    this.name = name
  }

  sayHi(){
    console.log('我是原型方法', this.name)
  }
}

const p = new Person('張三')

p.sayHi = function(){
  console.log('我是實例屬性', this.name)
}

p.sayHi()
// 輸出:我是實例屬性 張三

實作調度器

要求

  1. 更新時,觸發 scheduler
  2. 沒有傳入 scheduler,仍然要執行 run()

實作思路

  1. 使用者傳入 scheduler 方法,此為可選方法
  2. 因此 scheduler 有可能會被覆蓋(可參考剛剛提到的 Class 類別知識補充)
  3. 為了保證觸發更新可以正常實作,新建一個 notify 方法
export let activeSub;

export class ReactiveEffect { 
  constructor(public fn){
    
  }

  run(){
    const prevSub = activeSub
    activeSub = this
    
    try{
      return this.fn()
      
    }finally{
      activeSub = prevSub
    }   
  }

  /*
   * 如果依賴資料發生變化,通知更新。
   */
  notify(){
    this.scheduler()
  }
  
  /*
   * 預設調用 run 方法,
   * 如果用戶傳入覆蓋調用器,那以用戶的為主
   * 因為實例屬性優於原型屬性
   */
  
  scheduler(){
    this.run()
  }
  
}

export function effect(fn, options){

  const e = new ReactiveEffect(fn)
  // scheduler
  Object.assign(e, options)
  e.run()

  /*
   * 綁定 this
   * 1. 可替換為 const runner = () => e.run()
   * 2. 不能使用 const runner = e.run 
   * 因為當之後呼叫 runner() 時,會遺失 this
   */
  const runner = e.run.bind(e)

  //將 effect 實例,放入函式屬性
  runner.effect = e
  
  return runner
  
}

propagate函式中更改為執行 notify 方法,確保可以執行

export function propagate (subs){
  ....
  // 更改為執行 notify 方法
  // 因為 scheduler 方法有可能會被覆蓋
  // 因此使用 notify 確保可以執行
  queuedEffect.forEach(effect => effect.notify())
}

為什麼回傳 runner 時需要 e.run.bind(e)這麼處理?
如果直接回傳e.run會發生什麼?這就涉及到了 this 指向問題。

遺失 this 是指什麼

請參考下方範例:

export function effect(fn, options){
  const e = new ReactiveEffect(fn)
  Object.assign(e, options)
  e.run()
  
  return e.run  //遺失 this
}

// 使用時
const runner = effect(() => console.log('effect'))
runner() // Error! this 是 undefined 或 window

圖解 notify() 執行步驟

day09-01

執行原型方法(左)

effect 的預設行為,當我們像這樣使用它時:
effect(() => { ... })

  1. 資料變化 → propagate:
    當響應式資料的值被修改時,會觸發其 setter,最終由 propagate 函式開始遍歷依賴資料的 effect。

  2. propagate → effect.notify()
    在 propagate 的迴圈裡面,我們統一呼叫 effect.notify(),讓它作為更新的固定入口點。

  3. effect.notify() → scheduler()
    notify() 內部會去呼叫 this.scheduler()。在這個情況下,因為我們建立 effect 時沒有提供任何 options,所以 effect 實例上並不存在自己的 scheduler 屬性。

  4. scheduler() → run()
    根據 JavaScript 的原型鏈規則,它會回去尋找 ReactiveEffect 原型上的 scheduler() 方法。
    我們預設的 scheduler() 方法,就是直接呼叫 this.run()。因此,effect 的核心邏輯被立即執行。

使用調度器(右)

  1. 資料變化 → propagate → effect.notify()
    前三個步驟跟原形狀況完全相同:資料變更,propagate 遍歷並呼叫 effect.notify()。

  2. effect.notify() → scheduler()
    notify() 內部會呼叫 this.scheduler()。但關鍵在於,這次我們在建立 effect 時,透過 options 傳入了一個自訂的 scheduler 函式。

因此 Object.assign(e, options) 這個會把函式作為一個實例屬性附加到 effect 物件上。

  1. scheduler() → 使用者的調度器邏輯
    根據 JavaScript 的優先級,實例屬性大於原型方法。因此,JavaScript 在 effect 實例上直接找到這個自訂的 scheduler 並執行。

今天,我們透過引入調度器,將 effect 的核心邏輯以及執行策略進行了分離,當中包含 fn 做了什麼,以及 scheduler 決定什麼時候做。


同步更新《嘿,日安!》技術部落格


上一篇
Day 8 - Effect: 深入剖析巢狀 effect
下一篇
Day 10 - Effect:為何會被指數級觸發?
系列文
從零到一打造 Vue3 響應式系統 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
Dylan
iT邦研究生 2 級 ‧ 2025-09-18 10:56:09

但不能使用 const runner = e.run()

好奇這裡是不是要表達 const runner = e.run,因直接 e.run() 就不是遺失 this 的問題了@@

日安 iT邦新手 5 級 ‧ 2025-09-18 15:08:58 檢舉

你的理解正確,這邊我有筆誤,我修正一下寫法。

const runner = e.run:是把函式本身存起來,呼叫之後會遺失 this,這個才是我想講的。

const runner = e.run() :是把回傳結果存起來,這樣不能用 XD

0
leave3310
iT邦新手 5 級 ‧ 2025-11-11 08:55:37

有幾個問題想問
1.
queuedEffect.forEach(effect => effect.notify())
為什麼這邊要改成使用 notify,而不是使用原本的 run 呢?是因為為了要符合先前可以客製化的 scheduler 對吧?
2.

const runner = effect(() => console.log('effect'))
runner()

什麼情境下會直接呼叫這個 runner 呢?目前有點想不太到

日安 iT邦新手 5 級 ‧ 2025-11-28 16:13:28 檢舉
  1. 對,主要是要解決實例化的問題。
  2. 目前很少這樣使用,之所以舉這個例子,是因為如果我們要把 run 方法回傳出去。一旦開發者嘗試去執行runner,就會因為this遺失而出問題。

我要留言

立即登入留言