0
点赞
收藏
分享

微信扫一扫

【VUE】源码分析 - watch侦听器的实现原理

小龟老师 2022-04-30 阅读 188

tips:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。

GitHub - vuejs/vue: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.icon-default.png?t=M3K6https://github.com/vuejs/vue本系列博客 ( 不定期更新 ):

【VU1E】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客

进入正题

我们都知道,Vue 提供了一种方式来观察和响应 Vue 实例上的数据变动:侦听属性。那么其具体的实现原理是什么呢?

首先从vue初始化的地方出发:

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

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

可以看到,vue其实就是一个构造函数。在创建vue实例的时候,会根据传入的options做_init初始化处理。当然,在未初始化之前,就需要将一些必要的实例方法、属性等准备好。Vue构造函数下面的几个mixin文件,就类似一个个的"加工车间",将Vue层层包装,提前准备好所有需要的方法和属性。

而我们现在所研究的watch属性 ( watch属性的最终实现其实应该是$watch实例方法 ),就存放在stateMixin这个"车间"内。

$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) {
      const info = `callback for immediate watcher "${watcher.expression}"`
      pushTarget()
      invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
      popTarget()
    }
    return function unwatchFn () {
      watcher.teardown()
    }
  }

state文件中,在vue还未被实例化之前,将提前将$watch挂载在Vue的原型对象上。在$watch方法上,一共有三个形参,其分别是:

1,expOrFn:表达式或函数。也就是所侦听的属性,或者包含侦听属性的函数。

2,cb:也就是我们自己定义的回调函数,在侦听器响应之时触发。

3,options:各种选项,其中包括暴露给外界的:immediate, deep。以及未暴露出来的,供初始化的一些选项。比如在该函数中添加上的user属性,就是为了在出错的时候区分是否抛出异常。

immediate的实现:

分析完形参,我们再接着看函数内部的immediate部分。

if (options.immediate) {
      const info = `callback for immediate watcher "${watcher.expression}"`
      pushTarget()
      //invokeWithErrorHandling函数的作用就是调用cb函数,并且在出错的时候会抛出异常
      invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
      popTarget()
    }

其原理很简单,如果声明了immediate属性,则在初始化的时候就无差别的调用一次,无论其侦听的数据是否有变化。

而其中的pushTarget()和popTarget()又如何理解呢?在我的上一篇博客中有介绍过,收集依赖的过程是在get函数中实现的,而其收集的目标依赖,就是通过这个Target来实现的。而在这里如此处理,其作用就是将一个无效目标添加至栈中,在执行完毕之后在从栈中取出该无效目标,这样就可以防止在回调中操作响应式数据的时候添加上不必要的依赖。

unwatch接口的实现:

我们知道,想要侦听一个属性,不仅仅可以通过watch属性实现,也可以直接通过原型方法$watch来实现。而在使用$watch的时候,其会返回一个unwatch函数,调用该函数,就可以关闭当前侦听器。其实现原理也很简单,就是返回了一个调用watcher.teardown()的函数。而teardown方法,就是在Watcher实例上关闭该Watcher的接口。(注意,这里的Wathcer并不等同于watch,watch只是使用Watcher的一种情景。关于Watcher,在这里有介绍,在后面也会针对watch中的Watcher做单独介绍)。

接下来,我们再回过头来看函数体前面的部分:

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

我们知道,在使用侦听器的时候,我们并不总是要老老实实的使用cb的形式。我们还可以这样使用:

  {
    handler(){},
    immediate:true,
    deep:true
  }

而源码中的此if语句块,就是为了处理这种情况:

function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

如果handler参数为一个对象,则将其赋值给options,在将对象的handler属性赋值给自己。这样就完成了对选项合回调的处理。同时,handler也可以为一个string,也就是方法名。此时,vue会默认从当前实例中取出方法名对应的函数。最后,再将处理过的规范化属性,重新调用$watch方法。

new Watcher()的分析

通过上面的介绍,我们已经大概明白了$watch的实现流程。但其实还是会对其实现的根本一知半解。因为在之前我们都只是围绕着watch的非核心功能,或者参数的处理部分做了解。而现在所介绍的,就是整个watch实现的核心部分

