[vue解析]当我们使用watch的时候,vue又做了哪些处理

前言

这是第三篇了,每一篇的量都不小。而且这三篇关联性都挺强的,因此和源码中的初始化流程一样,建议先看data,再看computed,最后看watch

vue解析:data

vue解析:computed

依旧从一个最简单的例子开始

<div id="app">
  {{a}}
</div>
<script>
  let vm = new Vue({
    el: '#app',
    data: {
      a: 1,
      b: 2,
      d: 3
    },
    watch: {
      a: function (val, oldval) {
        console.log('new: %s, old: %s', val, oldval)
      },
      // 对象形式
      b: {
        handler: function (val, oldval) {
          console.log('new: %s, old: %s', val, oldval)
        },
        deep: true
      },
      d: {
        handler: 'someMethod',
        immediate: true
      },
      e: [
        function handle2() {},
        function handle3() {},
        function handle4() {},
      ]
    },
    methods: {
      someMethod(val, oldval) {
        console.log('new: %s, old: %s', val, oldval)
      }
    }
  })
  </script>
复制代码

可以看到,watch的书写形式很多,在官方文档api中还有更多的书写形式。点击进入查看。有这么多形式,在vue处理的时候 肯定不会一个个去单独处理,需要统一成一种格式,方便之后处理。这就是合并策略的作用。

_init方法中,有这么一段代码,这块的主要功能是通过策略模式将用户书写的各个属性props、data、methods、watch、computed等序列化成vue需要的格式 因此我们直接在这里打个断点,看vm.options的生成格式就成。

// 合并选项并赋值给 $options
vm.$options = mergeOptions(
  resolveConstructorOptions(vm.constructor),
  // 用户传进来的options 或者为空
  options || {},
  vm
)
复制代码

可以看到,本身还是对象形式,对应三种格式,后面的代码都是以这三种格式来解析的

{
  watch:{
    a: ƒ (val, oldval)
    b: {deep: true, handler: ƒ}
    d: {handler: 'someMethod', immediate: true}
    e: (3) [ƒ, ƒ, ƒ] 
  }
}
复制代码

initWatch

initState方法中,我们可以看到拿的就是vm.$options的数据,并且还有一个判断opts.watch !== nativeWatch。这是因为在firefox中, Object有一个watch方法,所以需要做一个判断。

// instance/state.js 
export function initState (vm: Component) {
  //这个数组将用来存储所有该组件实例的 watcher 对象
  vm._watchers = []
  const opts = vm.$options
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
复制代码

然后我们进去initWatch看看,很简单就是拿到keyvalue, 并传给了createWatcher方法,只是对不同格式做了一次处理。 而在createWatcher方法中,对对象类型的handle和字符串类型的handle分别做了处理。可以看到,字符串类型的handle值是从 vm上获得的,那么其实就能猜到methods方法除了在options有定义,实例上也有。

注意:

最后vue调用了vm.$watch,所以不管是函数形式的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)
}
复制代码

vm.$watch

这里我们一行行代码看,第一个判断主要是当我们使用$watch去创建监听函数的时候,需要对cb进行重新调整。 比如cb可以是这种形式{handle:function(){}, deep:true},这时候传入的options会被覆盖。这里可以写个 例子测试一下,比如this.$watch('e', {handle:function(){}, deep:true}, {immediate: true}), 可以单步调试看看,后面的options字段将被覆盖。

之后两行代码是核心,vueoptions添加了一个user属性,并且赋值为true。之后new Watcher创建构造函数。 可以发现这是第三种Watcher,我们将它命名为用户Watcher

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
  // 创建watcher对象
  const watcher = new Watcher(vm, expOrFn, cb, options)
  ...
}
复制代码

好接下来,看new Watcher,因为这段代码已经贴过好几回了,这里捡之前没讲过的,options这一块等到一个单独的章节一起讲。 先来看 求值表达式expOrFn,和其他不同,用户watcher支持使用字符串,所以这块可能走parsePath方法,这个方法返回了一个 expOrFn经过处理的函数,并且能传入obj,和之前写的简易响应式很像,如果obj传入的是this,那么我们调用的就是this[a], 第二次就是this[a][b]

export default class Watcher {
  constructor (
    vm: Component,
    // 求值表达式
    expOrFn: string | Function,
    // 回调
    cb: Function,
    // 选项
    options?: ?Object,
    // 是否是渲染watcher
    isRenderWatcher?: boolean
  ) {
    // options
    ...
    this.cb = cb // 回调
    this.id = ++uid // uid for batching 唯一标识
    this.active = true // 激活对象
  
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      // 处理表达式 obj.a
      this.getter = parsePath(expOrFn)
    }
    // 当时计算属性 构造函数是不求值的
    this.value = this.lazy
      ? undefined
      : this.get()
  }
}
// core/util/lang.js
export function parsePath (path: string): any {
  const segments = path.split('.')
  // 返回的还是函数, 会出现obj[a][b]
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}
复制代码

