[VueJS] $watch 藏在原始碼裡的邊緣人

身為一個菜蟲,洗菜的時候抓到很多蟲應該超棒的(欸!只是說,有些蟲實在是很惱人,因為那也不算是蟲,只是個隱藏版的功能。至於為何沒有寫在文件裡?這就只能去問作者了!

注意!最新版本的 Vue 2.6.10 與本文部分原始碼可能會有所出入,但邏輯大部分相同。服用前請先詳閱公開說明書( 哪來的公開說明書 )。


$watch 的實際狀況與問題

文件裡面有特別寫到 $watch 的額外選項,

deep

immediate

這個就不多做解釋,可以去看官方文件

https://vuejs.org/v2/api/#vm-watch

我來聊一下關於 $watch 遇到的問題,

methods: {
  ...mapActions(['setTick'])
},
computed: {
  ...mapGetters(['getTick'])
},
created () {
  this.$watch(() => {
    return this.getTick
  }, (tick) {
    console.log(tick)
  })

  setTimeout(() => {
    this.setTick(1)
  }, 500)
}

這樣你會拿到 1,這是沒有問題的。接著,我們連續觸發兩次,

created () {
  this.$watch(() => {
    return this.getTick
  }, (tick) {
    console.log(tick)
  })

  setTimeout(() => {
    this.setTick(1)
    this.setTick(2)
  }, 500)
}

這樣你會拿到 2,看起來也是沒有問題的。但是,第一次呼叫的 1 呢?在連續觸發的情況下,$watch 只會認最後一次變更的結果,換句話說,如果連續觸發多次,只有最後一次的結果會被傳回 $watch 的回呼函示中。

created () {
  this.$watch(() => {
    return this.getTick
  }, (tick) {
    console.log(tick)
  })

  setTimeout(() => {
    this.setTick(1)
    this.setTick(2)
    this.setTick(3)
    this.setTick(4)
    this.setTick(5)
  }, 500)
}

最後,你會拿到 5 這個結果。如果這個 $watch 存在於 setTimeout 或是 rAF (requestAnimationFrame) 當中,則不會有這個問題,

created () {
  this.$watch(() => {
    return this.getTick
  }, (tick) {
    console.log(tick)
  })

  setTimeout(() => {
    this.setTick(1)
  })
  setTimeout(() => {
    this.setTick(2)
  })
  setTimeout(() => {
    this.setTick(3)
  })
  setTimeout(() => {
    this.setTick(4)
  })
  setTimeout(() => {
    this.setTick(5)
  })
}

這樣你就能拿到 1, 2, 3, 4, 5 這樣的結果。但,這樣好像挺蠢的。基於抓菜蟲的精神,所以我翻了一下 Vue 的原始碼,當中關於 Watcher 的地方,發現了幾個菜蟲。

$watch 原始碼追蹤

Watcher 這個元件中,除了官方提及的 deepimmediate 外,他還多了 user, lazysync 這三個項目。奇妙的是,immediate 並沒有在 Watcher 的初始化方法中被列出,反而是在 $watch 這個方法才出現(不知道為什麼?

  // options
  if (options) {
    this.deep = !!options.deep;
    this.user = !!options.user;
    this.lazy = !!options.lazy;
    this.sync = !!options.sync;
  } else {
    this.deep = this.user = this.lazy = this.sync = false;
  }

這三個文件當中沒有的設定,lazy, lazysync 到底功用為何?我們先來看看他的更新動作,

Watcher.prototype.update = function update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};

這裡用到了 lazysync,那麼 user 在哪裡呢?答案在 run 這個方法裡面,

Watcher.prototype.run = function run () {
  // ...中略
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue);
        } catch (e) {
          /* istanbul ignore else */
          if (config.errorHandler) {
            config.errorHandler.call(null, e, this.vm);
          } else {
            process.env.NODE_ENV !== 'production' && warn(
              ("Error in watcher \"" + (this.expression) + "\""),
              this.vm
            );
            throw e
          }
        }
      } else {
        this.cb.call(this.vm, value, oldValue);
      }
  // ...後略
}

那到底他們各自做了什麼事情?

lazy 設定

lazy 並不會影響外部動作,他僅是將內部的 dirty 給一個預設值,意思就是說,當你設定 lazy: true 的時候,這個 Watcher 在初始化的時候,自身的 dirty 就會被設定為 true