const watcher = new Watcher(vm, expOrFn, cb, options)

首先我们看这一行代码。创建了一个Watcher的实例,而其4个参数对应的是:当前实例、表达式、回调、以及选项。

而在Wacher类中的constructor,会对这些选项做一些接收,其中最重要也是最核心的部分,就是对expOrFn的接收

getter属性的分析

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

如果expOrFn是一个函数,则将Wathcer实例的getter属性,赋值为该函数。否则,则调用parsePath方法来处理expOrFn。其实在此处处理的也就是表达式,或者路径。

可以回忆一下,在平时使用watch的时候,我们会有以下三种写法:

  watch{
    fantasy(){},
    'fantasy.name'(){}
  }

  this.$watch(()=>this.fantasy.firstName+this.fantasy.lastName,function(){})

其中,第一种、第二种对应的expOrFn就是 fantasy 和 fantasy.name的字符串。而第三种,对应的就是一个函数表达式。

介绍完expOrFn的几种场景,再回到parsePath。其做了一件什么事情呢?

function parsePath (path: string): any {
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

很简单,将path用 " . " 分割成数组,然后返回一个会拼接成最终真实JS表达式的函数。为什么这样处理,在接下来的内容会有介绍,不用担心。现在我们先选择性忽略处理的缘由。

那么通过上述的操作之后,我们最终获得的getter表达式为两种情况:1,一开始就是函数的情况;2,从字符串转换为上述函数的情况。也就是说,无论expOrFn为何,getter永远都是一个函数的形式。

get方法的分析

在经过上述的处理之后,我们会来到下面这一行代码:

this.value = this.get()

其实也就是调用了get方法,并获取了相应的value。接着我们把目光聚焦到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 {
      if (this.deep) {
        traverse(value)
      }
      popTarget()
    }
    return value
  }

是否有些熟悉。在上一篇博客中,也有该方法的身影。只是每次分析我都会选择性的删除掉与主内容不相关的部分,只专注于对当前主题的讲解。当然,这么做并不是为了偷懒,而是在短时间内杂糅太多东西,反而会起到副作用。大家也不用着急,我会一步一步的从简单到深入,最终把每一个内容每一个点都尽量讲解到。

言归正传。同样的,第一个,将当前Watcher添加至Target当中:

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

如果对收集依赖不清楚的同学,可以点击这里回顾一下之前的内容,在这里就不做重复介绍了。

将当前实例设置为Dep.target之后,就是对getter的调用了。

回到最之前的一个问题,为什么处理表达式expOrFn的时候不直接取得它对应的值,而是要放在函数中包裹起来呢?或许大家已经发现其中的玄机了:为了收集依赖。

首先我们要明确,侦听器对于表达式的值关心吗?会关心,但是其并不是核心的目的。其最根本的目的是收集依赖,这样,下一次侦听值变化的时候,才能正确的触发回调。

那么明确了这个目的,再来看这个问题。如果我们在constructor中直接获取的是值,而包裹的函数,那么我们就只是单纯的获取到了值,并不会添加上依赖。这就本末倒置了。而将其放置在函数内,在设置了target之后在通过函数调用去获取到相应的值,在获取value的同时,我们也就实现了依赖的添加:

//expOrFn为转换的表达式:
function(){
  //触发了fantasy和name的get函数,添加依赖
  return vm.fantasy.name
}

//expOrFn一开始就是一个函数表达式:
()=>{
  //触发fantasy,firstName,lastName的get函数,添加依赖
  return this.fantasy.firstName + this.fantasy.lastName
}

添加上依赖之后,只需要上述表达式中的值发生了改变,即触发了set函数,那么其就会自动通知每一个相关的Wathcer(在本篇博客中对应的就是watch)的update。关于update在后面会做介绍,我们先将get接下来内容给介绍完:

在实现了依赖的收集之后呢,会存在一个问题:只会侦听到表达式或者函数中使用的属性,而并不会深层监听。那么deep属性优势如何实现其功能的呢?

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

