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

前言#

watch 是由用户定义的数据监听,当监听的属性发生改变就会触发回调,这项配置在业务中是很常用。在面试时,也是必问知识点,一般会用作和 computed 进行比较。

那么本文就来带大家从源码理解 watch 的工作流程,以及依赖收集和深度监听的实现。在此之前,希望你能对响应式原理流程、依赖收集流程有一些了解,这样理解起来会更加轻松。

往期文章:

手摸手带你理解Vue响应式原理

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

watch 用法#

“知己知彼,才能百战百胜”,分析源码之前,先要知道它如何使用。这对于后面理解有一定的辅助作用。

第一种,字符串声明:

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

第二种,函数声明:

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

第三种,对象声明:

Copy
var vm = new Vue({ el: '#example', data: { peopel: { name: 'jojo', age: 15 } }, watch: { // 字段可使用点操作符 监听对象的某个属性 'people.name': { handler: function (newVal, oldVal) { /* ... */ } } } })
Copy
watch: { people: { handler: function (newVal, oldVal) { /* ... */ }, // 回调会在监听开始之后被立即调用 immediate: true, // 对象深度监听 对象内任意一个属性改变都会触发回调 deep: true } }

第四种,数组声明:

Copy
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) { /* ... */ } } })

工作流程#

入口文件:

Copy
// 源码位置:/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:

Copy
// 源码位置:/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:

Copy
// 源码位置:/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:

Copy
// 源码位置:/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:

Copy
// 源码位置:/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:

Copy
// 源码位置:/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 的逻辑,这里面也是依赖收集和更新的触发点。接下来看看这里面会有哪些操作。

依赖收集

Copy
// 源码位置:/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 都进行保存,这不是重点。让我们来关注下这段代码:

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

传进来的 expOrFnwatch 的键值,因为键值可能是 obj.a.b,需要调用 parsePath 对键值解析,这一步也是依赖收集的关键点。它执行后返回的是一个函数,先不着急 parsePath 做的是什么,先接着流程继续走。

下一步就是调用 get:

Copy
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 的逻辑。

Copy
// 源码位置:/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 回调。例如:

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

不仅修改 c 会触发回调,修改 ba 以及 obj 同样触发回调。这个设计也是很妙,通过简单的循环去为每一项都收集到了依赖。

更新

在更新时首先触发的是“数据劫持set”,调用 dep.notify 通知每一个 watcherupdate 方法。

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

接着就走 queueWatcher 进行异步更新,这里先不讲异步更新。只需要知道它最后会调用的是 run 方法。

Copy
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 函数,其中有一段代码是这样的:

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

判断是否需要深度监听,调用 traverse 并将值传入

Copy
// 源码位置:/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 的一部分,这里也讲下它的原理。

使用

先来看看它的用法:

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

使用 $watch 监听数据后,会返回一个对应的卸载监听函数。顾名思义,调用它当然就是不会再监听数据。

原理

Copy
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

Copy
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 有个奇怪的地方,导致它的用法是可以这样的:

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

一般 handler 是传递一个函数作为回调,但是对于对象类型,内部会进行递归去获取,直到值为函数。所以你可以无限套娃传对象。

递归的点在 $watch 中的这段代码:

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

如果你知道这段代码的实际应用场景麻烦告诉我一下,嘿嘿~

总结#

watch 监听实现利用遍历获取属性,触发“数据劫持get”逐个收集依赖,这样做的好处是其上级的属性发生修改也能执行回调。

datacomputed 不同,watch 收集依赖的流程是发生在页面渲染之前,而前两者是在页面渲染时进行取值才会收集依赖。

在面试时,如果被问到 computedwatch 的异同,我们可以从下面这些点进行回答:

  • 一是 computed 要依赖 data 上的属性变化返回一个值,watch 则是观察数据触发回调;
  • 二是 computedwatch 依赖收集的发生点不同;
  • 三是 computed 的更新需要“渲染Watcher”的辅助,watch 不需要,这点在我的上一篇文章有提到。
posted @   WahFung  阅读(1398)  评论(5编辑  收藏  举报
编辑推荐:
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
阅读排行:
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
Live2D
CONTENTS
点击右上角即可分享
微信分享提示