这样一个普通的不传入任何optionswatch就会正常的执行到this.get方法。pushTarget方法已经讲过好几次了, 功能就两个

  1. Dep.target赋值为当前Watcher
  2. 将当前Watcher放到targetStack数组中

然后来看这段代码this.getter.call(vm, vm),在看parsePath的返回赋值给了this.getter,所以其实我们执行的是 parsePath返回的函数,并且正好我们传入了vm,这就和我上面说的一样了。单步执行,就会触发this.a,也就是this._data.a 触发dataa的拦截器。

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    ...
  } finally {
    // 清除当前 target
    popTarget()
    // 清空依赖
    this.cleanupDeps()
  }
  return value
}
复制代码

后面的和computed一样,会将当前用户watcher存到对应adep.subs中。流程不细说了,建议自己debug一下。走完就会正常的,回到get方法 走下面的清理流程。这样就结束初始化了吗?没有,我们还要回到$watch方法。中间步骤先不说,刚刚我们只是执行了new Watcher。之后我们还会走下面的流程, 并且返回了一个unwatchFn。这个方法,可以执行teardown

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    ...
    const watcher = new Watcher(vm, expOrFn, cb, options)
    ...
    // 返回一个解除函数
    return function unwatchFn () {
      watcher.teardown()
    }
  }

复制代码

那么这样 初始化就完成了。下面开始执行例子。

触发watch

这里我们把例子改一下,用最简单的例子做测试。

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     a: 1,
   },
   watch: {
     a: function (val, oldval) {
       console.log('new: %s, old: %s', val, oldval)
     },
   }
 })
</script>
复制代码

这里我们做一个不一样的操作,将template里面的{{a}}去掉,这时候我们看看vm._render生成的匿名函数

(function anonymous(
) {
with(this){return _c('div',{attrs:{"id":"app"}})}
})
复制代码

没有a,那么就不会调用aget。这样就不会收集a的渲染watcher。因此a上只有一个用户watcher。 这时候我们再触发aset。在console中执行vm.a = 6。在set处断点,单步执行可以看到,执行流程是 dep.notify-->subs[i].update-->queueWatcher(this)-->nextTick(flushSchedulerQueue)走到了nextTick, 将当前用户Watcher放到了队列中,该队列会在flushSchedulerQueue中执行。

之后执行到flushSchedulerQueue的时候,就会将队列中的watcher拿出来顺序执行,也就是执行watcher.run方法。

run () {
 // 观察者是否处于激活状态
 if (this.active) {
   // 重新求值
   const value = this.get()
   // 在渲染函数中 这里永远不会被执行,因为 两次值都是 undefiend
   if (
     value !== this.value ||
     // 这里当值相等,可能是对象引用,值改变 引用还是同一个,所以判断是否是对象,
     // 是的话也执行
     isObject(value) ||
     this.deep
   ) {
     // 保存旧值, set 新值
     const oldValue = this.value
     this.value = value
     // 观察者是开发者定义 即 watch  $watch
     if (this.user) {
       const info = `callback for watcher "${this.expression}"`
       invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
     } else {
       this.cb.call(this.vm, value, oldValue)
     }
   }
 }
}
复制代码

这个方法要详细说说,首先会进行一次求值,这里主要是为了拿到新值,后面的依赖因为已经存在,会被重复的判断跳过。 这时候会新旧值同时缓存,然后当前我们的user=true,所以就会执行invokeWithErrorHandling。这方法就是执行 我们定义的handle,不过因为是用户定义,所以需要try catch。这样一次完整的watcher就执行完了。

export function invokeWithErrorHandling (
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res
  try {
    res = args ? handler.apply(context, args) : handler.call(context)
  } catch (e) {
    handleError(e, vm, info)
  }
  return res
}
复制代码

options 各个参数在vue中的执行过程

这样基础的watch就解析完了,现在我们看看每一种options,在vue中的执行过程。也就是new Watcher时候,构造函数内的这段代码

if (options) {
  this.deep = !!options.deep // 是否使用深度观测
  this.user = !!options.user // 用来标识当前观察者实例对象是 开发者定义的 还是 内部定义的
  this.lazy = !!options.lazy // 惰性watcher  第一次不请求
  this.sync = !!options.sync // 当数据变化的时候是否同步求值并执行回调
  this.before = options.before // 在触发更新之前的 调用回调
}
复制代码

immediate

同样使用最初的例子,我们加上options

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     a: 1,
   },
   watch: {
     a: {
       handler: function (val, oldval) {
         console.log('new: %s, old: %s', val, oldval)
       },
       immediate: true
     },
   }
 })