设置了deep,则会进入到该if语句块中,调用traverse方法。

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 (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)
  }
}

而上面就是traverse的代码实现:深度优先的遍历每一个属性,其实也就是对每一个属性,都触发一次get,通过这样操作,就会将依赖绑定到对象的最深层。那么无论在对象的哪一个属性发生变化,都会通知到该watch属性,这样一来,也就实现了deep侦听。

至于在这里为什么需要使用new Set(),原因如下:如果只是为了触发get,那么无论是否有容器都无所谓,只需要在代码中用到,就算是一次触发。而用get存放,是为了防止对象之间的循环引用,而导致此处的死循环。如:

const a = {}
const b = a
b.relative = a

而用set,将其对应的某个唯一的ID给储存起来(与另一个知识点相关,在后续博客会做介绍,在此不展开,只需要知道每个响应式对象,即observe对象都存在这么一个全局唯一的ID即可),就可以通过判断跳出循环:

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 (seen.has(depId)) {
      return
    }
    seen.add(depId)    

  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)
  }
}

而通过上述递归遍历,就实现了deep侦听。

以上,就是整个watch收集依赖的过程。那么收集依赖之后,又是怎么做出响应的呢?

Watcher的响应

看过我上篇博客的同学或许还会有印象,在set函数中,会触发每一个依赖项的update方法,而对于watch也是如此:

  update () {
      queueWatcher(this)
  }

而queueWatcher就是将当前Watcher实例添加到微任务队列当中,等待主任务全部执行完成,就会触发回调。同样,我们暂时先不关心queueWatcher是如何实现的,只需要知道在queueWatcher会触发watcher实例的run()方法。

而run方法,就是最终调用cb回调的地方:

run () {
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      // set new value
      const oldValue = this.value
      this.value = value
      this.cb.call(this.vm, value, oldValue)
    }
}

首先,对value做处理:调用一次get方法,获取最新的value,对应的也就是回调中的第一个参数。并且将老的value存放在oldValue当中,对应的是回调的第二个参数。然后调用cb,将上述两个参数传入。cb也就是我们自己定义的函数或handler。

当然,其对value还做了一些处理:newValue值等于oldValue的时候,不会触发回调。当然,这只是针对于基本类型的变量而言。因为引用类型除非指针发生了改变,否则其永远是相等的。

if语句中的另一个判断条件deep又作何理解呢?

首先需要明确的一点是,deep放在此处肯定不会为Object做补充的。因为如果Object使用了deep,那么只要相关值发生了变化,就一定会触发cb(因为if语句中的isObject(value)),多加一个deep判断条件是没有意义的。而对于基本类型变量来说,deep就更加没有意义了,因为都不会有收集深层依赖这一说。反而对基本类型使用deep属性,会出现一些"奇怪的"现象。如:

watch:{
  //假设name一开始的值为 fantasy 1
  'fantasy.name':{
    handler(){},
    deep:true
  }
}

this.fantasy.name = 'fantasy 2'
this.fantasy.name = 'fantasy 1'

首先,对于基本类型设置deep,理应是没有意义的。但是如果执行了下面这两步操作,那么就会在newValue === oldValue的情况下,依旧触发回调(至于为什么直接不设置this.fantasy.name = 'fantasy 1',是因为在set函数中其实做了一层过滤,当设置的值没有变化的时候是不会触发依赖的)。

因此,在我看来,这就是一句"无用代码"。当然,或许只是我的理解存在问题,如果大家有知道此处为何做如此处理的,欢迎大家随时"打我脸",一起学习进步。

以上,就是整个$watch添加依赖,触发回调的整个流程了。

watch属性的分析

而对于watch属性来说呢,就更简单了:


function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

上述就是watch属性的初始化函数。做的事情和前面分析的也很类似,首先对watch的每一项都进行遍历执行createWatcher操作,如果是数组,则在此循环为每一项handler都执行createWatcher,然后在createWatcher函数中处理好handler和options参数的问题,最后再调用原型方法$watch。

现在,对于整个watch流程应该都有了一个比较全面的认识了吧!

文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。

举报

相关推荐

0 条评论