手摸手帶你理解Vue的Watch原理

来源:https://www.cnblogs.com/chanwahfung/archive/2020/06/29/13210167.html

前言 watch 是由用戶定義的數據監聽,當監聽的屬性發生改變就會觸發回調,這項配置在業務中是很常用。在面試時,也是必問知識點,一般會用作和 computed 進行比較。 那麼本文就來帶大家從源碼理解 watch 的工作流程,以及依賴收集和深度監聽的實現。在此之前,希望你能對響應式原理流程、依賴收集 ...


前言

watch 是由用戶定義的數據監聽,當監聽的屬性發生改變就會觸發回調,這項配置在業務中是很常用。在面試時,也是必問知識點,一般會用作和 computed 進行比較。

那麼本文就來帶大家從源碼理解 watch 的工作流程,以及依賴收集和深度監聽的實現。在此之前,希望你能對響應式原理流程、依賴收集流程有一些瞭解,這樣理解起來會更加輕鬆。

往期文章:

手摸手帶你理解Vue響應式原理

手摸手帶你理解Vue的Computed原理

watch 用法

“知己知彼,才能百戰百勝”,分析源碼之前,先要知道它如何使用。這對於後面理解有一定的輔助作用。

第一種,字元串聲明:

var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  watch: {
    message: 'handler'
  },
  methods: {
    handler (newVal, oldVal) { /* ... */ }
  }
})

第二種,函數聲明:

var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  watch: {
    message: function (newVal, oldVal) { /* ... */ }
  }
})

第三種,對象聲明:

var vm = new Vue({
  el: '#example',
  data: {
    peopel: {
      name: 'jojo',
      age: 15
    }
  },
  watch: {
    // 欄位可使用點操作符 監聽對象的某個屬性
    'people.name': {
      handler: function (newVal, oldVal) { /* ... */ }
    }
  }
})
watch: {
  people: {
    handler: function (newVal, oldVal) { /* ... */ },
    // 回調會在監聽開始之後被立即調用
    immediate: true,
    // 對象深度監聽  對象內任意一個屬性改變都會觸發回調
    deep: true
  }
}

第四種,數組聲明:

var vm = new Vue({
  el: '#example',
  data: {
    peopel: {
      name: 'jojo',
      age: 15
    }
  },
  // 傳入回調數組,它們會被逐一調用
  watch: {
    'people.name': [
      'handle',
      function handle2 (newVal, oldVal) { /* ... */ },
      {
        handler: function handle3 (newVal, oldVal) { /* ... */ },
      }
    ],  
  },
  methods: {
    handler (newVal, oldVal) { /* ... */ }
  }
})

工作流程

入口文件:

// 源碼位置:/src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options) {
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

_init:

// 源碼位置:/src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      // mergeOptions 對 mixin 選項和 new Vue 傳入的 options 選項進行合併
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }

    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    // 初始化數據
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

initState:

// 源碼位置:/src/core/instance/state.js 
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  // 這裡會初始化 watch
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initWatch:

// 源碼位置:/src/core/instance/state.js 
function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      // 1
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      // 2
      createWatcher(vm, key, handler)
    }
  }
}
  1. 數組聲明的 watch 有多個回調,需要迴圈創建監聽
  2. 其他聲明方式直接創建

createWatcher:

// 源碼位置:/src/core/instance/state.js 
function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  // 1
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  // 2
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  // 3
  return vm.$watch(expOrFn, handler, options)
}
  1. 對象聲明的 watch,從對象中取出對應回調
  2. 字元串聲明的 watch,直接取實例上的方法(註:methods 中聲明的方法,可以在實例上直接獲取)
  3. expOrFnwatchkey 值,$watch 用於創建一個“用戶Watcher

所以在創建數據監聽時,除了 watch 配置外,也可以調用實例的 $watch 方法實現同樣的效果。

$watch:

// 源碼位置:/src/core/instance/state.js
export function stateMixin (Vue: Class<Component>) {
  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    // 1
    options = options || {}
    options.user = true
    // 2
    const watcher = new Watcher(vm, expOrFn, cb, options)
    // 3
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
      }
    }
    // 4
    return function unwatchFn () {
      watcher.teardown()
    }
  }
}

