四、vue派发更新

image

收集的目的就是为了当我们修改数据的时候,可以对相关的依赖派发更新,那么这一节我们来详细分析这个过程。

setter 部分的逻辑:

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // ...
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

假设我们有如下模板:

<div id="demo">
  {{name}}
</div>

我们知道这段模板将会被编译成渲染函数,接着创建一个渲染函数的观察者,从而对渲染函数求值,在求值的过程中会触发数据对象 name 属性的 get 拦截器函数,进而将该观察者收集到 name 属性通过闭包引用的“筐”中,即收集到 Dep 实例对象中。这个 Dep 实例对象是属于 name 属性自身所拥有的,这样当我们尝试修改数据对象 name 属性的值时就会触发 name 属性的 set 拦截器函数,这样就有机会调用 Dep 实例对象的 notify 方法,从而触发了响应,如下代码截取自 defineReactive 函数中的 set 拦截器函数:

set: function reactiveSetter (newVal) {
  // 省略...
  dep.notify()
}

如上高亮代码所示,可以看到当属性值变化时确实通过 set 拦截器函数调用了 Dep 实例对象的 notify 方法,这个方法就是用来通知变化的,我们找到 Dep 类的 notify 方法,如下:

export default class Dep {
  // 省略...

  constructor () {
    this.id = uid++
    this.subs = []
  }

