上一篇: Vue 原理解析(八) 之 diff 算法

之前的章节, 我们按照流程介绍了vue的初始化, 虚拟Dom生成, 虚拟Dom转为真实Dom, 深入理解响应式以及diff 算法等这些核心概念。 对它内部的实现做了分析, 这些首饰篇底层的原理。 下面我们将介绍日常开发中经常使用的API的原理, 进一步丰富vue 的认识, 主要包括如下API:

响应式相关API: this. w a t c h , t h i s . watch, this. watch,this.set, this.$delete

事件相关API: this. o n , t h i s . on , this. on,this.off, this. o n c e , t h i s . once, this. once,this.emit

生命周期相关 API: this. m o u n t , t h i s . mount , this. mount,this.forceUpdate, this.$destroy

全局API: Vue.extend, Vue.nexTick, Vue.set, Vue.delete, Vue.component, Vue.use, Vue.mixin, Vue.compile, Vue.version, Vue.directive, Vue.filter

这一章节主要分析computedwatch 属性, 对于接触vue 不久的朋友可能会对computedwatch 有疑惑, 什么时候使用哪个属性留有存疑, 夏明明我们从其内部实现角度出发, 搞懂它们分别适用的场景。

  • this.$watach
    这个API 之前介绍响应式是的Watcher类的一种封装, 也就是三种 watcher中的user-watcher, 监听属性经常会被这样使用到:
export default {
  watch: {
    name(newName) {...}
  }
}

其实它只是 this.$watch 这个 API 的一种封装:

export default {
  created() {
    this.$watch('name', newName => {...})
  }
}

监听属性初始化

为什么这么说, 首先来看下初始化时watch 属性都做了什么:

function initState(vm) {  // 初始化所有状态时
  vm._watchers = []  // 当前实例watcher集合
  const opts = vm.$options  // 合并后的属性
  
  ... // 其他状态初始化
  
  if(opts.watch) {  // 如果有定义watch属性
    initWatch(vm, opts.watch)  // 执行初始化方法
  }
}

---------------------------------------------------------

function initWatch (vm, watch) {  // 初始化方法
  for (const key in watch) {  // 遍历watch内多个监听属性
    const handler = watch[key]  // 每一个监听属性的值
    if (Array.isArray(handler)) {  // 如果该项的值为数组
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])  // 将每一项使用watcher包装
      }
    } else {
      createWatcher(vm, key, handler) // 不是数组直接使用watcher
    }
  }
}

---------------------------------------------------------

function createWatcher (vm, expOrFn, handler, options) {
  if (isPlainObject(handler)) { // 如果是对象,参数移位
    options = handler  
    handler = handler.handler
  }
  if (typeof handler === 'string') {  // 如果是字符串,表示为方法名
    handler = vm[handler]  // 获取methods内的方法
  }
  return vm.$watch(expOrFn, handler, options)  // 封装
}

注: 通过源码我们可以看出, 组件内自定义的watch 可以是数组,或者对象(对象内允许为函数/字符串; 如果是字符串子在vm中搜索对应的handleName )。

以上对监听属性的多种不同的使用方式,都做了处理。 使用示例在官网均可找到, 这里不过多做介绍了。 可以看到最后是调用了=vm.$watch 方法。

监听属性实现原理

我们接下来看下==$watch== 的内部实现:

Vue.prototype.$watch = function(expOrFn, cb, options = {}) {
  const vm = this
  if (isPlainObject(cb)) {  // 如果cb是对象,当手动创建监听属性时
    return createWatcher(vm, expOrFn, cb, options)
  }
  
  options.user = true  // user-watcher的标志位,传入Watcher类中
  const watcher = new Watcher(vm, expOrFn, cb, options)  // 实例化user-watcher
  
  if (options.immediate) {  // 立即执行
    cb.call(vm, watcher.value)  // 以当前值立即执行一次回调函数
  }  // watcher.value为实例化后返回的值
  
  return function unwatchFn () {  // 返回一个函数,执行取消监听
    watcher.teardown()
  }
}

---------------------------------------------------------------

export default {
  data() {
    return {
      name: 'cc'
    }  
  },
  created() {
    this.unwatch = this.$watch('name', newName => {...})
    this.unwatch()  // 取消监听
  }
}