</script>
复制代码

然后看源码,很简单在初始化过程中,new Watcher结束后,马上执行了一次invokeWithErrorHandling, 也就是执行了自定义的函数回调,并且传入的值就是当前new Watcher通过计算拿到的值。

Vue.prototype.$watch = function (
 expOrFn: string | Function,
 cb: any,
 options?: Object
): Function {
 // 立即执行
 if (options.immediate) {
   const info = `callback for immediate watcher "${watcher.expression}"`
   pushTarget()
   // 获取观察者实例对象,执行了 this.get
   invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
   popTarget()
 }
 
}
复制代码

lazy

computed的本质就是lazy watcher。并且vue为我们实现了值的缓存。所以一般我们不会再watch中传入lazy

sync

设置这个值,顾名思义,就是同步,看这段代码, 在Watcher类的update方法中,也就是在我们触发拦截器set的时候,通过dep.notify 到循环执行watcherupdate方法,这里如果sync=true,就不会将当前watcher放到微任务队列中,而是直接执行。

update () {
 /* istanbul ignore else */
 // 计算属性值是不参与更新的
 if (this.lazy) {
   this.dirty = true
   // 是否同步更新变化
 } else if (this.sync) {
   this.run()
 } else {
   // 将当前观察者对象放到一个异步更新队列
   queueWatcher(this)
 }
}
复制代码

deep

修改例子

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     b: {
       c: 2,
       d: 3
     }
   },
   watch: {
     b: {
       handler: function(val, oldval) {
         console.log(`new: ${JSON.stringify(val)}, old: ${JSON.stringify(oldval)}`)
       },
       deep: true
     }
   }
 })
</script>
复制代码

deep表示深层监听,那么思考一下,vue会在哪里触发深层对象的拦截器?一般来说是在表层的a经过get的拦截器触发,存放 watcher之后,那么显而易见了。查看watcher类里的get方法,也就是调用求值表达式的地方

get () {
 // 给Dep.target 赋值 Watcher
 pushTarget(this)
 let value
 const vm = this.vm
 try {
   value = this.getter.call(vm, vm)
 } catch (e) {
   ...
 } finally {
   if (this.deep) {
     traverse(value)
   }
   // 清除当前 target
   popTarget()
   // 清空依赖
   this.cleanupDeps()
 }
 return value
}
复制代码

在清除依赖之前,vue判断了deep,然后调用了traverse方法。

这里的代码比较难以理解,我们从最初开始,首先在第一次触发求值表达式的时候,触发的bget,这时候会先把用户watcher放到 defineReactive定义的关于b的闭包dep里。我们这么表示,同级还有一个 new Observer创建的__ob__

{
  b(-->闭包dep{subs:[Watcher], id:3}):{
    c: 2,
    d: 3,
    __ob__: {
      value: {},
      id: 4,
      subs: []
    }
  }
  __ob__: {
    value: {},
    id: 2,
    subs: []
  }
}
复制代码

这里回忆一下data嵌套对象的初始化,并且再来看一下源码,childOb是有值的,初始化后被闭包保存着,而且值就是b的对象, 而且value也是它。既然它有值,那么就会进入childOb.dep.depend()方法,这时候我们就在__ob__中存了一个watcher

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 依赖框
  const dep = new Dep()
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    get: function reactiveGetter () {
      // 如果存在自定义getter 执行自定义的
      const value = getter ? getter.call(obj) : val
      // 要被收集的依赖
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value
    },
  })
}
复制代码

也就是说这样,还有一点要注意,这时候watchernewDepIds有两个值[3, 4]

{
  b(-->闭包dep{subs:[Watcher], id:3}):{
    c: 2,
    d: 3,
    __ob__: {
      value: {},
      dep: {
        id: 4,
        subs: [
         Watcher // 通过childOb存的用户watcher
        ]
      },
      vmCount:0
    }
  }
  __ob__: {
    value: {},
    dep: {
      id: 2,
      subs: []
    },
    vmCount: 1
  }
}
复制代码

之后b的拦截器就结束了,这时候进入traverse方法。首先查看val的值,它是通过上一次的计算拿到的,也就是b的值是对象。

const seenObjects = new Set()

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

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  // 检查 val是不是数组
  // * val 为 被观察属性的值
  const isA = Array.isArray(val)
  // * 解决循环引用导致死循环的问题
  // 拿到 Dep中的唯一值 进行已响应式对象去除
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  // val[i] 和 val[key[i]] 都是在求值,这将触发紫属性的get拦截器
  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)
  }
}
复制代码

中间这块__ob__的判断就不讲了,注释上写的很明白,其实就是当我们存在互相引用的时候,如果有__ob__就退出。以免死循环。 这里直接进入这行while (i--) _traverse(val[keys[i]], seen)代码,val[keys[i]]明显会触发d的拦截器,这时候就会 给ddep添加watcher,同理c也是,这样初始化就完成了。