  // 省略...

  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

大家观察 notify 函数可以发现其中包含如下这段 if 条件语句块:

if (process.env.NODE_ENV !== 'production' && !config.async) {
  // subs aren't sorted in scheduler if not running async
  // we need to sort them now to make sure they fire in correct
  // order
  subs.sort((a, b) => a.id - b.id)
}

对于这段代码的作用,我们会在本章的 同步执行观察者 一节中对其详细讲解,现在大家可以完全忽略,这并不影响我们对代码的理解。如果我们去掉如上这段代码,那么 notify 函数将变为:

notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

notify 方法只做了一件事,就是遍历当前 Dep 实例对象的 subs 属性中所保存的所有观察者对象,并逐个调用观察者对象的 update 方法,这就是触发响应的实现机制,那么大家应该也猜到了,重新求值的操作应该是在 update 方法中进行的,那我们就找到观察者对象的 update 方法,看看它做了什么事情,如下:

update () {
  /* istanbul ignore else */
  if (this.computed) {
    // 省略...
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }

在 update 方法中代码被拆分成了三部分,即 if...else if...else 语句块。首先 if 语句块的代码会在判断条件 this.computed 为真的情况下执行,我们说过 this.computed 属性是用来判断该观察者是不是计算属性的观察者,这部分代码我们将会在计算属性部分详细讲解。也就是说渲染函数的观察者肯定是不会执行 if 语句块中的代码的,此时会继续判断 else...if 语句的条件 this.sync 是否为真,我们知道 this.sync 属性的值就是创建观察者实例对象时传递的第三个选项参数中的 sync 属性的值,这个值的真假代表了当变化发生时是否同步更新变化。对于渲染函数的观察者来讲,它并不是同步更新变化的,而是将变化放到一个异步更新队列中,也就是 else 语句块中代码所做的事情,即 queueWatcher 会将当前观察者对象放到一个异步更新队列,这个队列会在调用栈被清空之后按照一定的顺序执行。关于更多异步更新队列的内容我们会在后面单独讲解,这里大家只需要知道一件事情,那就是无论是同步更新变化还是将更新变化的操作放到异步更新队列,真正的更新变化操作都是通过调用观察者实例对象的 run 方法完成的。所以此时我们应该把目光转向 run 方法,如下:

run () {
  if (this.active) {
    this.getAndInvoke(this.cb)
  }
}

run 方法的代码很简短,它判断了当前观察者实例的 this.active 属性是否为真,其中 this.active 属性用来标识一个观察者是否处于激活状态,或者可用状态。如果观察者处于激活状态那么 this.active 的值为真,此时会调用观察者实例对象的 getAndInvoke 方法,并以 this.cb 作为参数,我们知道 this.cb 属性是一个函数,我们称之为回调函数,当变化发生时会触发,但是对于渲染函数的观察者来讲,this.cb 属性的值为 noop,即什么都不做

现在我们终于找到了更新变化的根源,那就是 getAndInvoke 方法,如下:

getAndInvoke (cb: Function) {
  const value = this.get()
  if (
    value !== this.value ||
    // Deep watchers and watchers on Object/Arrays should fire even
    // when the value is the same, because the value may
    // have mutated.
    isObject(value) ||
    this.deep
  ) {
    // set new value
    const oldValue = this.value
    this.value = value
    this.dirty = false
    if (this.user) {
      try {
        cb.call(this.vm, value, oldValue)
      } catch (e) {
        handleError(e, this.vm, `callback for watcher "${this.expression}"`)
      }
    } else {
      cb.call(this.vm, value, oldValue)
    }
  }
}

在 getAndInvoke 方法中,第一句代码就调用了 this.get 方法,这意味着重新求值,这也证明了我们在上一小节中的假设。对于渲染函数的观察者来讲,重新求值其实等价于重新执行渲染函数,最终结果就是重新生成了虚拟DOM并更新真实DOM,这样就完成了重新渲染的过程。在重新调用 this.get 方法之后是一个 if 语句块,实际上对于渲染函数的观察者来讲并不会执行这个 if 语句块,因为 this.get 方法的返回值其实就等价于 updateComponent 函数的返回值,这个值将永远都是 undefined。实际上 if 语句块内的代码是为非渲染函数类型的观察者准备的,它用来对比新旧两次求值的结果,当值不相等的时候会调用通过参数传递进来的回调。我们先看一下判断条件,如下:

const value = this.get()
if (
  value !== this.value ||
  // Deep watchers and watchers on Object/Arrays should fire even
  // when the value is the same, because the value may
  // have mutated.
  isObject(value) ||
  this.deep
) {
  // 省略...
}

首先对比新值 value 和旧值 this.value 是否相等,只有在不相等的情况下才需要执行回调,但是两个值相等就一定不执行回调吗?未必,这个时候就需要检测第二个条件是否成立,即 isObject(value),判断新值的类型是否是对象,如果是对象的话即使值不变也需要执行回调,注意这里的“不变”指的是引用不变,如下代码所示:

const data = {
  obj: {
    a: 1
  }
}
const obj1 = data.obj
data.obj.a = 2
const obj2 = data.obj

console.log(obj1 === obj2) // true

上面的代码中由于 obj1 与 obj2 具有相同的引用,所以他们总是相等的,但其实数据已经变化了,这就是判断 isObject(value) 为真则执行回调的原因。

接下来我们就看一下 if 语句块内的代码:

const oldValue = this.value
this.value = value
this.dirty = false
if (this.user) {
  try {
    cb.call(this.vm, value, oldValue)
  } catch (e) {
    handleError(e, this.vm, `callback for watcher "${this.expression}"`)
  }
} else {
  cb.call(this.vm, value, oldValue)
}

代码如果执行到了 if 语句块内,则说明应该执行观察者的回调函数了。首先定义了 oldValue 常量,它的值是旧值,紧接着使用新值更新了 this.value 的值。我们可以看到如上代码中是如何执行回调的:

cb.call(this.vm, value, oldValue)

将回调函数的作用域修改为当前 Vue 组件对象,然后传递了两个参数,分别是新值和旧值。

另外大家可能注意到了这句代码:this.dirty = false,将观察者实例对象的 this.dirty 属性设置为 false,实际上 this.dirty 属性也是为计算属性准备的,由于计算属性是惰性求值,所以在实例化计算属性的时候 this.dirty 的值会被设置为 true,代表着还没有求值,后面当真正对计算属性求值时,也就是执行如上代码时才会将 this.dirty 设置为 false,代表着已经求过值了。

除此之外,我们注意如下代码:

if (this.user) {
  try {
    cb.call(this.vm, value, oldValue)
  } catch (e) {
    handleError(e, this.vm, `callback for watcher "${this.expression}"`)
  }
} else {
  cb.call(this.vm, value, oldValue)
}

在调用回调函数的时候,如果观察者对象的 this.user 为真意味着这个观察者是开发者定义的,所谓开发者定义的是指那些通过 watch 选项或 $watch 函数定义的观察者,这些观察者的特点是回调函数是由开发者编写的,所以这些回调函数在执行的过程中其行为是不可预知的,很可能出现错误,这时候将其放到一个 try...catch 语句块中,这样当错误发生时我们就能够给开发者一个友好的提示。并且我们注意到在提示信息中包含了 this.expression 属性,我们前面说过该属性是被观察目标(expOrFn)的字符串表示,这样开发者就能清楚的知道是哪里发生了错误。

异步更新队列

异步更新的意义----性能优化

当所有的突变完成之后,再一次性的执行队列中所有观察者的更新方法,同时清空队列,这样就达到了优化的目的

看一看其具体实现,我们知道当修改一个属性的值时,会通过执行该属性所收集的所有观察者对象的 update 方法进行更新,那么我们就找到观察者对象的 update 方法,如下:

update () {
  /* istanbul ignore else */
  if (this.computed) {
    // 省略...
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

如果没有指定这个观察者是同步更新(this.sync 为真),那么这个观察者的更新机制就是异步的,这时当调用观察者对象的 update 方法时,在 update 方法内部会调用 queueWatcher 函数,并将当前观察者对象作为参数传递,queueWatcher 函数的作用就是我们前面讲到过的,它将观察者放到一个队列中等待所有突变完成之后统一执行更新。

queueWatcher 函数来自 src/core/observer/scheduler.js 文件,如下是 queueWatcher 函数的全部代码:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

queueWatcher 函数接收观察者对象作为参数,首先定义了 id 常量,它的值是观察者对象的唯一 id,然后执行 if 判断语句,如下是简化的代码:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // let has: { [key: number]: ?true } = {}
  if (has[id] == null) {
    has[id] = true
    // 省略...
  }
}

当 queueWatcher 函数被调用之后,会尝试将该观察者放入队列中,并将该观察者的 id 值登记到 has 对象上作为 has 对象的属性同时将该属性值设置为 true。该 if 语句以及变量 has 的作用就是用来避免将相同的观察者重复入队的。在该 if 语句块内执行了真正的入队操作,如下代码高亮的部分所示:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true  
    // let flushing = false  初始值是 false
    if (!flushing) {
    // const queue: Array<Watcher> = []
      queue.push(watcher)
    } else {
      // 省略...
    }
    // 省略...
  }
}

flushing 变量是一个标志,我们知道放入队列 queue 中的所有观察者将会在突变完成之后统一执行更新,当更新开始时会将 flushing 变量的值设置为 true,代表着此时正在执行更新,所以根据判断条件 if (!flushing) 可知只有当队列没有执行更新时才会简单地将观察者追加到队列的尾部有的同学可能会问:“难道在队列执行更新的过程中还会有观察者入队的操作吗?”,实际上是会的,典型的例子就是计算属性,比如队列执行更新时经常会执行渲染函数观察者的更新,渲染函数中很可能有计算属性的存在,由于计算属性在实现方式上与普通响应式属性有所不同,所以当触发计算属性的 get 拦截器函数时会有观察者入队的行为,这个时候我们需要特殊处理,也就是 else 分支的代码,如下:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // 省略...
  }
}

