首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >手摸手带你理解Vue的Watch原理

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

作者头像
WahFung
发布于 2020-08-22 10:09:35
发布于 2020-08-22 10:09:35
1.8K02
代码可运行
举报
文章被收录于专栏:前端技术分享前端技术分享
运行总次数:2
代码可运行

前言

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

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

往期文章:

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

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

watch 用法

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

第一种,字符串声明:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  watch: {
    message: 'handler'
  },
  methods: {
    handler (newVal, oldVal) { /* ... */ }
  }
})

第二种,函数声明:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  watch: {
    message: function (newVal, oldVal) { /* ... */ }
  }
})

第三种,对象声明:

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

第四种,数组声明:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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) { /* ... */ }
  }
})

工作流程

入口文件:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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 的逻辑,这里面也是依赖收集和更新的触发点。接下来看看这里面会有哪些操作。

依赖收集

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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 都进行保存,这不是重点。让我们来关注下这段代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
if (typeof expOrFn === 'function') {
  this.getter = expOrFn
} else {
  this.getter = parsePath(expOrFn)
}

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

下一步就是调用 get:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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 的逻辑。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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 回调。例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
watch: {
    'obj.a.b.c': function(){}
}

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

更新

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
update () {
  if (this.lazy) { dirty置为true
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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 函数,其中有一段代码是这样的:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
if (this.deep) {
  traverse(value)
}

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 源码位置:/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 的一部分,这里也讲下它的原理。

使用

先来看看它的用法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
data(){
  return {
    name: 'jojo'
  }
}
mounted() {
  let unwatchFn = this.$watch('name', () => {})
  setTimeout(()=>{
    unwatchFn()
  }, 10000)
}

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

原理

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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 有个奇怪的地方,导致它的用法是可以这样的:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
watch:{
  name:{
    handler: {
      handler: {
        handler: {
          handler: {
            handler: {
              handler: {
                handler: ()=>{console.log(123)},
                immediate: true
              }
            }
          }
        }
      }
    }
  }
}

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

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
if (isPlainObject(cb)) {
  return createWatcher(vm, expOrFn, cb, options)
}

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

总结

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

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

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

  • 一是 computed 要依赖 data 上的属性变化返回一个值,watch 则是观察数据触发回调;
  • 二是 computedwatch 依赖收集的发生点不同;
  • 三是 computed 的更新需要“渲染Watcher”的辅助,watch 不需要,这点在我的上一篇文章有提到。
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2020-06-29 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
【Vue原理】Watch - 源码版
没错,当你调用 Vue 创建实例过程中,会去处理各种选项,其中包括处理 watch
神仙朱
2019/08/02
7880
【Vue原理】Watch - 源码版
手摸手带你理解Vue的Computed原理
computed 在 Vue 中是很常用的属性配置,它能够随着依赖属性的变化而变化,为我们带来很大便利。那么本文就来带大家全面理解 computed 的内部原理以及工作流程。
WahFung
2020/08/24
1.3K0
nextTick的理解和作用
最近使用Vue全家桶做后台系统的时候,遇到了一个很奇葩的问题:有一个输入框只允许输入数字,当输入其它类型的数据时,输入的内容会被重置为null。为了实现这一功能,使用了一个父组件和子组件。为了方便陈述,这里将业务场景简化,具体代码如下:
用户9253515
2021/12/16
8260
Vue响应式依赖收集原理分析-vue高级必备
在 Vue 的初始化阶段,_init 方法执行的时候,会执行 initState(vm) ,它的定义在 src/core/instance/state.js 中。在初始化 data 和 props option 时我们注意 initProps 和 initData 方法中都调用了 observe 方法。通过 observe (value),就可以将数据变成响应式。
yyds2026
2022/10/26
6150
Vue2剥丝抽茧-响应式系统之watch2
Vue2 源码从零详解系列文章, 还没有看过的同学可能需要看一下之前的,vue.windliang.wang/
windliang
2022/08/20
2880
Vue2剥丝抽茧-响应式系统之watch2
手摸手带你理解Vue响应式原理
响应式原理作为 Vue 的核心,使用数据劫持实现数据驱动视图。在面试中是经常考查的知识点,也是面试加分项。
WahFung
2020/08/24
9910
石桥码农:20 vue计算属性和侦听器
在template里的插值表达式,如果太长,会让模板代码变得难于维护;如果有多处用到了同样的插值表达式,也不便于复用和修改。例如,这样的一个插值表达式:
LIYI
2020/02/13
7290
石桥码农:20 vue计算属性和侦听器
Vue.js源码逐行代码注解src下core下observer
达达前端
2023/10/08
4330
vue源码分析-响应式系统工作原理
上一章,我们讲到了Vue初始化做的一些操作,那么我们这一章来讲一个Vue核心概念响应式系统。
yyzzabc123
2022/10/19
5220
vue源码分析-响应式系统工作原理_2023-03-01
上一章,我们讲到了Vue初始化做的一些操作,那么我们这一章来讲一个Vue核心概念响应式系统。
用户10377014
2023/03/01
4890
Vue源码解读之InitState
看上面代码,先声明了一个_watchers的空数组;然后依次判断传递进来的options是否包含系列参数;依次执行initProps、initMethods、initData、initComputed、initWatch。
yyzzabc123
2022/09/28
3340
Vue2剥丝抽茧-响应式系统之watch
Vue2 源码从零详解系列文章, 还没有看过的同学可能需要看一下之前的,vue.windliang.wang/
windliang
2022/08/20
4240
Vue2剥丝抽茧-响应式系统之watch
4. 「vue@2.6.11 源码分析」new Vue() 整体流程和组件渲染之前的准备工作
将组件渲染渲染分为两大步骤:组件信息(事件、方法等等)的初始化,以及组件的渲染。 虽然源码中 $mount方法调用放在了_init方法上,但是感觉拿出来好些,毕竟是两个大的步骤。
tinyant
2023/02/24
7510
4.  「vue@2.6.11 源码分析」new Vue() 整体流程和组件渲染之前的准备工作
请你挑战一下这几道nextTick面试题
Vue大家再熟悉不过了,Vue的this.$nextTick大家也再熟悉不过了,今天我们就来看看自创的nextTick相关的几道面试题,看看你是否真正理解Vue的nextTick。
kai666666
2024/07/11
1570
[咖聊] 您瞅啥?瞅 reactive
props、computed、watch 那些我们放到后面再深究,先从主流程看起。聚焦到 data 部分:
码农小余
2022/06/16
5760
[咖聊]  您瞅啥?瞅 reactive
vue源码分析-响应式系统(二)
在构建简易式响应式系统的时候,我们引出了几个重要的概念,他们都是响应式原理设计的核心,我们先简单回顾一下:
yyzzabc123
2022/10/17
3630
Vue2剥丝抽茧-响应式系统之computed
Vue2 源码从零详解系列文章, 还没有看过的同学可能需要看一下之前的,vue.windliang.wang/
windliang
2022/08/20
4190
Vue2剥丝抽茧-响应式系统之computed
顺藤摸瓜:用单元测试读懂 vue3 watch 函数
在 Vue 3.x 的 Composition API 中,我们可以用近似 React Hooks 的方式组织代码的复用;ref/reactive/computed 等好用的响应式 API 函数可以摆脱组件的绑定,抽离出来被随处使用了。
江米小枣
2020/07/02
2.1K0
Vue——initState【十】
前面我们简单的了解了 vue 初始化时的一些大概的流程,这里我们详细的了解下具体的内容;
思索
2024/08/15
1030
Vue 高频原理面试篇+详细解答
原文首地址 掘金 三连哦 更多好文 github 大家好,我是林一一,这是一篇关于 vue 的原理面试题,如果能够完全弄懂相信对大家很有帮助。 面试题篇 1.老生常谈之, MPA/SPA 的理解,优缺点是什么? MPA 多页面应用。 构成:有多个页面 html 构成, 跳转方式:页面的跳转是从一个页面到另一个页面 刷新的方式:全页面刷新 页面数据跳转:依赖 URL/cookie/localStorage 跳转后的资源 会重新加载 优点:对 SEO 比较友好,开发难度低一点。 SPA单页面应用 页面组成:
前端小tips
2021/12/09
7590
Vue 高频原理面试篇+详细解答
推荐阅读
相关推荐
【Vue原理】Watch - 源码版
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档