【原始碼系列#04】Vue3偵聽器原理(Watch)

柏成發表於2023-12-26

專欄分享:vue2原始碼專欄vue3原始碼專欄vue router原始碼專欄玩具專案專欄,硬核💪推薦🙌
歡迎各位ITer關注點贊收藏🌸🌸🌸

語法

偵聽一個或多個響應式資料來源,並在資料來源變化時呼叫所給的回撥函式

const x = ref(0)
const y = ref(0)

// 單個 ref
watch(x, (newValue, oldValue) => {
  console.log(`x is ${newValue}`)
})

// getter 函式
watch(
  () => x.value + y.value,
  (newValue, oldValue) => {
    console.log(`sum of x + y is: ${newValue}`)
  }
)

// 多個來源組成的陣列
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

第一個引數可以是不同形式的“資料來源”:它可以是一個 ref (包括計算屬性)、一個響應式物件、一個 getter 函式、或多個資料來源組成的陣列

第二個引數是在發生變化時要呼叫的回撥函式。這個回撥函式接受三個引數:新值、舊值,以及一個用於註冊副作用清理的回撥函式。該回撥函式會在副作用下一次重新執行前呼叫,可以用來清除無效的副作用,例如等待中的非同步請求。

第三個可選的引數是一個物件,支援以下這些選項:

  • immediate:在偵聽器建立時立即觸發回撥。第一次呼叫時舊值是 undefined。
  • deep:如果源是物件,強制深度遍歷,以便在深層級變更時觸發回撥。參考深層偵聽器
  • flush:調整回撥函式的重新整理時機。參考回撥的重新整理時機watchEffect()
  • onTrack / onTrigger:除錯偵聽器的依賴。參考除錯偵聽器

原始碼實現

  • @issue1 深度遞迴迴圈時考慮物件中有迴圈引用的問題

  • @issue2 相容資料來源為響應式物件和getter函式的情況

  • @issue3 immediate回撥執行時機

  • @issue4 onCleanup該回撥函式會在副作用下一次重新執行前呼叫

/**
 * @desc 遞迴迴圈讀取資料
 * @issue1 考慮物件中有迴圈引用的問題
 */
function traversal(value, set = new Set()) {
  // 第一步遞迴要有終結條件,不是物件就不在遞迴了
  if (!isObject(value)) return value

  // @issue1 處理迴圈引用
  if (set.has(value)) {
    return value
  }
  set.add(value)

  for (let key in value) {
    traversal(value[key], set)
  }
  return value
}

/**
 * @desc watch
 * @issue2 相容資料來源為響應式物件和getter函式的情況
 * @issue3 immediate 立即執行
 * @issue4 onCleanup:用於註冊副作用清理的回撥函式。該回撥函式會在副作用下一次重新執行前呼叫,可以用來清除無效的副作用,例如等待中的非同步請求
 */
// source 是使用者傳入的物件, cb 就是對應的回撥
export function watch(source, cb, { immediate } = {} as any) {
  let getter

  // @issue2
  // 是響應式資料
  if (isReactive(source)) {
    // 遞迴迴圈,只要迴圈就會訪問物件上的每一個屬性,訪問屬性的時候會收集effect
    getter = () => traversal(source)
  } else if (isRef(source)) {
    getter = () => source.value
  } else if (isFunction(source)) {
    getter = source
  }else {
    return
  }

  // 儲存使用者的函式
  let cleanup
  const onCleanup = fn => {
    cleanup = fn
  }

  let oldValue
  const scheduler = () => {
    // @issue4 下一次watch開始觸發上一次watch的清理
    if (cleanup) cleanup()
    const newValue = effect.run()
    cb(newValue, oldValue, onCleanup)
    oldValue = newValue
  }

  // 在effect中訪問屬性就會依賴收集
  const effect = new ReactiveEffect(getter, scheduler) // 監控自己構造的函式,變化後重新執行scheduler

  // @issue3
  if (immediate) {
    // 需要立即執行,則立刻執行任務
    scheduler()
  }

  // 執行getter,讓getter中的每一個響應式變數都收集這個effect
  oldValue = effect.run()
}

測試程式碼

迴圈引用

物件中存在迴圈引用的情況

const person = reactive({
  name: '柏成',
  age: 25,
  address: {
    province: '山東省',
    city: '濟南市',
  }
})
person.self = person

watch(
  person,
  (newValue, oldValue) => {
    console.log('person', newValue, oldValue)
  }, {
    immediate: true
  },
)

資料來源

  1. 資料來源為 ref 的情況,和 immediate 回撥執行時機
const x = ref(1)

watch(
  x,
  (newValue, oldValue) => {
    console.log('x', newValue, oldValue)
  }, {
    immediate: true
  },
)

setTimeout(() => {
  x.value = 2
}, 100)
  1. 相容資料來源為 響應式物件getter函式 的情況,和 immediate 回撥執行時機
const person = reactive({
  name: '柏成',
  age: 25,
  address: {
    province: '山東省',
    city: '濟南市',
  }
})

// person.address 物件本身及其內部每一個屬性 都收集了effect。traversal遞迴遍歷
watch(
  person.address,
  (newValue, oldValue) => {
    console.log('person.address', newValue, oldValue)
  }, {
    immediate: true
  },
)

// 注意!我們在 watch 原始碼內部滿足了 isFunction 條件
// 此時只有 address 物件本身收集了effect,僅當 address 物件整體被替換時,才會觸發回撥;
// 其內部屬性發生變化並不會觸發回撥
watch(
  () => person.address,
  (newValue, oldValue) => {
    console.log('person.address', newValue, oldValue)
  }, {
    immediate: true
  },
)

// person.address.city 收集了 effect
watch(
  () => person.address.city,
  (newValue, oldValue) => {
    console.log('person.address.city', newValue, oldValue)
  }, {
    immediate: true
  },
)

setTimeout(() => {
  person.address.city = '青島市'
}, 100)

onCleanup

watch回撥函式接受三個引數:新值、舊值,以及一個用於註冊副作用清理的回撥函式(即我們的onCleanup)。該回撥函式會在副作用下一次重新執行前呼叫,可以用來清除無效的副作用,例如等待中的非同步請求。

const person = reactive({
  name: '柏成',
  age: 25
})

let timer = 3000
function getData(timer) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(timer)
    }, timer)
  })
}

// 1. 第一次呼叫watch的時候注入一個取消的回撥
// 2. 第二次呼叫watch的時候會執行上一次注入的回撥
// 3. 第三次呼叫watch會執行第二次注入的回撥
// 後面的watch觸發會將上次watch中的 clear 置為true
watch(
  () => person.age,
  async (newValue, oldValue, onCleanup) => {
    let clear = false
    onCleanup(() => {
      clear = true
    })

    timer -= 1000
    let res = await getData(timer) // 第一次執行2s後渲染2000, 第二次執行1s後渲染1000, 最終應該是1000
    if (!clear) {
      document.body.innerHTML = res
    }
  },
)

person.age = 26
setTimeout(() => {
  person.age = 27
}, 0)

相關文章