当变量 flushing 为真时,说明队列正在执行更新,这时如果有观察者入队则会执行 else 分支中的代码,这段代码的作用是为了保证观察者的执行顺序,现在大家只需要知道观察者会被放入 queue 队列中即可

接着我们再来看如下代码:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    // 省略...
    // queue the flush
>     if (!waiting) {
>       waiting = true
>       if (process.env.NODE_ENV !== 'production' && !config.async) {
>         flushSchedulerQueue()
>         return
>       }
>       nextTick(flushSchedulerQueue)
>     }
  }
}

大家观察如上代码中有这样一段 if 条件语句:

if (process.env.NODE_ENV !== 'production' && !config.async) {
  flushSchedulerQueue()
  return
}

在接下来的讲解中我们将会忽略这段代码,并在 同步执行观察者 一节中补充讲解,

我们回到那段高亮的代码,这段代码是一个 if 语句块,其中变量 waiting 同样是一个标志,它也定义在 scheduler.js 文件头部,初始值为 false

let waiting = false

我们看 if 语句块内的代码就知道了,在 if 语句块内先将 waiting 的值设置为 true,这意味着无论调用多少次 queueWatcher 函数,该 if 语句块的代码只会执行一次。接着调用 nextTick 并以 flushSchedulerQueue 函数作为参数,其中 flushSchedulerQueue 函数的作用之一就是用来将队列中的观察者统一执行更新的。对于 nextTick 相信大家已经很熟悉了,其实最好理解的方式就是把 nextTick 看做 setTimeout(fn, 0),如下:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    // 省略...
    // queue the flush
    if (!waiting) {
      waiting = true
      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      setTimeout(flushSchedulerQueue, 0)
    }
  }
}

我们对 Vue 数据修改派发更新的过程也有了认识,实际上就是当数据发生变化的时候,触发 setter 逻辑,把在依赖过程中订阅的的所有观察者,也就是 watcher,都触发它们的 update 过程,这个过程又利用了队列做了进一步优化,在 nextTick 后执行所有 watcher 的 run,最后执行它们的回调函数。nextTick 是 Vue 一个比较核心的实现了。

posted @ 2018-11-29 11:47  快乐~  阅读(314)  评论(0编辑  收藏  举报