1. 程式人生 > >手摸手帶你理解Vue的Watch原理

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

## 前言 `watch` 是由使用者定義的資料監聽,當監聽的屬性發生改變就會觸發回撥,這項配置在業務中是很常用。在面試時,也是必問知識點,一般會用作和 `computed` 進行比較。 那麼本文就來帶大家從原始碼理解 `watch` 的工作流程,以及依賴收集和深度監聽的實現。在此之前,希望你能對響應式原理流程、依賴收集流程有一些瞭解,這樣理解起來會更加輕鬆。 往期文章: [手摸手帶你理解Vue響應式原理](https://juejin.im/post/5ef010d6f265da0299791fa0) [手摸手帶你理解Vue的Computed原理](https://juejin.im/post/5ef54801e51d45348165cc4f) ## watch 用法 “知己知彼,才能百戰百勝”,分析原始碼之前,先要知道它如何使用。這對於後面理解有一定的輔助作用。 第一種,字串宣告: ```js var vm = new Vue({ el: '#example', data: { message: 'Hello' }, watch: { message: 'handler' }, methods: { handler (newVal, oldVal) { /* ... */ } } }) ``` 第二種,函式宣告: ```js var vm = new Vue({ el: '#example', data: { message: 'Hello' }, watch: { message: function (newVal, oldVal) { /* ... */ } } }) ``` 第三種,物件宣告: ```js var vm = new Vue({ el: '#example', data: { peopel: { name: 'jojo', age: 15 } }, watch: { // 欄位可使用點操作符 監聽物件的某個屬性 'people.name': { handler: function (newVal, oldVal) { /* ... */ } } } }) ``` ```js watch: { people: { handler: function (newVal, oldVal) { /* ... */ }, // 回撥會在監聽開始之後被立即呼叫 immediate: true, // 物件深度監聽 物件內任意一個屬性改變都會觸發回撥 deep: true } } ``` 第四種,陣列宣告: ```js 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) { /* ... */ } } }) ``` ## 工作流程 入口檔案: ```js // 原始碼位置:/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`: ```js // 原始碼位置:/src/core/instance/init.js export function initMixin (Vue: Class) { 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`: ```js // 原始碼位置:/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`: ```js // 原始碼位置:/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`: ```js // 原始碼位置:/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. `expOrFn` 是 `watch` 的 `key` 值,`$watch` 用於建立一個“使用者`Watcher`” 所以在建立資料監聽時,除了 `watch` 配置外,也可以呼叫例項的 `$watch` 方法實現同樣的效果。 `$watch`: ```js // 原始碼位置:/src/core/instance/state.js export function stateMixin (Vue: Class) { 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` 的邏輯,這裡面也是依賴收集和更新的觸發點。接下來看看這裡面會有哪些操作。 ### 依賴收集 ```js // 原始碼位置:/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` 都進行儲存,這不是重點。讓我們來關注下這段程式碼: ```js if (typeof expOrFn === 'function') { this.getter = expOrFn } else { this.getter = parsePath(expOrFn) } ``` 傳進來的 `expOrFn` 是 `watch` 的鍵值,因為鍵值可能是 `obj.a.b`,需要呼叫 `parsePath` 對鍵值解析,這一步也是依賴收集的關鍵點。它執行後返回的是一個函式,先不著急 `parsePath` 做的是什麼,先接著流程繼續走。 下一步就是呼叫 `get`: ```js 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` 的邏輯。 ```js // 原始碼位置:/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 } } ``` 引數 `obj` 是 `vm` 例項,`segments` 是解析後的鍵值陣列,迴圈去獲取每項鍵值的值,觸發它們的“資料劫持`get`”。接著觸發 `dep.depend` 收集依賴(依賴就是掛在 `Dep.target` 的 `Watcher`)。 到這裡依賴收集就完成了,從上面我們也得知,每一項鍵值都會被觸發依賴收集,也就是說上面的任何一項鍵值的值發生改變都會觸發 `watch` 回撥。例如: ```js watch: { 'obj.a.b.c': function(){} } ``` 不僅修改 `c` 會觸發回撥,修改 `b`、`a` 以及 `obj` 同樣觸發回撥。這個設計也是很妙,通過簡單的迴圈去為每一項都收集到了依賴。 ### 更新 在更新時首先觸發的是“資料劫持`set`”,呼叫 `dep.notify` 通知每一個 `watcher` 的 `update` 方法。 ```js update () { if (this.lazy) { dirty置為true this.dirty = true } else if (this.sync) { this.run() } else { queueWatcher(this) } } ``` 接著就走 `queueWatcher` 進行非同步更新,這裡先不講非同步更新。只需要知道它最後會呼叫的是 `run` 方法。 ```js 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` 函式,其中有一段程式碼是這樣的: ```js if (this.deep) { traverse(value) } ``` 判斷是否需要深度監聽,呼叫 `traverse` 並將值傳入 ```js // 原始碼位置:/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` 的一部分,這裡也講下它的原理。 ### 使用 先來看看它的用法: ```js data(){ return { name: 'jojo' } } mounted() { let unwatchFn = this.$watch('name', () =>
{}) setTimeout(()=>{ unwatchFn() }, 10000) } ``` 使用 `$watch` 監聽資料後,會返回一個對應的解除安裝監聽函式。顧名思義,呼叫它當然就是不會再監聽資料。 ### 原理 ```js 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`。 ```js 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` 有個奇怪的地方,導致它的用法是可以這樣的: ```js watch:{ name:{ handler: { handler: { handler: { handler: { handler: { handler: { handler: ()=>
{console.log(123)}, immediate: true } } } } } } } } ``` 一般 `handler` 是傳遞一個函式作為回撥,但是對於物件型別,內部會進行遞迴去獲取,直到值為函式。所以你可以無限套娃傳物件。 遞迴的點在 `$watch` 中的這段程式碼: ```js if (isPlainObject(cb)) { return createWatcher(vm, expOrFn, cb, options) } ``` 如果你知道這段程式碼的實際應用場景麻煩告訴我一下,嘿嘿~ ## 總結 `watch` 監聽實現利用遍歷獲取屬性,觸發“資料劫持`get`”逐個收集依賴,這樣做的好處是其上級的屬性發生修改也能執行回撥。 與 `data` 和 `computed` 不同,`watch` 收集依賴的流程是發生在頁面渲染之前,而前兩者是在頁面渲染時進行取值才會收集依賴。 在面試時,如果被問到 `computed` 和 `watch` 的異同,我們可以從下面這些點進行回答: * 一是 `computed` 要依賴 `data` 上的屬性變化返回一個值,`watch` 則是觀察資料觸發回撥; * 二是 `computed` 和 `watch` 依賴收集的發生點不同; * 三是 `computed` 的更新需要“渲染`Watcher`”的輔助,`watch` 不需要,這點在我的上一篇文章有