Vue源码浅析之响应式系统(二)

693 阅读5分钟

在上一篇文章Vue源码浅析之响应式系统(一)中分析了Vue2.X版本中响应式系统的依赖收集与派发更新,这篇文章主要围绕响应式系统中的更新机制与侦听/计算属性的实现。

异步队列更新

首先我们观察一段代码:

<template>
  <div>
    <div>{{number}}</div>
  </div>
</template>
export default {
  data() {
    return {
      number: 0
    }
  },
  mounted() {
    let total = 100
    while(total--) {
      this.number++
    }
  }
}

mounted阶段中,number的值被重复多次累加。那么在拦截器函数setter被触发了100次后,DOM的更新是否也渲染了100次呢?答案是否定的。

当我们在组件中对响应数据做出了修改时,会触发拦截器的setter方法,调用Dep实例方法notify通知更新。

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

缓存Watcher队列

在update方法中,针对computed与sync状态分别作了判断。

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

在一般组件数据更新时,会走到最后一个queueWatcher(this)方法。

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

flushing变量用于判断当前是否在执行更新,如果没有,则将观察者添加到队列尾部;如果有,则会按某种顺序添加,这种情况存在于计算属性中,当触发了计算属性的get时,会将观察者添加入队列中,这时候就走到了else判断。

这里Vue引入了队列的概念,这是针对通知更新的优化,Vue不会把每次数据的改变都触发回调,而是把这些Watcher先添加到一个队列里,然后在nextTick后执行flushSchedulerQueue

渲染时机

我们知道任务队列分为macro-taskmicro-task。在macro-task中两个不同任务之间会穿插UI的重新渲染,需要在micro-task中把所有UI渲染之前需要更新的数据更新,就可以保证一次渲染得到最新的DOM

优选的选择是用micro-task去更新数据,因此最优解是Promise,如果不支持Promise,则会降级为macro-task,例如选用setTimeout等。综上,nextTick方法实际上是在做一个最优选择,优先去检测是否支持Promise,否则降级至macro-task中渲染。

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // in problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

代码中首先定义变量 microTimerFunc 。如果支持Promise,则通过定义返回值为立即resolvePromise对象,将flushCallbacks注册为micro-task

降级的处理是注册macro-task,将其赋值给microTimerFuncmacroTimerFunc是将flushCallbacks注册为macro-task。优先考虑setImmediate,接着是MessageChannelsetTimeout为最后的备选。setImmediate的优势在于不需要不停地做超时检测,缺点是只有IE支持。对于MessageChannel ,它的实现在于使用另一个port向前一个port发送消息时,前一个portonmessage回调就会注册为macro-task

nextTick具体实现

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 省略...
}

这里将cb添加至callbacks数组中,被添加到数组的函数执行时会调用cb回调。

export function nextTick (cb?: Function, ctx?: Object) {
  // 省略...
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // 省略...
}

定义了pending表示判断是否在等待刷新。触发刷新就需要执行后续的macroTimerFuncmicroTimerFunc方法。而无论哪种任务类型,都需要等待调用栈清空之后。

created () {
  this.$nextTick(() => { console.log(1) })
  this.$nextTick(() => { console.log(2) })
  this.$nextTick(() => { console.log(3) })
}

上述例子中,由于调用了三次nextTick方法,只有第一次调用的时候会执行microTimerFuncflushCallbacks 注册为micro-task。这时候不会立即执行而是需要等待调用栈清空,也就是后两次nextTick执行之后才会执行。而那个时候,callbacks队列中已经包含了所有本次循环注册的回调,接下来就是在flushCallbacks 执行回调并清空。

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

这里是使用了copies常量来保存了一份callbacks的赋值,接着去遍历执行,而在遍历copies之前就已经将callbacks数组清空。这么做的原因是为了防止在遍历执行回调的过程中,不断有新的回调添加到 callbacks 数组中的情况发生 。

在实际开发中,获取到从服务端的数据后,如果数据做了修改而我们又需要依赖更改后后的DOM,这时候就必须在nextTick后执行。

计算属性computed的实现

计算属性的初始化是发生在 Vue 实例初始化阶段的 initState 函数中,执行了 if (opts.computed) initComputed(vm, opts.computed)

const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}

在对 computed 对象做遍历之后,拿到计算属性的每一个 userDef,然后获取这个 userDef 对应的 getter 函数, 也就是说计算属性观察者的求值对象是 getter 函数。接下来为每一个 getter 创建一个 watcher

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

在这个函数中,利用了 Object.defineProperty 给计算属性添加了gettersetter。其getter函数为:

sharedPropertyDefinition.get = function computedGetter () {
  const watcher = this._computedWatchers && this._computedWatchers[key]
  if (watcher) {
    watcher.depend()
    return watcher.evaluate()
  }
}

在调用时,首先定义了watcher常量,其值为计算属性的观察对象。若该对象存在,则会执行depend方法与evaluate方法。

首先depend方法的执行,会触发收集依赖,这时候的Dep.target是渲染函数的依赖(观察者)。evaluate方法中会触发this.get()方法,那么实际上是触发了Watcher的第二个参数getter。

computed: {
  calcA () {
    return this.a + 1
  }
}

在上述的例子中,getter实际上就是对于如上函数的求值。同时这个求值的过程会触发属性aget拦截器函数,这会导致a收集到一个依赖,这个依赖便是计算属性的依赖,而实际上是渲染函数的依赖。

而当我们修改a的值时,会触发a所收集到的所有依赖,其中便是包含了计算属性的观察者。

综上总结,在定义计算属性时,实际上实例化watcher对象。当在模板中使用了计算属性后,会触发计算属性的get拦截器,调用了相关方法去收集依赖,这个过程中计算属性收集的实际上是渲染函数的依赖(观察者)。在收集完依赖后会对其进行求值,这个求值的过程实际上会触发计算属性依赖的响应式数据对象的get拦截器,导致了这个数据对象属性收集到一个计算属性的观察者,实际上是收集到了渲染函数的观察者。那么当该数据对象改变的时候,则会触发它所收集的所有依赖,其中就包含了计算属性的观察者,由此完成了相应的更新。

侦听属性watch的实现

侦听属性的初始化也是发生在 Vue 实例初始化阶段的 initState 函数中:

if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.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进行了相关的类型判断后,执行createWatcher方法,这个方法最后调用的是$watch逻辑。

$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) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}

该方法本质上是创建了一个Watcher实例对象。一旦数据发生变化,便会执行Watcher实例方法run,调用函数cb,如果我们设置了immediatetrue,则会立刻调用一次cb。最后返回了移除这个watcher的方法。

深度观测

我们首先来看Watcher中的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 {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

当我们将侦听属性的deep设置为true时,就会执行traverse(value)的逻辑。

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__) {
    const depId = val.__ob__.dep.id
    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)
  }
}

其中针对val.__ob__的判断是防止数据循环引用导致的死循环问题。在执行_traverse(val[i], seen)时,传入了val[i],触发了子属性的get拦截,由此收集到了依赖。

总的来说,侦听属性也是基于Watcher的一种实现。