虽然watch 内部使用 this. w a t c h = = , 但 是 我 们 也 是 可 以 手 动 调 用 t h i s . watch==, 但是我们也是可以手动调用 this. watch==,this.watch 来创建监听属性的, 所以第二个参数cb== 会出现是对象的情况。 接下来设置一个标记为options.usertrue, 表明这是一个user-watcher再给watch 设置了immediate 属性后, 会将实例化后得到的值传入回调, 并立即执行一次回调函数, 这也是immediate的实现原理 。 最后的返回值是一个方法, 执行后可取消对该监听属性的监听。 接下来我们看看user-watcher 是如何定义的:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm
    vm._watchers.push(this)  // 添加到当前实例的watchers内
    
    if(options) {
      this.deep = !!options.deep  // 是否深度监听
      this.user = !!options.user  // 是否是user-wathcer
      this.sync = !!options.sync  // 是否同步更新
    }
    
    this.active = true  // // 派发更新的标志位
    this.cb = cb  // 回调函数
    
    if (typeof expOrFn === 'function') {  // 如果expOrFn是函数
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)  // 如果是字符串对象路径形式,返回闭包函数
    }
    
    ...
    
  }
}

当时user-watcher 时, Watcher 内部是以上方式实例化的, 通常情况下我们是使用字符串的形式创建监听属性, 所以首先来看下parsePath 方法是干什么的:

const bailRE = /[^\w.$]/  // 得是对象路径形式,如info.name

function parsePath (path) {
  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
  }
}

parsePath 方法最终返回一个闭包方法, 此时Watcher 类中的this.getter 就是一个函数了, 再执行this.get() 方法时会将this.vm传入到闭包内, 补全Watcher其他的逻辑:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    
    ...
    this.getter = parsePath(expOrFn)  // 返回的方法
    
    this.value = this.get()  // 执行get
  }
  
  get() {
    pushTarget(this)  // 将当前user-watcher实例赋值给Dep.target,读取时收集它
    
    let value = this.getter.call(this.vm, this.vm)  // 将vm实例传给闭包,进行读取操作
    
    if (this.deep) {  // 如果有定义deep属性
      traverse(value)  // 进行深度监听
    }
    
    popTarget()
    return value  // 返回闭包读取到的值,参数immediate使用的就是这里的值
  }
  
  ...
  
}

因为之前初始化已经将状态全部都代理到了this 下, 所以读取this 下的属性即可以, 比如:

export default {
  data() {  // data的初始化先与watch
    return {
      info: {
        name: 'cc'
      }
    }
  },
  created() {
    this.$watch('info.name', newName => {...})  // 何况手动创建
  }
}

注: 这里我们可以发现组件内使用watch 自定义监听时, watch 允许我们对某个对象下的某个属性进行监听

首先读取this下的info属性, 然后读取info下的name 属性。 注意: 这里我们使用了读取这个动词, 所以会执行之前包装data响应式数据的get方法进行依赖收集, 将依赖收集到读取的属性的dep 里, 不过收集的是user-watcherget方法最后返回闭包读取到的值。

之后就是当info.name 属性被重新赋值时, 走派发更新的流程, 我们这里把和render-watcher 不同之处做单独的说明, 派发更新会执行Watcher内的update方法:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    ...
  }
  
  update() {  // 执行派发更新
    if(this.sync) {  // 如果有设置sync为true
      this.run()  // 不走nextTick队列,直接执行
    } else {
      queueWatcher(this)  // 否则加入队列,异步执行run()
    }
  }
  
  run() {
    if (this.active) {
      this.getAndInvoke(this.cb)  // 传入回调函数
    }
  }
  
  getAndInvoke(cb) {
    const value = this.get()  // 重新求值
    
    if(value !== this.value || isObject(value) || this.deep) {
      const oldValue = this.value  // 缓存之前的值
      this.value = value  // 新值
      if(this.user) {  // 如果是user-watcher
        cb.call(this.vm, value, oldValue)  // 在回调内传入新值和旧值
      }
    }
  }
}

其实这里的sync属性已经没在官方做说明了, 不过我们看到源码中还是保留了相关代码。 接下来我们看到什么watch 的回调内可以得到新值和旧值的原理, 因为 cb.call(this.vm, value, oldValue) 这句代码的原因, 内部将新值和旧值传递给了回调函数。

watch监听属性示例:
<template>  
  <div>{{name}}</div>
</template>

export default {  // App组件
  data() {
    return {
      name: 'cc'
    }
  },
  watch: {
    name(newName, oldName) {...}  // 派发新值和旧值给回调
  },
  mounted() {
    setTimeout(() => {  
      this.name = 'ww'  // 触发name的set
    }, 1000)
  }
}