{
 c(-->闭包dep{subs:[watcher], id:5}): 2,
 d(-->闭包dep{subs:[watcher], id:6}): 3,
 __ob__: {
   value: {},
   dep: {
     id: 4,
     subs: [
      Watcher // 通过childOb存的用户watcher
     ]
   },
   vmCount:0
 }
}
复制代码

因为在相关属性上的dep都保存了用户watcher所以,我们设置多种属性都能触发watcher,尝试下面代码

vm.b.c = 7
// new: {"c":7,"d":3}, old: {"c":7,"d":3}
vm.b.d = 8
// new: {"c":7,"d":8}, old: {"c":7,"d":8}
vm.b = 6
// new: 6, old: {"c":7,"d":8}
vm.$set(vm.b, 'e', 6)
// new: {"c":2,"d":3,"e":6}, old: {"c":2,"d":3,"e":6}
复制代码

因为vueb对象上的__ob__属性内dep保存了用户watcher,所以对b的操作也是生效的,除非我们真要这么做, 深度观测上这样其实还是蛮消耗性能的,如果层级再多一点。我们有更好的处理方式。

观察parsePath方法,有这么一段代码path.split('.'),所以如果我们想观测深层,例如想观测c可以这么写

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     b: {
       c: 2,
       d: 3
     }
   },
   watch: {
     'b.c': {
       handler: function(val, oldval) {
         console.log('new: %s, old: %s', val, oldval)
       },
     }
   }
 })
</script>
复制代码

这样我们只对bb下的属性__ob__c保存了watcher。如果b内属性很多,相当于少了n-1/n。很大的优化了。

before

这不是一个官方文档中使用的属性,但也是可以使用的,如下

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     a: 1
   },
   watch: {
     a: {
       handler: function (val, oldval) {
         // console.log(`new: ${JSON.stringify(val)}, old: ${JSON.stringify(oldval)}`)
         console.log('new: %s, old: %s', val, oldval)
       },
       before: function () {
         console.log('调用了before')
       }
     }
   }
 })
</script>
复制代码

在源码中,它在watcher.run()之前运行,而在我们使用渲染watcher的时候,他被用作于触发beforeUpdate。 而上面的例子,很显然也会在handler之前运行

if (watcher.before) {
   watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
复制代码

函数调用的形式

使用$watch并没有什么不同,但是它有声明式不具备的功能,想想computed,它在new Watcher的时候求值表达式一直是函数。那么显然 watch也应该支持传入函数,这就是$watch的作用。例如下面的例子

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     a: 1,
     b: 2
   },
   mounted() {
     this.$watch(() => ([this.a, this.b]), (val, oldval)=> {
       console.log(`new: ${val}, old: ${oldval}`)
     })
   }
 })
</script>
复制代码

这是分别触发a或者b都会触发监听回调。至于原理显然是abdep里都保存了该用户watcher

vm.a = 7
// 24 new: 7,2, old: 1,2
vm.b = 5
// new: 7,5, old: 7,2
复制代码

teardown

上面还有一个遗漏的东西没有讲,在我们执行完$watch的时候,会返回一个unwatchFn。比如例子

<div id="app">
</div>
<script>
 let vm = new Vue({
   el: '#app',
   data: {
     a: 1,
   },
   mounted() {
     let unwatch = this.$watch('a', (val, oldval)=> {
       console.log(`new: ${val}, old: ${oldval}`)
       unwatch()
     })
   }
 })
</script>
复制代码
vm.a = 5
// new: 5, old: 1
vm.a = 6
复制代码

执行它能发现,第二次set就不会执行了,所以他所做的工作就是清理。分为三步

  1. 清除当前_watchers里对应的watcher
  2. 清除dep里面的当前用户watcher,注意Dep实例和Watcher是相互保存的,而这个就是为了清除
  3. 解除观察者激活状态
teardown () {
 if (this.active) {
   // 在组件没有被销毁时,移除该watcher对象
   if (!this.vm._isBeingDestroyed) {
     remove(this.vm._watchers, this)
   }
   let i = this.deps.length
   // 一个观察者可以同时观察多个属性,所以要移除该观察者观察的所有属性
   while (i--) {
     this.deps[i].removeSub(this)
   }
   // 解除观察者的激活状态
   this.active = false
 }
}
复制代码

结尾and碎碎念

这样watch也算解析完毕了,这几天的文章写下来,对于我个人来说帮助非常大,基本相关代码都逐行去调试了。 如果有人也有这想法,建议在无痕模式下,并且多f5刷新几次清除缓存的影响。

补充:添加teardown方法解析

猜你喜欢

转载自juejin.im/post/7049733410117386276