stateMixin 在入口文件就已經調用了,為 Vue 的原型添加 $watch 方法。

  1. 所有“用戶Watcher”的 options,都會帶有 user 標識
  2. 創建 watcher,進行依賴收集
  3. immediate 為 true 時,立即調用回調
  4. 返回的函數可以用於取消 watch 監聽

依賴收集及更新流程

經過上面的流程後,最終會進入 new Watcher 的邏輯,這裡面也是依賴收集和更新的觸發點。接下來看看這裡面會有哪些操作。

依賴收集

// 源碼位置:/src/core/observer/watcher.js
export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    // options
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.value = this.lazy
      ? undefined
      : this.get()
  }
}

Watcher 構造函數內,對傳入的回調和 options 都進行保存,這不是重點。讓我們來關註下這段代碼:

if (typeof expOrFn === 'function') {
  this.getter = expOrFn
} else {
  this.getter = parsePath(expOrFn)
}

傳進來的 expOrFnwatch 的鍵值,因為鍵值可能是 obj.a.b,需要調用 parsePath 對鍵值解析,這一步也是依賴收集的關鍵點。它執行後返回的是一個函數,先不著急 parsePath 做的是什麼,先接著流程繼續走。

下一步就是調用 get:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

pushTarget 將當前的“用戶Watcher”(即當前實例this) 掛到 Dep.target 上,在收集依賴時,找的就是 Dep.target。然後調用 getter 函數,這裡就進入 parsePath 的邏輯。

// 源碼位置:/src/core/util/lang.js
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

參數 objvm 實例,segments 是解析後的鍵值數組,迴圈去獲取每項鍵值的值,觸發它們的“數據劫持get”。接著觸發 dep.depend 收集依賴(依賴就是掛在 Dep.targetWatcher)。

到這裡依賴收集就完成了,從上面我們也得知,每一項鍵值都會被觸發依賴收集,也就是說上面的任何一項鍵值的值發生改變都會觸發 watch 回調。例如:

watch: {
    'obj.a.b.c': function(){}
}

不僅修改 c 會觸發回調,修改 ba 以及 obj 同樣觸發回調。這個設計也是很妙,通過簡單的迴圈去為每一項都收集到了依賴。

更新

在更新時首先觸發的是“數據劫持set”,調用 dep.notify 通知每一個 watcherupdate 方法。