在这里插入图片描述

监听属性的 deep 深度监听原理

之前的get 方法内有说明, 如果有deep属性且值为true, 则执行traveerse方法:

const seenObjects = new Set()  // 不重复添加

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

function _traverse (val, seen) {
  let i, keys
  const isA = Array.isArray(val)  // val是否是数组
  
  if ((!isA && !isObject(val))  // 如果不是array和object
        || Object.isFrozen(val)  // 或者是已经冻结对象
        || val instanceof VNode) {  // 或者是VNode实例
    return  // 再见
  }
  
  if (val.__ob__) {  // 只有object和array才有__ob__属性
    const depId = val.__ob__.dep.id  // 手动依赖收集器的id
    if (seen.has(depId)) {  // 已经有收集过
      return  // 再见
    }
    seen.add(depId)  // 没有被收集,添加
  }
  
  if (isA) {  // 是array
    i = val.length
    while (i--) {
      _traverse(val[i], seen)  // 递归触发每一项的get进行依赖收集
    }
  } 
  
  else {  // 是object
    keys = Object.keys(val)
    i = keys.length
    while (i--) {
      _traverse(val[keys[i]], seen)  // 递归触发子属性的get进行依赖收集
    }
  }
}

看着挺复杂 简单来说deep的实现原理就是递归的触发数组或对象的get进行依赖收集, 因为只有数组和对象才有__ob__ 属性, 也就是我们第七章说明的手动依赖管理器, 将它们的依赖收集到Observer类里的dep内, 完成deep深度监听。

watch总结: 这里说明了为什么watchthis.$watch 的实现是一致的, 以及简单解释它们的原理就是为需要观察的数据创建并收集user-watcher。 当数据改变是通知到user-watcher 将新值和旧值传递给用户自己定义的回调函数。 最后分析了定义watch时会被使用到的三个参数: sync, immediate, deep 它们的实现原理。 简单说明它们的实现原理就是: sync是不将watcher加入到nextTick队列而是同步的更新, immediate 是立即以得到的值执行一次回调函数, deep是递归的对它的子值进行依赖收集。

  • this.$set
    这个API 已经在第七章的最后做了具体分析, 需要可以前往this.set实现原理查阅。

  • this.$delete
    这个API 也已经在第七章的最后做了具体分析, 需要可以前往this.delete实现原理查阅。

  • computed 计算属性

计算属性不是API, 但它是Watcher 类的最后也是最复杂的一种实例化的使用, 还是很有必要分析的。 (vue版本2.6.10)其实主要就是分析计算属性为何可以做到当它的依赖项发生改变时才会进行重新的计算, 否则当前数据是被缓存的。 计算属性的值可以是对象, 这个对象需要传入setget 方法, 这种并不常用,所以这里的分析还是介绍常用的函数形式, 它们之间是大同小异的, 不过可以减少认知负担, 聚焦核心原理实现。

export default {
  computed: {
    newName: {  // 不分析这种了~
      get() {...},  // 内部会采用get属性为计算属性的值
      set() {...}
    }
  }
}

计算属性初始化

function initState(vm) {  // 初始化所有状态时
  vm._watchers = []  // 当前实例watcher集合
  const opts = vm.$options  // 合并后的属性
  
  ... // 其他状态初始化
  
  if(opts.computed) {  // 如果有定义计算属性
    initComputed(vm, opts.computed)  // 进行初始化
  }
  ...
}

---------------------------------------------------------------------------

function initComputed(vm, computed) {
  const watchers = vm._computedWatchers = Object.create(null) // 创建一个纯净对象
  
  for(const key in computed) {
    const getter = computed[key]  // computed每项对应的回调函数
    
    watchers[key] = new Watcher(vm, getter, noop, {lazy: true})  // 实例化computed-watcher
    
    ...
    
  }
}

计算属性实现原理

这里还是按照惯例, 将定义的computed 属性的每一项使用Watcher类进行实例化, 不过这里是按照computed-wacher 的形式, 来看下如何实例化的:

class Watcher{
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm
    this._watchers.push(this)
    
    if(options) {
      this.lazy = !!options.lazy  // 表示是computed
    }
    
    this.dirty = this.lazy  // dirty为标记位,表示是否对computed计算
    
    this.getter = expOrFn  // computed的回调函数
    
    this.value = undefined
  }
}