這件事情基本上跟 immediate 有點關連,舉例來說,

created () {
  this.$watch(() => {
    return this.getTick
  }, (tick) {
    console.log(tick)
  }, {
    immediate: true,
    lazy: true
  })

  setTimeout(() => {
    this.setTick(1)
  })

這樣的話,由於你設定了 immediate,所以在 $watch 初始化的時候會呼叫一次,接著,因為設定了 lazy,所以第一次呼叫會傳回 undefined。這件事情在初始化有數值的時候,是不適合這樣做的,舉例來說,

created () {
  this.$watch(() => {
    return this.tick
  }, (tick) {
    console.log(tick)
  }, {
    immediate: true,
    lazy: true
  })

  setTimeout(() => {
    this.tick(1)
  })
},
data () {
  return {
    tick: 0
  }
}

由於加上了 immediate,所以在整個 $watch 被初始化的同時,會先回傳一個 0,但是,因為加上 lazy 的關係,所以並不會是 0 而會回傳 undefined!而每次的 lazy 並不會被重新設定,而他也只會在 $watch 初始化時發生作用。

user 設定

這個設定挺神奇,感覺上就只是為了避免資料有非同步傳輸的情況,而誕生的一個設定值。他出現在 run 的方法當中,如果將 user 設定為 true 的同時,他並不會直接去呼叫回呼函示將數值傳回,而是包著 try { ... } catch (e) { } 的方式來避免一些例外情況發生(請看上面的原始碼。

也就是說,當你 { user: true } 的時候,他會試著呼叫回呼函示,如果錯誤了,就拋出一個異常,交給 Vue 的異常函示去處理後續動作(預設是 Vue.config.errorHandler,如果沒有設定,在正式站的環境下,會直接噴一個 throw 出去。

sync 設定

顧名思義,就是同步處理,也就是這次要解決 setTimeout 這個蠢方法的方式。舉例來說,

created () {
  this.$watch(() => {
    return this.getTick
  }, (tick) {
    console.log(tick)
  }, {
    sync: true
  })

  setTimeout(() => {
    this.setTick(1)
    this.setTick(2)
    this.setTick(3)
    this.setTick(4)
    this.setTick(5)
  }, 500)
}

這樣你就能拿到 1, 2, 3, 4, 5 這樣的結果,而不需要連續的 setTimeout 來達到目的。主要的方式在於 update 時,當遇到 sync === true 時,直接呼叫 run 方法,把數值輸出,而不會使用預設的 queueWatcher

所以,這個方法會在任何的變更時,都會呼叫 $watchrun 並且餵給回呼函示,將資料帶出去。當然,我想效能上可能會有低落的可能(畢竟每次都餵出去。

但是,我總覺得每次都餵出去才比較合乎我們凡人的想像,畢竟他的 queueWatcher 有個 很機歪 偷效能的地方。

為何預設方式無法連續觸發?

$watch 本身的 run 預設是直接進入 queueWatcher 去將 $watch 的東西做一個排程。所以連續呼叫的狀態下,有人會因為這個排程的執行而被剔除。我們先來看看 queueWatcher 的原始碼,

function queueWatcher (watcher) {
  var id = watcher.id;
  if (has$1[id] == null) {
    has$1[id] = true;
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      var i = queue.length - 1;
      while (i >= 0 && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(Math.max(i, index) + 1, 0, watcher);
    }
    // queue the flush
    if (!waiting) {
      waiting = true;
      nextTick(flushSchedulerQueue);
    }
  }
}

至此的連續呼叫其實都正常,但是,這個東西把連續呼叫給中斷了,

  if (has$1[id] == null) {
    has$1[id] = true;
    // ...中略
  }

這個 has$1Watcher 當中是屬於全域變數,要直到 flushSchedulerQueue 執行過了,或是 resetSchedulerState 執行之後才會被重新設定。那,感覺上應該是先執行了 setTick(1)setTick(2) 被忽略才對,為何剛好相反?

喔,因為這個 watcher 並不是不可變的元件 (Immutable) 的關係,所以你知道的(以下省略一萬字。

小結

這年頭菜蟲不好當啊(欸

Hina Chen
偏執與強迫症的患者,算不上是無可救藥,只是我已經遇上我的良醫了。
Taipei