update () {
  if (this.lazy) { dirty置為true
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

接著就走 queueWatcher 進行非同步更新,這裡先不講非同步更新。只需要知道它最後會調用的是 run 方法。

run () {
  if (this.active) {
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      // set new value
      const oldValue = this.value
      this.value = value
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

this.get 獲取新值,調用 this.cb,將新值舊值傳入。

深度監聽

深度監聽是 watch 監聽中一項很重要的配置,它能為我們觀察對象中任何一個屬性的變化。

目光再拉回到 get 函數,其中有一段代碼是這樣的:

if (this.deep) {
  traverse(value)
}

判斷是否需要深度監聽,調用 traverse 並將值傳入

// 源碼位置:/src/core/observer/traverse.js
const seenObjects  = new Set()

export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  const isA = Array.isArray(val)
  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  if (val.__ob__) {
    // 1
    const depId = val.__ob__.dep.id
    // 2
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  // 3
  if (isA) {
    i = val.length
    while (i--) _traverse(val[i], seen)
  } else {
    keys = Object.keys(val)
    i = keys.length
    while (i--) _traverse(val[keys[i]], seen)
  }
}
  1. depId 是每一個被觀察屬性都會有的唯一標識
  2. 去重,防止相同屬性重覆執行邏輯
  3. 根據數組和對象使用不同的策略,最終目的是遞歸獲取每一項屬性,觸發它們的“數據劫持get”收集依賴,和 parsePath 的效果是異曲同工

從這裡能得出,深度監聽利用遞歸進行監聽,肯定會有性能損耗。因為每一項屬性都要走一遍依賴收集流程,所以在業務中儘量避免這類操作。

卸載監聽

這種手段在業務中基本很少用,也不算是重點,屬於那種少用但很有用的方法。它作為 watch 的一部分,這裡也講下它的原理。

使用

先來看看它的用法:

data(){
  return {
    name: 'jojo'
  }
}
mounted() {
  let unwatchFn = this.$watch('name', () => {})
  setTimeout(()=>{
    unwatchFn()
  }, 10000)
}

使用 $watch 監聽數據後,會返回一個對應的卸載監聽函數。顧名思義,調用它當然就是不會再監聽數據。

原理

Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {}
  options.user = true
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    try {
      // 立即調用 watch
      cb.call(vm, watcher.value)
    } catch (error) {
      handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
    }
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}

可以看到返回的 unwatchFn 里實際執行的是 teardown

teardown () {
  if (this.active) {
    if (!this.vm._isBeingDestroyed) {
      remove(this.vm._watchers, this)
    }
    let i = this.deps.length
    while (i--) {
      this.deps[i].removeSub(this)
    }
    this.active = false
  }
}

teardown 里的操作也很簡單,遍歷 deps 調用 removeSub 方法,移除當前 watcher 實例。在下一次屬性更新時,也不會通知 watcher 更新了。deps 存儲的是屬性的 dep

奇怪的地方

在看源碼時,我發現 watch 有個奇怪的地方,導致它的用法是可以這樣的:

watch:{
  name:{
    handler: {
      handler: {
        handler: {
          handler: {
            handler: {
              handler: {
                handler: ()=>{console.log(123)},
                immediate: true
              }
            }
          }
        }
      }
    }
  }
}

一般 handler 是傳遞一個函數作為回調,但是對於對象類型,內部會進行遞歸去獲取,直到值為函數。所以你可以無限套娃傳對象。

遞歸的點在 $watch 中的這段代碼:

if (isPlainObject(cb)) {
  return createWatcher(vm, expOrFn, cb, options)
}

如果你知道這段代碼的實際應用場景麻煩告訴我一下,嘿嘿~

總結

watch 監聽實現利用遍歷獲取屬性,觸發“數據劫持get”逐個收集依賴,這樣做的好處是其上級的屬性發生修改也能執行回調。

datacomputed 不同,watch 收集依賴的流程是發生在頁面渲染之前,而前兩者是在頁面渲染時進行取值才會收集依賴。

在面試時,如果被問到 computedwatch 的異同,我們可以從下麵這些點進行回答:

  • 一是 computed 要依賴 data 上的屬性變化返回一個值,watch 則是觀察數據觸發回調;
  • 二是 computedwatch 依賴收集的發生點不同;
  • 三是 computed 的更新需要“渲染Watcher”的輔助,watch 不需要,這點在我的上一篇文章有提到。

您的分享是我們最大的動力!

更多相關文章
  • Android自家的,又可以省去findviewbyid(),而且Butterknife上大神都已經推薦使用的,還有什麼理由不去改寫呢。build.gradle中開啟viewBinding功能。Activity 綁定private lateinit var mBinding: ActivityEbo... ...
  • 原文鏈接:https://www.cnblogs.com/qiyer/、https://www.cnblogs.com/qiyer/p/7442680.html ...
  • 序言 疫情基本控制,同時面試也漸漸開始了,以下iOS面試題僅供參考,畢竟面試是不可控的,但懂得越多,自然機會越大! 位元組一面內容: 1、 自我介紹 2、 介紹一下簡歷中的一個項目 3、 面向對象的三個要素 4、 多態? 5、 Java,python,OC運行效率孰高? 6、 Property,其中c ...
  • 新聞 谷歌發佈全新AR技術 單攝像頭即可實現AR景深感應 谷歌發佈首款基於Android 11開發者預覽版的Android TV版 代號“sabrina”:新Android TV電視棒將採用鵝卵石造型 Android 12曝光:谷歌欲全面拋棄對32位的支持 Android 11中的“對話”功能可能不 ...
  • Android app 本地設置信息的保存與調用。preferences.getString後面的文本是調用失敗後的預設顯示值。儲存值一定要實例化一個Editor出來,如果直接使用.edit().putString()不是不可以,但會每次調用都多出一個實例。最後記得要editor.apply()執行... ...
  • 基本類型(棧數據) String Number Boolean null undefined symbol(ES6) 引用類型(堆數據) Array Object Function Date RegExp 等 區分 棧小堆大 1.基礎類型是放置在棧裡面,一般基礎類型的數據都比較小,賦值不影響自身 v ...
  • vue是數據驅動視圖更新的框架, 所以對於vue來說組件間的數據通信非常重要,那麼組件之間如何進行數據通信的呢? 首先我們需要知道在vue中組件之間存在什麼樣的關係, 才更容易理解他們的通信方式, 就好像過年回家,坐著一屋子的陌生人,相互之間怎麼稱呼,這時就需要先知道自己和他們之間是什麼樣的關係。 ...
  • 延遲函數delay const delay = ms => new Promise((resolve, reject) => setTimeout(resolve, ms)) ​ const getData = status => new Promise((resolve, reject) => { ...
一周排行
  • 圖文講解,一門教學級邏輯式編程語言,NMiniKanren,的運行原理。 ...
  • 多Sheet導入教程 說明 本教程主要說明如何使用Magicodes.IE.Excel完成多個Sheet數據的Excel導入。 要點 多個相同格式的Sheet數據導入 多個不同格式的Sheet數據導入 主要步驟 1. 多個相同格式的Sheet數據導入 1.1 創建導入Sheet的Dto 主要代碼如下 ...
  • 在KeyPress事件中寫入 private void txtBoxKeyPress(object sender, KeyPressEventArgs e) { if ((e.KeyChar >= 'a' && e.KeyChar <= 'z') || (e.KeyChar >= 'A' && e. ...
  • 在 Xunit 中使用依賴註入 Intro 之前寫過一篇 xunit 的依賴註入相關的文章,但是實際使用起來不是那麼方便 今天介紹一個基於xunit和微軟依賴註入框架的“真正”的依賴註入使用方式 ——— Xunit.DependencyInjection, 來自大師的作品,讓你在測試代碼里使用依賴註 ...
  • 官網 http://www.hzhcontrols.com/ 前提 入行已經7,8年了,一直想做一套漂亮點的自定義控制項,於是就有了本系列文章。 GitHub:https://github.com/kwwwvagaa/NetWinformControl 碼雲:https://gitee.com/kww ...
  • 在項目的實際開發過程中,我們經常會遇到Tab頁面的開發 EciTab控制項有多種使用方式: 下麵介紹Frame容器方式: 下麵介紹的Tab頁面採用的策略是 Tab頁面管理幾個子頁面,頁面組織上用Iframe管理的模式 採用Iframe的原因主要有兩個 1.開發簡單,每一個頁面都是簡單的畫面 2.性能考 ...
  • 引用的DLL MySql.Data.MySqlClient System.Data City實體 public class City { public int ID { get; set; } public string Name { get; set; } public string Countr ...
  • 案例故事: 即時通訊(IM)軟體有很多,比如企業微信,釘釘,飛書,Skype, 微軟的Lync等, 這些軟體現在都很牛,還能監控誰誰在不在電腦旁工作,誰誰誰提前下班溜了。。。 一次偶然的機會,有個妹子請教我,她每天都想準時18點下班, 她問我如何做到: 假裝企業微信線上,併在2個小時後(20點)準時 ...
  • 一.官方文檔 https://pypi.org/project/muggle-ocr/ 二模塊安裝 pip install muggle-ocr # 因模塊過新,阿裡/清華等第三方源可能尚未更新鏡像,因此手動指定使用境外源,為了提高依賴的安裝速度,可預先自行安裝依賴:tensorflow/numpy ...
  • 前言 ​ 關於 Python 這個欄目,咕了幾個月了,今天講講如何發送驗證碼並驗證。 ​ 因為部分原因,寫這篇文章的時候心情是不太好的,播放首歌吧。 代碼 導入 導入yagmail,random和time庫 import yagmail,random,time #導入 yagmail , rando ...