这里就点到为止, 实例化已经结束了。 并没有和之前render-watcher 以及 user-watcher 那般, 执行get 方法, 这是为什么? 我们接着分析为何如此, 补全之前初始化computed的方法:

function initComputed(vm, computed) {
  ...
  
  for(const key in computed) {
    const getter = computed[key]  // // computed每项对应的回调函数
    ...
    
    if (!(key in vm)) {
      defineComputed(vm, key, getter)
    }
    
    ... key不能和data里的属性重名
    ... key不能和props里的属性重名
  }
}

这里App 组件在执行extend创建子组件的构造函数时, 已经将key挂载到vm的原型中了, 不过之前也是执行的defineComputed 方法, 所以不妨碍我们看它做了什么:

function defineComputed(target, key) {
  ...
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get: createComputedGetter(key),
    set: noop
  })
}

这个方法的作用就是让computed 成为一个响应式数据, 并定义它的get属性, 也就是说当页面执行渲染访问到computed时, 才会触发get然后执行createComputedGetter 方法, 所以之前的点到为止再这里会续上, 看下get方法是怎么定义的:

function createComputedGetter (key) { // 高阶函数
  return function () {  // 返回函数
    const watcher = this._computedWatchers && this._computedWatchers[key]
    // 原来this还可以这样用,得到key对应的computed-watcher
    
    if (watcher) {
      if (watcher.dirty) {  // 在实例化watcher时为true,表示需要计算
        watcher.evaluate()  // 进行计算属性的求值
      }
      if (Dep.target) {  // 当前的watcher,这里是页面渲染触发的这个方法,所以为render-watcher
        watcher.depend()  // 收集当前watcher
      }
      return watcher.value  // 返回求到的值或之前缓存的值
    }
  }
}

------------------------------------------------------------------------------------

class Watcher {
  ...
  
  evaluate () {
    this.value = this.get()  //  计算属性求值
    this.dirty = false  // 表示计算属性已经计算,不需要再计算
  }
  
  depend () {
    let i = this.deps.length  // deps内是计算属性内能访问到的响应式数据的dep的数组集合
    while (i--) {
      this.deps[i].depend()  // 让每个dep收集当前的render-watcher
    }
  }
}

这里的变量watcher 就是之前computed对应的computed-watcher 实例, 接下来会执行Watcher 类专门为计算属性定义的两个方法, 在执行evaluate 方法进行求值的过程中又会触发computed 内可以访问到的响应式数据的get, 它们将当前的computed-watcher 作为依赖收集到自己的dep里, 计算完毕之后将dirty置为false, 表示已经计算过了。
然后执行depend让计算属性内的响应式数据订阅当前的render-watcher , 所以computed 内的响应式数据会收集computed-watcherrender-watcher 两个watcher , 当computed 内状态发生变更触发set后,首先通知computed需要进行重新计算, 然后通知到试图执行渲染, 在渲染中会访问到computed计算后的值, 最后渲染到页面。

PS: 计算属性内的值需是响应式数据才能触发重新计算

computed内的响应式数据变更后触发的通知:

class Watcher {
  ...
  update() {  // 当computed内的响应式数据触发set后
    if(this.lazy) {
      this.diray = true  // 通知computed需要重新计算了
    }
    ...
  }
}
复制代码最后还是以一个示例结合流程图来帮大家理清楚这里的逻辑:
export default {
  data() {
    return {
      manName: "cc",
      womanName: "ww"
    };
  },
  computed: {
    newName() {
      return this.manName + ":" + this.womanName;
    }
  },
  methods: {
    changeName() {
      this.manName = "ss";
    }
  }
};

在这里插入图片描述

watch 总结: 为什么计算属性有缓存功能? 因为当计算属性经过计算后, 内部的标志位会表明已经计算过了。 再次访问时会直接读取计算后的值; 为什么计算属性内的响应式数据发生变更后,计算属性会重新计算? 因为内部的响应式数据会收集computed-watcher, 变更后通知计算属性要进行计算, 也会通知页面重新渲染, 渲染时会读取到重新计算后的值。

最后我们用一个问题来结束本章的内容~

computed 属性和watch属性分别什么场景使用?

解答:

  • 当模板中的某个值需要通过一个或多个数据计算得到时, 就可以使用计算属性,还有计算属性的函数不接受参数; 监听属性主要是监听某个值发生变化后, 对新值去进行逻辑处理。

下一篇: vue 原理分析(十) 之 事件API原理及组件库中的使用

Logo

前往低代码交流专区

更多推荐