Vue 渲染函数的观察者与进阶的数据响应系统

发布于 2024-09-25 11:31:24 字数 90729 浏览 6 评论 0

实际上在 揭开数据响应系统的面纱 一节中我们仅仅学习了数据响应系统的部分内容,比如当时我们做了一个合理的假设,即: dep.depend() 这句代码的执行就代表观察者被收集了,而 dep.notify() 的执行则代表触发了响应,但是我们并没有详细讲解 dep 本身是什么东西,我们只是把它当做了一个收集依赖的“筐”。除此之外我们也没有讲解数据响应系统中另一个很重要的部分,即 Watcher ,我们知道正是由于 Watcher 对所观察字段的求值才触发了字段的 get ,从而才有了收集到该观察者的机会。本节我们的目标就是深入 Vue 中有关于这部分的具体源码,看一看这里面的秘密。

为了更好的讲解 DepWatcher ,我们需要选择一个合适的切入点,这个切入点就是 Vue.prototype._init 函数。为什么是 Vue.prototype._init 呢?因为数据响应系统本身的切入点就是 initState 函数,而 initState 函数的调用就在 _init 函数中。现在我们把视线重新转移到 _init 函数,然后 试图从 渲染(render) -> 重新渲染(re-render) 的过程探索数据响应系统更深层次的内容

$mount 挂载函数

打开 src/core/instance/init.js 文件并找到 Vue.prototype._init 函数,如下代码所示:

Vue.prototype._init = function (options?: Object) {
  // 省略...

  // expose real self
  vm._self = vm
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')

  // 省略...

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

以上是简化后的代码,注意高亮的那一句: vm.$mount(vm.$options.el) ,这句代码是 _init 函数的最后一句代码,在这句代码执行之前完成了所有初始化的工作,虽然我们目前对初始化工作还有很多不了解的地方,不过没关系,现在我们就假设已经完成了所有初始化的工作,然后开始我们的探索,不过在这之前我们需要先了解一下 $mount 函数是如何将组件挂载到给定元素的。

大家还记得 $mount 函数定义在哪里吗?我们在 Vue 构造函数 一节中,在整理 Vue 构造函数的时候发现 $mount 的定义出现在两个地方,第一个地方是 platforms/web/runtime/index.js 文件,如下:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

我们知道 platforms/web/runtime/index.js 文件是运行时版 Vue 的入口文件,也就是说如上代码中 $mount 函数的功能就是运行时版 Vue$mount 函数的功能,我们看看它做了什么, $mount 函数接收两个参数,第一个参数 el 可以是一个字符串也可以是一个 DOM 元素,第二个参数 hydrating 是用于 Virtual DOM 的补丁算法的,这里大家不需要关心。来看 $mount 函数的第一句代码:

el = el && inBrowser ? query(el) : undefined

首先检测是否传递了 el 选项,如果传递了 el 选项则会接着判断 inBrowser 是否为真,即当前宿主环境是否是浏览器,如果在浏览器中则将 el 透传给 query 函数并用返回值重写 el 变量,否则 el 将被重写为 undefined 。其中 query 函数来自 src/platforms/web/util/index.js 文件,用来根据给定的参数在 DOM 中查找对应的元素并返回。总之如果在浏览器环境下,那么 el 变量将存储着 DOM 元素(理想情况下)。

接着来到 $mount 函数的第二句代码:

return mountComponent(this, el, hydrating)

调用了 mountComponent 函数完成真正的挂载工作,并返回( return ) 其运行结果,以上就是运行时版 Vue$mount 函数所做的事情。

第二个定义 $mount 函数的地方是 src/platforms/web/entry-runtime-with-compiler.js 文件,我们知道这个文件是完整版 Vue 的入口文件,在该文件中重新定义了 $mount 函数,但是保留了运行时 $mount 的功能,并在此基础上为 $mount 函数添加了编译模板的能力,接下来我们详细讲解一下完整版 $mount 函数的实现,打开 src/platforms/web/entry-runtime-with-compiler.js 文件,如下:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 省略...
  return mount.call(this, el, hydrating)
}

如上代码所示,首先使用 mount 常量缓存了运行时版的 $mount 函数,然后重新定义了 Vue.prototype.$mount 函数并在重新定义的 $mount 函数体内调用了缓存下来的运行时版的 $mount 函数,另外重新定义前后 $mount 函数所接收的参数是不变的。我们说过,之所以重写 $mount 函数,其目的就是为了给运行时版的 $mount 函数增加编译模板的能力,我们看看它是怎么做的,在 $mount 函数的开始是如下这段代码:

el = el && query(el)

/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
  process.env.NODE_ENV !== 'production' && warn(
    `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
  )
  return this
}

首先如果传递了 el 参数,那么就使用 query 函数获取到指定的 DOM 元素并重新赋值给 el 变量,这个元素我们称之为挂载点。接着是一段 if 语句块,检测了挂载点是不是 <body> 元素或者 <html> 元素,如果是的话那么在非生产环境下会打印警告信息,警告你不要挂载到 <body> 元素或者 <html> 元素。为什么不允许这么做呢?那是因为挂载点的本意是 组件挂载的占位 ,它将会被组件自身的模板 替换 掉,而 <body> 元素和 <html> 元素显然是不能被替换掉的。

继续看代码,如下是对 $mount 函数剩余代码的简化:

const options = this.$options
// resolve template/el and convert to render function
if (!options.render) {
  // 省略...
}
return mount.call(this, el, hydrating)

可以看到,首先定义了 options 常量,该常量是 $options 的引用,然后使用一个 if 语句检测否包含 render 选项,即是否包含渲染函数。如果渲染函数存在那么什么都不会做,直接调用运行时版 $mount 函数即可,我们知道运行时版 $mount 仅有两句代码,且真正的挂载是通过调用 mountComponent 函数完成的,所以可想而知 mountComponent 完成挂载所需的必要条件就是: 提供渲染函数给 mountComponent

那么如果 options.render 选项不存在呢?这个时候将会执行 if 语句块的代码,而 if 语句块的代码所做的事情只有一个: 使用 templateel 选项构建渲染函数 。我们看看它是如何构建的,如下是 if 语句块的第一段代码:

let template = options.template
if (template) {
  if (typeof template === 'string') {
    if (template.charAt(0) === '#') {
      template = idToTemplate(template)
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && !template) {
        warn(
          `Template element not found or is empty: ${options.template}`,
          this
        )
      }
    }
  } else if (template.nodeType) {
    template = template.innerHTML
  } else {
    if (process.env.NODE_ENV !== 'production') {
      warn('invalid template option:' + template, this)
    }
    return this
  }
} else if (el) {
  template = getOuterHTML(el)
}

首先定义了 template 变量,它的初始值是 options.template 选项的值,在没有 render 渲染函数的情况下会优先使用 template 选项,并尝试将 template 编译成渲染函数,但开发者未必传递了 template 选项,这时会检测 el 是否存在,存在的话则使用 el.outerHTML 作为 template 的值。如上代码的 if 分支较多,但目标只有一个,即获取合适的内容作为模板( template ),下面的总结阐述了获取模板( template ) 的过程:

  • 如果 template 选项不存在,那么使用 el 元素的 outerHTML 作为模板内容
  • 如果 template 选项存在:
    • template 的类型是字符串
      • 如果第一个字符是 # ,那么会把该字符串作为 css 选择符去选中对应的元素,并把该元素的 innerHTML 作为模板
      • 如果第一个字符不是 # ,那么什么都不做,就用 template 自身的字符串值作为模板
    • template 的类型是元素节点( template.nodeType 存在)
      • 则使用该元素的 innerHTML 作为模板
    • template 既不是字符串又不是元素节点,那么在非生产环境会提示开发者传递的 template 选项无效

经过以上逻辑的处理之后,理想状态下此时 template 变量应该是一个模板字符串,将来用于渲染函数的生成。但这个 template 存在为空字符串的情况,所以即便经过上述逻辑的处理,后续还需要对其进行判断。

另外在上面的代码中使用到了两个工具函数,分别是 idToTemplategetOuterHTML ,这两个函数都定义当前文件。其中 idToTemplate 函数的源码如下:

const idToTemplate = cached(id => {
  const el = query(id)
  return el && el.innerHTML
})

如上代码所示 idToTemplate 是通过 cached 函数创建的。可以在附录 shared/util.js 文件工具方法全解 中查看关于 cached 函数的讲解,该函数的作用是通过缓存来避免重复求值,提升性能。但 cached 函数并不改变原函数的行为,很显然原函数的功能是返回指定元素的 innerHTML 字符串。

getOuterHTML 函数的源码如下:

function getOuterHTML (el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML
  } else {
    const container = document.createElement('div')
    container.appendChild(el.cloneNode(true))
    return container.innerHTML
  }
}

它接收一个 DOM 元素作为参数,并返回该元素的 outerHTML 。我们注意到上面的代码中首先判断了 el.outerHTML 是否存在,也就是说一个元素的 outerHTML 属性未必存在,实际上在 IE9-11SVG 标签元素是没有 innerHTMLouterHTML 这两个属性的,解决这个问题的方案很简单,可以把 SVG 元素放到一个新创建的 div 元素中,这样新 div 元素的 innerHTML 属性的值就等价于 SVG 标签 outerHTML 的值,而这就是上面代码中 else 语句块所做的事情。

接下来我们继续看代码,在处理完 template 选项之后,代码运行到了最关键的阶段,如下:

if (template) {
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile')
  }

  const { render, staticRenderFns } = compileToFunctions(template, {
    shouldDecodeNewlines,
    shouldDecodeNewlinesForHref,
    delimiters: options.delimiters,
    comments: options.comments
  }, this)
  options.render = render
  options.staticRenderFns = staticRenderFns

  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile end')
    measure(`vue ${this._name} compile`, 'compile', 'compile end')
  }
}

在处理完 options.template 选项之后, template 变量中存储着最终用来生成渲染函数的字符串,但正如前面提到过的 template 变量可能是一个空字符串,所以在上面代码中第一句高亮的代码对 template 进行判断,只有在 template 存在的情况下才会执行 if 语句块内的代码,而 if 语句块内的代码的作用就是使用 compileToFunctions 函数将模板( template ) 字符串编译为渲染函数( render ),并将渲染函数添加到 vm.$options 选项中( optionsvm.$options 的引用)。对于 compileToFunctions 函数我们会在讲解 Vue 编译器的时候会详细说明,现在大家只需要知道他的作用即可,实际上在 src/platforms/web/entry-runtime-with-compiler.js 文件的底部我们可以看到这样一句代码:

Vue.compile = compileToFunctions

Vue.compile 函数是 Vue 暴露给开发者的工具函数,他能够将字符串编译为渲染函数。而上面这句代码证明了 Vue.compile 函数就是 compileToFunctions 函数。

另外注意如下代码中高亮的部分:

if (template) {
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile')
  }

  const { render, staticRenderFns } = compileToFunctions(template, {
    shouldDecodeNewlines,
    shouldDecodeNewlinesForHref,
    delimiters: options.delimiters,
    comments: options.comments
  }, this)
  options.render = render
  options.staticRenderFns = staticRenderFns

  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile end')
    measure(`vue ${this._name} compile`, 'compile', 'compile end')
  }
}

这两段高亮的代码是用来统计编译器性能的,我们在 Vue.prototype._init 函数中已经遇到过类似的代码,详细内容可以在 以一个例子为线索 以及 perf.js 文件代码说明 这两个章节中查看。

最后我们来做一下总结,实际上完整版 Vue$mount 函数要做的核心事情就是编译模板( template ) 字符串为渲染函数,并将渲染函数赋值给 vm.$options.render 选项,这个选项将会在真正挂载组件的 mountComponent 函数中。

渲染函数的观察者

无论是完整版 Vue$mount 函数还是运行时版 Vue$mount 函数,他们最终都将通过 mountComponent 函数去真正的挂载组件,接下来我们就看一看在 mountComponent 函数中发生了什么,打开 src/core/instance/lifecycle.js 文件找到 mountComponent 如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 省略...
}

mountComponent 函数接收三个参数,分别是组件实例 vm ,挂载元素 el 以及透传过来的 hydrating 参数。 mountComponent 函数的第一句代码如下:

vm.$el = el

在组件实例对象上添加 $el 属性,其值为挂载元素 el 。我们知道 $el 的值是组件模板根元素的引用,如下代码:

<div id="foo"></div>

<script>
const new Vue({
  el: '#foo',
  template: '<div id="bar"></div>'
})
</script>

上面代码中,挂载元素为是一个 idfoodiv 元素,而组件模板是一个 idbardiv 元素。那么大家思考一个问题: vm.$el 的值应该是哪一个 div 元素的引用?答案是: vm.$elidbardiv 的引用 。这是因为 vm.$el 始终是组件模板的根元素。由于我们传递了 template 选项指定了模板,那么 vm.$el 自然就是 idbardiv 的引用。假设我们没有传递 template 选项,那么根据我们前面的分析, el 选项指定的挂载点将被作为组件模板,这个时候 vm.$el 则是 idfoodiv 元素的引用。

再结合 mountComponent 函数体的这句话: vm.$el = el ,有的同学就会有疑问了,这里明明把 el 挂载元素赋值给了 vm.$el ,那么 vm.$el 怎么可能引用的是 template 选项指定的模板的根元素呢?其实这里仅仅是暂时赋值而已,这是为了给虚拟 DOM 的 patch 算法使用的,实际上 vm.$el 会被 patch 算法的返回值重写,为了证明这一点我们可以打开 src/core/instance/lifecycle.js 文件找到 Vue.prototype._update 方法,如下高亮代码所示:

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  // 省略...

  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  
  // 省略...
}

正如上面高亮的两句代码所示的那样, vm.$el 的值将被 vm.__patch__ 函数的返回值重写。不过现在大家或许还不清楚 Vue.prototype._update 的作用是什么,这块内容我们将在后面的章节详细讲解。

我们继续查看 mountComponent 函数的代码,接下来是一段 if 语句块:

if (!vm.$options.render) {
  vm.$options.render = createEmptyVNode
  if (process.env.NODE_ENV !== 'production') {
    /* istanbul ignore if */
    if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
      vm.$options.el || el) {
      warn(
        'You are using the runtime-only build of Vue where the template ' +
        'compiler is not available. Either pre-compile the templates into ' +
        'render functions, or use the compiler-included build.',
        vm
      )
    } else {
      warn(
        'Failed to mount component: template or render function not defined.',
        vm
      )
    }
  }
}

这段 if 条件语句块首先检查渲染函数是否存在,即 vm.$options.render 是否为真,如果不为真说明渲染函数不存在,这时将会执行 if 语句块内的代码,在 if 语句块内首先将 vm.$options.render 的值设置为 createEmptyVNode 函数,也就是说此时渲染函数的作用将仅仅渲染一个空的 vnode 对象,然后在非生产环境下会根据相应的情况打印警告信息。

在上面这段 if 语句块的下面,执行了 callHook 函数,触发 beforeMount 生命周期钩子:

callHook(vm, 'beforeMount')

在触发 beforeMount 生命周期钩子之后,组件将开始挂载工作,首先是如下这段代码:

let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  updateComponent = () => {
    const name = vm._name
    const id = vm._uid
    const startTag = `vue-perf-start:${id}`
    const endTag = `vue-perf-end:${id}`

    mark(startTag)
    const vnode = vm._render()
    mark(endTag)
    measure(`vue ${name} render`, startTag, endTag)

    mark(startTag)
    vm._update(vnode, hydrating)
    mark(endTag)
    measure(`vue ${name} patch`, startTag, endTag)
  }
} else {
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
}

这段代码的作用只有一个,即定义并初始化 updateComponent 函数,这个函数将用作创建 Watcher 实例时传递给 Watcher 构造函数的第二个参数,这也将使我们第一次真正的接触 Watcher 构造函数,不过现在我们需要先把 updateComponent 函数搞清楚,在上面的代码中首先定义了 updateComponent 变量,虽然是一个 if...else 语句块,其中 if 语句块的条件我们已经遇到过很多次了,在满足该添加的情况下会做一些性能统计,可以看到在 if 语句块中分别统计了 vm._render() 函数以及 vm._update() 函数的运行性能。也就是说说无论是执行 if 语句块还是执行 else 语句块,最终 updateComponent 函数的功能是不变的。

既然功能相同,我们就直接看 else 语句块的代码,因为它要简洁的多:

let updateComponent
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  // 省略...
} else {
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
}

可以看到 updateComponent 是一个函数,该函数的作用是以 vm._render() 函数的返回值作为第一个参数调用 vm._update() 函数。由于我们还没有讲解 vm._render 函数和 vm._update 函数的作用,所以为了让大家更好理解,我们可以简单的认为:

  • vm._render 函数的作用是调用 vm.$options.render 函数并返回生成的虚拟节点( vnode )
  • vm._update 函数的作用是把 vm._render 函数生成的虚拟节点渲染成真正的 DOM

也就是说目前我们可以简单的认为 updateComponent 函数的作用就是: 把渲染函数生成的虚拟 DOM 渲染成真正的 DOM ,其实在 vm._update 内部是通过虚拟 DOM 的补丁算法( patch ) 来完成的,这些我们放到后面的具体章节去讲。

再往下,我们将遇到创建观察者( Watcher ) 实例的代码:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

前面说过,这将是我们第一次真正意义上的遇到观察者构造函数 Watcher ,我们在 揭开数据响应系统的面纱 一章中有提到过,正是因为 watcher 对表达式的求值,触发了数据属性的 get 拦截器函数,从而收集到了依赖,当数据变化时能够触发响应。在上面的代码中 Watcher 观察者实例将对 updateComponent 函数求值,我们知道 updateComponent 函数的执行会间接触发渲染函数( vm.$options.render ) 的执行,而渲染函数的执行则会触发数据属性的 get 拦截器函数,从而将依赖( 观察者 ) 收集,当数据变化时将重新执行 updateComponent 函数,这就完成了重新渲染。同时我们把上面代码中实例化的观察者对象称为 渲染函数的观察者

初识 Watcher

接下来我们就以渲染函数的观察者对象为例,顺着脉络了解 Watcher 类, Watcher 类定义在 src/core/observer/watcher.js 文件中,如下是 Watcher 类的全部内容:

export default class Watcher {

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    
  }

  get () {
    // 省略...
  }

  addDep (dep: Dep) {
    // 省略...
  }

  cleanupDeps () {
    // 省略...
  }

  update () {
    // 省略...
  }

  run () {
    // 省略...
  }

  getAndInvoke (cb: Function) {
    // 省略...
  }

  evaluate () {
    // 省略...
  }

  depend () {
    // 省略...
  }

  teardown () {
    // 省略...
  }
}

通过 Watcher 类的 constructor 方法可以知道在创建 Watcher 实例时可以传递五个参数,分别是:组件实例对象 vm 、要观察的表达式 expOrFn 、当被观察的表达式的值变化时的回调函数 cb 、一些传递给当前观察者对象的选项 options 以及一个布尔值 isRenderWatcher 用来标识该观察者实例是否是渲染函数的观察者。

如下是在 mountComponent 函数中创建渲染函数观察者实例的代码:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

可以看到在创建渲染函数观察者实例对象时传递了全部五个参数,第一个参数 vm 很显然就是当前组件实例对象;第二个参数 updateComponent 就是被观察的目标,它是一个函数;第三个参数 noop 是一个空函数;第四个参数是一个包含 before 函数的对象,这个对象将作为传递给该观察者的选项;第五个参数为 true ,我们知道这个参数标识着该观察者实例对象是否是渲染函数的观察者,很显然上面的代码是在为渲染函数创建观察者对象,所以第五个参数自然为 true

这里有几个问题需要注意,首先被观察的表达式是一个函数,即 updateComponent 函数,我们知道 Watcher 的原理是通过对“被观测目标”的求值,触发数据属性的 get 拦截器函数从而收集依赖,至于“被观测目标”到底是表达式还是函数或者是其他形式的内容都不重要,重要的是“被观测目标”能否触发数据属性的 get 拦截器函数,很显然函数是具备这个能力的。另外一个我们需要注意的是传递给 Watcher 构造函数的第三个参数 noop 是一个空函数,它什么事情都不会做,有的同学可能会有疑问:“不是说好了当数据变化时重新渲染吗,现在怎么什么都不做了?”,实际上数据的变化不仅仅会执行回调,还会重新对“被观察目标”求值,也就是说 updateComponent 也会被调用,所以不需要通过执行回调去重新渲染。说到这里大家或许又产生了一个疑问:“再次执行 updateComponent 函数难道不会导致再次触发数据属性的 get 拦截器函数导致重复收集依赖吗?”,这是个好问题,不过不用担心,因为 Vue 已经实现了避免收集重复依赖的处理,我们后面会讲到的。

接下来我们就从 constructor 函数开始,看一下创建渲染函数观察者实例对象的过程,进一步了解一个观察者,如下是 constructor 函数开头的一段代码:

this.vm = vm
if (isRenderWatcher) {
  vm._watcher = this
}
vm._watchers.push(this)

首先将当前组件实例对象 vm 赋值给该观察者实例的 this.vm 属性,也就是说每一个观察者实例对象都有一个 vm 实例属性,该属性指明了这个观察者是属于哪一个组件的。接着使用 if 条件语句判断 isRenderWatcher 是否为真,前面说过 isRenderWatcher 标识着是否是渲染函数的观察者,只有在 mountComponent 函数中创建渲染函数观察者时这个参数为真,如果 isRenderWatcher 为真那么则会将当前观察者实例赋值给 vm._watcher 属性,也就是说组件实例的 _watcher 属性的值引用着该组件的渲染函数观察者。大家还记得 _watcher 属性是在哪里初始化的吗?是在 initLifecycle 函数中被初始化的,其初始值为 null 。在 if 语句块的后面将当前观察者实例对象 pushvm._watchers 数组中,也就是说属于该组件实例的观察者都会被添加到该组件实例对象的 vm._watchers 数组中,包括渲染函数的观察者和非渲染函数的观察者。另外组件实例的 vm._watchers 属性是在 initState 函数中初始化的,其初始值是一个空数组。

再往下是这样一段代码:

if (options) {
  this.deep = !!options.deep
  this.user = !!options.user
  this.computed = !!options.computed
  this.sync = !!options.sync
  this.before = options.before
} else {
  this.deep = this.user = this.computed = this.sync = false
}

这是一个 if...else 语句块,判断是否传递了 options 参数,如果没有传递则 else 语句块的代码将被执行,可以看到在 else 语句块内将当前观察者实例对象的四个属性 this.deepthis.userthis.computed 以及 this.sync 全部初始化为 false 。如果传递了 options 参数,那么这四个属性的值则会使用 options 对象中同名属性值的真假来初始化。通过 if 语句块内的代码我们可以知道在创建一个观察者对象时,可以传递五个选项,分别是:

  • options.deep ,用来告诉当前观察者实例对象是否是深度观测

我们平时在使用 Vuewatch 选项或者 vm.$watch 函数去观测某个数据时,可以通过设置 deep 选项的值为 true 来深度观测该数据。

  • options.user ,用来标识当前观察者实例对象是 开发者定义的 还是 内部定义的

实际山无论是 Vuewatch 选项还是 vm.$watch 函数,他们都实现都是通过实例化 Watcher 类完成的,等到我们讲解 Vuewatch 选项和 vm.$watch 的具体实现时大家会看到,除了内部定义的观察者(如:渲染函数的观察者、计算属性的观察者等) 之外,所有观察者都被认为是开发者定义的,这时 options.user 会自动被设置为 true

  • options.computed ,用来标识当前观察者实例对象是否是计算属性的观察者

这里需要明确的是,计算属性的观察者并不是指一个观察某个计算属性变化的观察者,而是指 Vue 内部在实现计算属性这个功能时为计算属性创建的观察者。等到我们讲解计算属性的实现时再详细说明。

  • options.sync ,用来告诉观察者当数据变化时是否同步求值并执行回调

默认情况下当数据变化时不会同步求值并执行回调,而是将需要重新求值并执行回调的观察者放到一个异步队列中,当所有数据的变化结束之后统一求值并执行回调,这么做的好处有很多,我们后面会详细讲解。

  • options.before ,可以理解为 Watcher 实例的钩子,当数据变化之后触发更新之前调用

在创建渲染函数的观察者实例对象时传递了 before 选项,如下高亮代码:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

可以看到当数据变化之后,触发更新之前,如果 vm._isMounted 属性的值为真,则会调用 beforeUpdate 生命周期钩子。

再往下又定义了一些实例属性,如下:

this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.computed // for computed watchers

如上代码所示,定义了 this.cb 属性,它的值为 cb 回调函数。定义了 this.id 属性,它是观察者实例对象的唯一标识。定义了 this.active 属性,它标识着该观察者实例对象是否是激活状态,默认值为 true 代表激活。定义了 this.dirty 属性,该属性的值与 this.computed 属性的值相同,也就是说只有计算属性的观察者实例对象的 this.dirty 属性的值才会为真,因为计算属性是惰性求值。

接着往下看代码,如下:

this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()

这四个属性两两一组, this.depsthis.depIds 为一组, this.newDepsthis.newDepIds 为一组。那么这两组属性的作用是什么呢?其实它们就用传说中用来实现避免收集重复依赖,且移除无用依赖的功能也依赖于它们,后面我们会详细讲解,现在大家注意一下这四个属性的数据结构,其中 this.depsthis.newDeps 被初始化为空数组,而 this.depIdsthis.newDepIds 被初始化为 Set 实例对象。

再往下是这句代码:

this.expression = process.env.NODE_ENV !== 'production'
  ? expOrFn.toString()
  : ''

定义了 this.expression 属性,在非生产环境下该属性的值为表达式( expOrFn ) 的字符串表示,在生产环境下其值为空字符串。所以可想而知 this.expression 属性肯定是在非生产环境下使用的,后面我们遇到了再说。

再往下,来到一段 if...else 语句块:

if (typeof expOrFn === 'function') {
  this.getter = expOrFn
} else {
  this.getter = parsePath(expOrFn)
  if (!this.getter) {
    this.getter = function () {}
    process.env.NODE_ENV !== 'production' && warn(
      `Failed watching path: "${expOrFn}" ` +
      'Watcher only accepts simple dot-delimited paths. ' +
      'For full control, use a function instead.',
      vm
    )
  }
}

这段代码检测了 expOrFn 的类型,如果 expOrFn 是函数,那么直接使用 expOrFn 作为 this.getter 属性的值。如果 expOrFn 不是函数,那么将 expOrFn 透传给 parsePath 函数,并以 parsePath 函数的返回值作为 this.getter 属性的值。那么 parsePath 函数做了什么呢? parsePath 函数定义在 src/core/util/lang.js 文件,源码如下:

const bailRE = /[^\w.$]/
export function parsePath (path: string): any {
  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 函数接收的参数是什么,如下是平时我们在使用 $watch 函数的例子:

// 函数
const expOrFn = function () {
  return this.obj.a
}
this.$watch(expOrFn, function () { /* 回调 */ })

// 表达式
const expOrFn = 'obj.a'
this.$watch(expOrFn, function () { /* 回调 */ })

以上两种用法实际上是等价的,当 expOrFn 不是函数时,比如上例中的 'obj.a' 是一个字符串,这时便会将该字符串传递给 parsePath 函数,其实我们可以看到 parsePath 函数的返回值是另一个函数,那么返回的新函数的作用是什么呢?很显然其作用是触发 'obj.a'get 拦截器函数,同时新函数会将 'obj.a' 的值返回。

接下来我们具体看一下 parsePath 函数的具体实现,首先来看一下在 parsePath 函数之前定义的 bailRE 正则:

const bailRE = /[^\w.$]/

同时在 parsePath 函数开头有一段 if 语句,使用该正则来匹配传递给 parsePath 的参数 path ,如果匹配则直接返回( return ),且返回值是 undefined ,也就是说如果 path 匹配正则 bailRE 那么最终 this.getter 将不是一个函数而是 undefined 。那么这个正则是什么含义呢?这个正则将匹配一个位置,该位置满足三个条件:

  • 不是 \w ,也就是说这个位置不能是 字母数字下划线汉字
  • 不是字符 .
  • 不是字符 $

举几个例子如 obj~aobj/aobj*aobj+a 等,这些字符串中的 ~/* 以及 + 字符都能成功匹配正则 bailRE ,这时 parsePath 函数将返回 undefined ,也就是解析失败。实际上这些字符串在 javascript 中不是一个合法的访问对象属性的语法,按照 bailRE 正则只有如下这几种形式的字符串才能解析成功: obj.athis.$watch 等,看到这里你也应该知道为什么 bailRE 正则中包含字符 .$

回过头来,如果参数 path 不满足正则 bailRE ,那么如下高亮的代码将被执行:

export function parsePath (path: string): any {
  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
  }
}

首先定义 segments 常量,它的值是通过字符 . 分割 path 字符串产生的数组,随后 parsePath 函数将返回值一个函数,该函数的作用是遍历 segments 数组循环访问 path 指定的属性值。这样就触发了数据属性的 get 拦截器函数。但要注意 parsePath 返回的新函数将作为 this.getter 的值,只有当 this.getter 被调用的时候,这个函数才会执行。

看完了 parsePath 函数,我们在回到如下这段代码中:

if (typeof expOrFn === 'function') {
  this.getter = expOrFn
} else {
  this.getter = parsePath(expOrFn)
  if (!this.getter) {
    this.getter = function () {}
    process.env.NODE_ENV !== 'production' && warn(
      `Failed watching path: "${expOrFn}" ` +
      'Watcher only accepts simple dot-delimited paths. ' +
      'For full control, use a function instead.',
      vm
    )
  }
}

现在我们明白了观察者实例对象的 this.getter 函数终将会是一个函数,如果不是函数,如上高亮代码所示。此时只有一种可能,那就是 parsePath 函数在解析表达式的时候失败了,那么这时在非生产环境会打印警告信息,告诉开发者: Watcher 只接受简单的点( . ) 分隔路径,如果你要用全部的 js 语法特性直接观察一个函数即可

再往下我们来到了 constructor 函数的最后一段代码:

if (this.computed) {
  this.value = undefined
  this.dep = new Dep()
} else {
  this.value = this.get()
}

通过这段代码我们可以发现,计算属性的观察者和其他观察者实例对象的处理方式是不同的,对于计算属性的观察者我们会在讲解计算属性是详细说明。除计算属性的观察者之外的所有观察者实例对象都将执行如上代码的 else 分支语句,即调用 this.get() 方法。

依赖收集的过程

this.get() 是我们遇到的第一个观察者对象的实例方法,它的作用可以用两个字描述即: 求值 。求值的目的有两个,第一能够触访问器属性的 get 拦截器函数,第二能能够获得被观察目标的值。而且能够触发访问器属性的 get 拦截器函数是依赖被收集的关键,下面我们具体查看一下 this.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
}

如上是 this.get() 方法的全部代码,一上来调用了 pushTarget(this) 函数,并将当前观察者实例对象作为参数传递,这里的 pushTarget 函数来自于 src/core/observer/dep.js 文件,如下代码所示:

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

Dep.target = null
const targetStack = []

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

export function popTarget () {
  Dep.target = targetStack.pop()
}

src/core/observer/dep.js 文件中定义了 Dep 类,我们在 揭开数据响应系统的面纱 一章中就遇到过 Dep 类,当时我们说每个响应式数据的属性都通过闭包引用着一个用来收集属于自身依赖的“筐”,实际上那个“筐”就是 Dep 类的实例对象。更多关于 Dep 类的内容我们会在合适的地方讲解,现在我们的主要目的是搞清楚 pushTarget 函数是做什么的。在上面这段代码中我们可以看到 Dep 类拥有一个静态属性,即 Dep.target 属性,该属性的初始值为 null ,其实 pushTarget 函数的作用就是用来为 Dep.target 属性赋值的, pushTarget 函数会将接收到的参数赋值给 Dep.target 属性,我们知道传递给 pushTarget 函数的参数就是调用该函数的观察者对象,所以 Dep.target 保存着一个观察者对象,其实这个观察者对象就是即将要收集的目标。

我们再回到 this.get() 方法中,如下是简化后的代码:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    // 省略...
  } finally {
    // 省略...
  }
  return value
}

在调用 pushTarget 函数之后,定义了 value 变量,该变量的值为 this.getter 函数的返回值,我们知道观察者对象的 this.getter 属性是一个函数,这个函数的执行就意味着对被观察目标的求值,并将得到的值赋值给 value 变量,而且我们可以看到 this.get 方法的最后将 value 返回,为什么要强调这一点呢?如下代码所示:

constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  // 省略...
  if (this.computed) {
    this.value = undefined
    this.dep = new Dep()
  } else {
    this.value = this.get()
  }
}

这句高亮的代码将 this.get() 方法的返回值赋值给了观察者实例对象的 this.value 属性。也就是说 this.value 属性保存着被观察目标的值。

this.get() 方法除了对被观察目标求值之外,大家别忘了正是因为对被观察目标的求值才得以触发数据属性的 get 拦截器函数,还是以渲染函数的观察者为例,假设我们有如下模板:

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

这段模板被编译将生成如下渲染函数:

// 编译生成的渲染函数是一个匿名函数
function anonymous () {
  with (this) {
    return _c('div',
      { attrs:{ "id": "demo" } },
      [_v("\n      "+_s(name)+"\n    ")]
    )
  }
}

大家看不懂渲染函数没关系,关于模板到渲染函数的编译过程我们会在编译器相关章节为大家讲解,现在大家只需要注意如上高亮的那句代码,可以发现渲染函数的执行会读取数据属性 name 的值,这将会触发 name 属性的 get 拦截器函数,如下代码截取自 defineReactive 函数:

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

这段代码我们已经很熟悉了,它是数据属性的 get 拦截器函数,由于渲染函数读取了 name 属性的值,所以 name 属性的 get 拦截器函数将被执行,大家注意如上代码中高亮的两句代码,首先判断了 Dep.target 是否存在,如果存在则调用 dep.depend 方法收集依赖。那么 Dep.target 是否存在呢?答案是存在,这就是为什么 pushTarget 函数要在调用 this.getter 函数之前被调用的原因。既然 dep.depend 方法被执行,那么我们就找到 dep.depend 方法,如下:

depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

dep.depend 方法内部又判断了一次 Dep.target 是否有值,有的同学可能会有疑问,这不是多此一举吗?其实这么做并不多于,因为 dep.depend 方法除了在属性的 get 拦截器函数内被调用之外还在其他地方被调用了,这时候就需要对 Dep.target 做判断,至于在哪里调用的我们后面会讲到。另外我们发现在 depend 方法内部其实并没有真正的执行收集依赖的动作,而是调用了观察者实例对象的 addDep 方法: Dep.target.addDep(this) ,并以当前 Dep 实例对象作为参数。为了搞清楚这么做的目的,我们找到观察者实例对象的 addDep 方法,如下:

addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

可以看到 addDep 方法接收一个参数,这个参数是一个 Dep 对象,在 addDep 方法内部首先定义了常量 id ,它的值是 Dep 实例对象的唯一 id 值。接着是一段 if 语句块,该 if 语句块的代码很关键,因为它的作用就是用来 避免收集重复依赖 的,既然是用来避免收集重复的依赖,那么就不得不用到我们前面提到过的两组属性,即 newDepIdsnewDeps 以及 depIdsdeps 。为了让大家更好的理解,我们思考一下可不可以把 addDep 方法修改成如下这样:

addDep (dep: Dep) {
  dep.addSub(this)
}

首先解释一下 dep.addSub 方法,它的源码如下:

addSub (sub: Watcher) {
  this.subs.push(sub)
}

addSub 方法接收观察者对象作为参数,并将接收到的观察者添加到 Dep 实例对象的 subs 数组中,其实 addSub 方法才是真正用来收集观察者的方法,并且收集到的观察者都会被添加到 subs 数组中存起来。

了解了 addSub 方法之后,我们再回到如下这段代码:

addDep (dep: Dep) {
  dep.addSub(this)
}

我们修改了 addDep 方法,直接在 addDep 方法内调用 dep.addSub 方法,并将当前观察者对象作为参数传递。这不是很好吗?难道有什么问题吗?当然有问题,假如我们有如下模板:

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

这段模板的不同之处在于我们使用了两次 name 数据,那么相应的渲染函数也将变为如下这样:

function anonymous () {
  with (this) {
    return _c('div',
      { attrs:{ "id": "demo" } },
      [_v("\n      "+_s(name)+_s(name)+"\n    ")]
    )
  }
}

可以看到,渲染函数的执行将读取两次数据对象 name 属性的值,这必然会触发两次 name 属性的 get 拦截器函数,同样的道理, dep.depend 也将被触发两次,最后导致 dep.addSub 方法被执行了两次,且参数一模一样,这样就产生了同一个观察者被收集多次的问题。所以我们不能像如上那样修改 addDep 函数的代码,那么此时我相信大家也应该知道如下高亮代码的含义了:

addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

addDep 内部并不是直接调用 dep.addSub 收集观察者,而是先根据 dep.id 属性检测该 Dep 实例对象是否已经存在于 newDepIds 中,如果存在那么说明已经收集过依赖了,什么都不会做。如果不存在才会继续执行 if 语句块的代码,同时将 dep.id 属性和 Dep 实例对象本身分别添加到 newDepIdsnewDeps 属性中,这样无论一个数据属性被读取了多少次,对于同一个观察者它只会收集一次。

不过有的同学可能注意到了,如下高亮代码所示:

addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

这里的判断条件 !this.depIds.has(id) 是什么意思呢?我们知道 newDepIds 属性用来避免在一次求值的过程中收集重复的依赖,其实 depIds 属性是用来在 多次求值 中避免收集重复依赖的。什么是多次求值,其实所谓多次求值是指当数据变化时重新求值的过程。大家可能会疑惑,难道重新求值的时候不能用 newDepIds 属性来避免收集重复的依赖吗?不能,原因在于每一次求值之后 newDepIds 属性都会被清空,也就是说每次重新求值的时候对于观察者实例对象来讲 newDepIds 属性始终是全新的。虽然每次求值之后会清空 newDepIds 属性的值,但在清空之前会把 newDepIds 属性的值以及 newDeps 属性的值赋值给 depIds 属性和 deps 属性,这样重新求值的时候 depIds 属性和 deps 属性将会保存着上一次求值中 newDepIds 属性以及 newDeps 属性的值。为了证明这一点,我们来看一下观察者对象的求值方法,即 get() 方法:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    // 省略...
  } finally {
    // 省略...
    popTarget()
    this.cleanupDeps()
  }
  return value
}

可以看到在 finally 语句块内调用了观察者对象的 cleanupDeps 方法,这个方法的作用正如我们前面所说的那样,每次求值完毕后都会使用 depIds 属性和 deps 属性保存 newDepIds 属性和 newDeps 属性的值,然后再清空 newDepIds 属性和 newDeps 属性的值,如下是 cleanupDeps 方法的源码:

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this)
    }
  }
  let tmp = this.depIds
  this.depIds = this.newDepIds
  this.newDepIds = tmp
  this.newDepIds.clear()
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}

cleanupDeps 方法内部,首先是一个 while 循环,我们暂且不关心这个循环的作用,我们看循环下面的代码,即高亮的部分,这段代码是典型的引用类型变量交换值的过程,最终的结果就是 newDepIds 属性和 newDeps 属性被清空,并且在被清空之前把值分别赋给了 depIds 属性和 deps 属性,这两个属性将会用在下一次求值时避免依赖的重复收集。

现在我们可以做几点总结:

  • 1、 newDepIds 属性用来在一次求值中避免收集重复的观察者
  • 2、每次求值并收集观察者完成之后会清空 newDepIdsnewDeps 这两个属性的值,并且在被清空之前把值分别赋给了 depIds 属性和 deps 属性
  • 3、 newDeps 属性用来避免重复求值时收集重复的观察者

通过以上三点内容我们可以总结出一个结论,即 newDepIdsnewDeps 这两个属性的值所存储的总是当次求值所收集到的 Dep 实例对象,而 depIdsdeps 这两个属性的值所存储的总是上一次求值过程中所收集到的 Dep 实例对象。

除了以上三点之外,其实 deps 属性还能够用来移除废弃的观察者, cleanupDeps 方法中开头的那段 while 循环就是用来实现这个功能的,如下代码所示:

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this)
    }
  }
  // 省略...
}

这段 while 循环就是对 deps 数组进行遍历,也就是对上一次求值所收集到的 Dep 对象进行遍历,然后在循环内部检查上一次求值所收集到的 Dep 实例对象是否存在于当前这次求值所收集到的 Dep 实例对象中,如果不存在则说明该 Dep 实例对象已经和该观察者不存在依赖关系了,这时就会调用 dep.removeSub(this) 方法并以该观察者实例对象作为参数传递,从而将该观察者对象从 Dep 实例对象中移除。

我们可以找到 Dep 类的 removeSub 实例方法,如下:

removeSub (sub: Watcher) {
  remove(this.subs, sub)
}

它的内容很简单,接收一个要被移除的观察者作为参数,然后使用 remove 工具函数,将该观察者从 this.subs 数组中移除。其中 remove 工具函数来自 src/shared/util.js 文件,可以在 shared/util.js 文件工具方法全解 中查看。

触发依赖的过程

在上一小节中我们提到了,每次求值并收集完观察者之后,会将当次求值所收集到的观察者保存到另外一组属性中,即 depIdsdeps ,并将存有当次求值所收集到的观察者的属性清空,即清空 newDepIdsnewDeps 。我们当时也说过了,这么做的目的是为了对比当次求值与上一次求值所收集到的观察者的变化情况,并作出合理的矫正工作,比如移除那么已经没有关联关系的观察者等。本节我们将以数据属性的变化为切入点,讲解重新求值的过程。

假设我们有如下模板:

<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()
    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 属性用来标识一个观察者是否处于激活状态,或者可用状态。如果观察者处于激活状态那么 thisd.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

上面的代码中由于 obj1obj2 具有相同的引用,所以他们总是相等的,但其实在数据已经变化了,这就是判断 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 设置为 true ,代表着已经求过值了。

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

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 ) 的字符串表示,这样开发者就能清楚的知道是哪里发生了错误。

异步更新队列

异步更新的意义

接下来我们就聊一聊 Vue 中的异步更新队列。在上一节中我们讲解了触发依赖的过程,举个例子如下:

<div id="app">
  <p>{{name}}</p>
</div>

<script>
  new Vue({
    el: '#app',
    data: {
      name: ''
    },
    mounted () {
      this.name = 'hcy'
    }
  })
</script>

如上代码所示,我们在模板中使用了数据对象的 name 属性,这意味着 name 属性将会收集渲染函数的观察者作为依赖,接着我们在 mounted 钩子中修改了 name 属性的值,这样就会触发响应: 渲染函数的观察者会重新求值,完成重渲染 ,这个过程可以用一张图来描述,如下图所示:

[图片丢失]

上图描述了一个同步的视图更新过程,从属性值的变化到完成重新渲染,这是一个同步更新的过程,大家思考一下“同步更新”会导致什么问题?很显然这会导致每次属性值的变化都会引发一次重新渲染,假设我们要修改两个属性的值,那么同步更新将导致两次的重渲染,如下图所示:

[图片丢失]

有时候这是致命的缺陷,想象一下复杂业务场景,你可能会同时修改很多属性的值,如果每次属性值的变化都要重新渲染,就会导致严重的性能问题,而异步更新队列就是用来解决这个问题的,为了让大家更好的理解,我们同样用一张图来描述异步更新的过程,如下:

[图片丢失]

上图描述了异步更新的过程,与同步更新的不同之处在于,每次修改属性的值之后并没有立即重新求值,而是将需要执行更新操作的观察者放入一个队列中。当我们修改 name 属性值时,由于 name 属性收集了渲染函数的观察者(后面我们称其为 renderWatcher ) 作为依赖,所以此时 renderWatcher 会被添加到队列中,接着我们修改了 age 属性的值,由于 age 属性也收集了 renderWatcher 作为依赖,所以此时也会尝试将 renderWatcher 添加到队列中,但是由于 renderWatcher 已经存在于队列中了,所以并不会重复添加,这样队列中将只会存在一个 renderWatcher 。当所有的突变完成之后,在一次性的执行队列中所有观察者的更新方法,同时清空队列,这样就达到了优化的目的。

接下来我们就从具体代码入手,看一看其具体实现,我们知道当修改一个属性的值时,会通过执行该属性所收集的所有观察者对象的 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
      nextTick(flushSchedulerQueue)
    }
  }
}

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

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

其中变量 has 定义在 scheduler.js 文件头部,它是一个空对象:

let has: { [key: number]: ?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
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 省略...
    }
    // 省略...
  }
}

其中 queue 常量也定义在 scheduler.js 文件的头部:

const queue: Array<Watcher> = []

queue 常量是一个数组,入队就是调用该数组的 push 方法将观察者添加到数组的尾部。在入队之前有一个对变量 flushing 的判断, flushing 变量也定义在 scheduler.js 文件的头部,它的初始值是 false

let flushing = false

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

这段代码是一个 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
      setTimeout(flushSchedulerQueue, 0)
    }
  }
}

我们完全可以使用 setTimeout 替换 nextTick ,我们只需要执行一次 setTimeout 语句即可, waiting 变量就保证了 setTimeout 语句只会执行一次,这样 flushSchedulerQueue 函数将会在下一次事件循环开始时立即调用,但是既然可以使用 setTimeout 替换 nextTick 那么为什么不用 setTimeout 呢?原因就在于 setTimeout 并不是最优的选择, nextTick 的意义就是它会选择一条最优的解决方案,接下来我们就讨论一下 nextTick 是如何实现的。

nextTick 的实现

nextTick 函数来自于 src/core/util/next-tick.js 文件,对于 nextTick 函数相信大家都不陌生,我们常用的 $nextTick 方法实际上就是对 nextTick 函数的封装,如下:

export function renderMixin (Vue: Class<Component>) {
  // 省略...
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }
  // 省略...
}

$nextTick 方法是在 renderMixin 函数中挂载到 Vue 原型上的,可以看到 $nextTick 函数体只有一句话即调用 nextTick 函数,这说明 $nextTick 确实是对 nextTick 函数的简单包装。

前面说过 nextTick 函数的作用相当于 setTiomeout(fn, 0) ,这里有几个概念需要大家去了解一下,即调用栈、任务队列、事件循环, javascript 是一种单线程的语言,它的一切都是建立在以这三个概念为基础之上的。详细内容在这里就不讨论了,读者自行补充,后面的讲解将假设大家对这些概念已经非常清楚了。

我们知道任务队列并非只有一个队列,在 node 中更为复杂,但总的来说我们可以将其分为 microtask(macro)task ,并且这两个队列的行为还要依据不同浏览器的具体实现去讨论,这里我们只讨论被广泛认同和接受的队列执行行为。当调用栈空闲后每次事件循环只会从 (macro)task 中读取一个任务并执行,而在同一次事件循环内会将 microtask 队列中所有的任务全部执行完毕,且要先于 (macro)task 。另外 (macro)task 中两个不同的任务之间可能穿插着 UI 的重渲染,那么我们只需要在 microtask 中把所有在 UI 重渲染之前需要更新的数据全部更新,这样只需要一次重渲染就能得到最新的 DOM 了。恰好 Vue 是一个数据驱动的框架,如果能在 UI 重渲染之前更新所有数据状态,这对性能的提升是一个很大的帮助,所有要优先选用 microtask 去更新数据状态而不是 (macro)task ,这就是为什么不使用 setTimeout 的原因,因为 setTimeout 会将回调放到 (macro)task 队列中而不是 microtask 队列,所以理论上最优的选择是使用 Promise ,当浏览器不支持 Promise 时再降级为 setTimeout 。如下是 next-tick.js 文件中的一段代码:

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 定义在文件头部,它的初始值是 undefined ,上面的代码中首先检测当前宿主环境是否支持原生的 Promise ,如果支持则优先使用 Promise 注册 microtask ,做法很简单,首先定义常量 p 它的值是一个立即 resolvePromise 实例对象,接着将变量 microTimerFunc 定义为一个函数,这个函数的执行将会把 flushCallbacks 函数注册为 microtask 。另外大家注意这句代码:

if (isIOS) setTimeout(noop)

注释已经写得很清楚了,这是一个解决怪异问题的变通方法,在一些 UIWebViews 中存在很奇怪的问题,即 microtask 没有被刷新,对于这个问题的解决方案就是让浏览做一些其他的事情比如注册一个 (macro)task 即使这个 (macro)task 什么都不做,这样就能够间接触发 microtask 的刷新。

使用 Promise 是最理想的方案,但是如果宿主环境不支持 Promise ,我们就需要降级处理,即注册 (macro)task ,这就是 else 语句块内代码所做的事情:

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 省略... 
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

macroTimerFunc 的值赋值给 microTimerFunc 。我们知道 microTimerFunc 用来将 flushCallbacks 函数注册为 microtask ,而 macroTimerFunc 则是用来将 flushCallbacks 函数注册为 (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)
  }
}

将一个回调函数注册为 (macro)task 的方式有很多,如 setTimeoutsetInterval 以及 setImmediate 等等,但不同的方案之间是有区别的,通过上面的代码我们可以看到 setTimeout 被作为最后的备选方案:

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 省略...
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  // 省略...
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

而首选方案是 setImmediate

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  // 省略...
} else {
  // 省略...
}

如果宿主环境支持原生 setImmediate 函数,则使用 setImmediate 注册 (macro)task ,为什么首选 setImmediate 呢?这是有原因的,因为 setImmediate 拥有比 setTimeout 更好的性能,这个问题很好理解, setTimeout 在将回调注册为 (macro)task 之前要不停的做超时检测,而 setImmediate 则不需要,这就是优先选用 setImmediate 的原因。但是 setImmediate 的缺陷也很明显,就是它的兼容性问题,到目前为止只有 IE 浏览器实现了它,所以为了兼容非 IE 浏览器我们还需要做兼容处理,只不过此时还轮不到 setTimeout 上场,而是使用 MessageChannel

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 省略...
} 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 {
  // 省略...
}

相信大家应该了解过 Web Workers ,实际上 Web Workers 的内部实现就是用到了 MessageChannel ,一个 MessageChannel 实例对象拥有两个属性 port1prot2 ,我们只需要让其中一个 prot 监听 onmessage 事件,然后使用另外一个 protpostMessage 向前一个 prot 发送消息即可,这样前一个 protonmessage 回调就会被注册为 (macro)task ,由于它也不需要做任何检测工作,所以性能也要优于 setTimeout 。总之 macroTimerFunc 函数的作用就是将 flushCallbacks 注册为 (macro)task

现在是时候仔细看一下 nextTick 函数都做了什么事情了,不过为了更融入理解 nextTick 函数的代码,我们需要从 $nextTick 方法入手,如下:

export function renderMixin (Vue: Class<Component>) {
  // 省略...
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }
  // 省略...
}

$nextTick 方法只接收一个回调函数作为参数,但在内部调用 nextTick 函数时,除了把回调函数 fn 透传之外,第二个参数是硬编码为当前组件实例对象 this 。我们知道在使用 $nextTick 方法时是可以省略回调函数这个参数的,这时 $nextTick 方法会返回一个 promise 实例对象。这些功能实际上都是有 nextTick 函数提供的,如下是 nextTick 函数的签名:

export function nextTick (cb?: Function, ctx?: Object) {
  // 省略...
}

nextTick 函数接收两个参数,第一个参数是一个回调函数,第二个参数指定一个作用域。下面我们逐个分析传递回调函数与不传递回调函数这两种使用场景功能的实现,首先我们来看传递回调函数的情况,那么此时参数 cb 就是回调函数,来看如下代码:

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)
    }
  })
  // 省略
}

nextTick 函数会在 callbacks 数组中添加一个新的函数, callbacks 数组定义在文件头部: const callbacks = [] 。注意并不是将 cb 回调函数直接添加到 callbacks 数组中,但这个被添加到 callbacks 数组中的函数的执行会间接调用 cb 回调函数,并且可以看到在调用 cb 函数时使用 .call 方法将函数 cb 的作用域设置为 ctx ,也就是 nextTick 函数的第二个参数。所以对于 $nextTick 方法来讲,传递给 $nextTick 方法的回调函数的作用域就是当前组件实例对象,当然了前提是回调函数不能是箭头函数,其实在平时的使用中,回调函数使用箭头函数也没关系,只要你能够达到你的目的即可。另外我们再次强调一遍,此时回调函数并没有被执行,当你调用 $nextTick 方法并传递回调函数时,会使用一个新的函数包裹回调函数并将新函数添加到 callbacks 数组中。

我们继续看 nextTick 函数的代码,如下:

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

在将回调函数添加到 callbacks 数组之后,会进行一个 if 条件判断,判断变量 pending 的真假, pending 变量也定义在文件头部: let pending = false ,它是一个标识,它的真假代表回调队列是否处于等待刷新的状态,初始值是 false 代表回调队列为空不需要等待刷新。假如此时在某个地方调用了 $nextTick 方法,那么 if 语句块内的代码将会被执行,在 if 语句块内优先将将变量 pending 的值设置为 true ,代表着此时回调队列不为空,正在等待刷新。既然等待刷新,那么当然要刷新回调队列啊,怎么刷新呢?这时就用到了我们前面讲过的 microTimerFunc 或者 macroTimerFunc 函数,我们知道这两个函数的作用是将 flushCallbacks 函数分别注册为 microtask(macro)task 。但是无论哪种任务类型,它们都将会等待调用栈清空之后才执行。如下:

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

上面的代码中我们在 created 钩子中连续调用三次 $nextTick 方法,但只有第一次调用 $nextTick 方法时才会执行 microTimerFunc 函数将 flushCallbacks 注册为 microtask ,但此时 flushCallbacks 函数并不会执行,因为它要等待接下来的两次 $nextTick 方法的调用语句执行完后才会执行,或者准确的说等待调用栈被清空之后才会执行。也就是说当 flushCallbacks 函数执行的时候, callbacks 回调队列中将包含本次事件循环所收集的所有通过 $nextTick 方法注册的回调,而接下来的任务就是在 flushCallbacks 函数内将这些回调全部执行并清空。如下是 flushCallbacks 函数的源码:

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

很好理解,首先将变量 pending 重置为 false ,接着开始执行回调,但需要注意的是在执行 callbacks 队列中的回调函数时并没有直接遍历 callbacks 数组,而是使用 copies 常量保存一份 callbacks 的复制,然后遍历 copies 数组,并且在遍历 copies 数组之前将 callbacks 数组清空: callbacks.length = 0 。为什么要这么做呢?这么做肯定是有原因的,我们模拟一下整个异步更新的流程就明白了,如下代码:

created () {
  this.name = 'HcySunYang'
  this.$nextTick(() => {
    this.name = 'hcy'
    this.$nextTick(() => { console.log('第二个 $nextTick') })
  })
}

上面代码中我们在外层 $nextTick 方法的回调函数中再次调用了 $nextTick 方法,理论上外层 $nextTick 方法的回调函数不应该与内层 $nextTick 方法的回调函数不应该在同一个 microtask 任务中被执行,而是两个不同的 microtask 任务,虽然在结果上看或许没什么却别,当从设计角度就应该这么做。

我们注意上面代码中我们修改了两次 name 属性的值(假设它是响应式数据),首先我们将 name 属性的值修改为字符串 HcySunYang ,我们前面讲过这会导致依赖于 name 属性的渲染函数观察者被添加到 queue 队列中,这个过程是通过调用 src/core/observer/scheduler.js 文件中的 queueWatcher 函数完成的。同时在 queueWatcher 函数内会使用 nextTickflushSchedulerQueue 添加到 callbacks 数组中,所以此时 callbacks 数组如下:

callbacks = [
  flushSchedulerQueue // queue = [renderWatcher]
]

同时会注册将 flushCallbacks 函数注册为 microtask ,所以此时 microtask 队列如下:

// microtask 队列
[
  flushCallbacks
]

接着调用了第一个 $nextTick 方法, $nextTick 方法会将其回调数添加到 callbacks 数组中,那么此时的 callbacks 数组如下:

callbacks = [
  flushSchedulerQueue, // queue = [renderWatcher]
  () => {
    this.name = 'hcy'
    this.$nextTick(() => { console.log('第二个 $nextTick') })
  }
]

接下来主线程处于空闲状态(调用栈清空),开始执行 microtask 队列中的任务,即执行 flushCallbacks 函数, flushCallbacks 函数会按照顺序执行 callbacks 数组中的函数,首先会执行 flushSchedulerQueue 函数,这个函数会遍历 queue 中的所有观察者并重新求值,完成重新渲染,在完成渲染之后,本次更新队列已经清空, queue 会被重置为空数组,一切状态还原。接着会执行如下函数:

() => {
  this.name = 'hcy'
  this.$nextTick(() => { console.log('第二个 $nextTick') })
}

这个函数是第一个 $nextTick 方法的回调函数,由于在执行该回调函数之前已经完成了重新渲染,所以该回调函数内的代码是能够访问更新后的 DOM 的,到目前为止一切都很正常,我们继续往下看,在该回调函数内再次修改了 name 属性的值为字符串 hcy ,这会再次触发响应,同样的会调用 nextTick 函数将 flushSchedulerQueue 添加到 callbacks 数组中,但是由于在执行 flushCallbacks 函数时优先将 pending 的重置为 false ,所以 nextTick 函数会将 flushCallbacks 函数注册为一个新的 microtask ,此时 microtask 队列将包含两个 flushCallbacks 函数:

// microtask 队列
[
  flushCallbacks, // 第一个 flushCallbacks
  flushCallbacks  // 第二个 flushCallbacks
]

怎么样?我们的目的达到了,现在有两个 microtask 任务。

而另外除了将变量 pending 的值重置为 false 之外,我们要知道第一个 flushCallbacks 函数遍历的并不是 callbacks 本身,而是它的赋值品 copies 数组,并且在第一个 flushCallbacks 函数的一开头就清空了 callbacks 数组本身。所以第二个 flushCallbacks 函数的一切流程与第一个 flushCallbacks 是完全相同。

最后我们再来讲一下,当调用 $nextTick 方法时不传递回调函数时,是如何实现返回 Promise 实例对象的,实现很简单我们来看一下 nextTick 函数的代码,如下:

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 省略...
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

如上高亮代码所示,当 nextTick 函数没有接收到 cb 参数时,会检测当前宿主环境是否支持 Promise ,如果支持则直接返回一个 Promise 实例对象,并且将 resolve 函数赋值给 _resolve 变量, _resolve 变量声明在 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)
    }
  })
  // 省略...
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

flushCallbacks 函数开始执行 callbacks 数组中的函数时,如果没有传递 cb 参数,则直接调用 _resolve 函数,我们知道这个函数就是返回的 Promise 实例对象的 resolve 函数。这样就实现了 Promise 方式的 $nextTick 方法。

$watch 和 watch 选项的实现

前面我们已经讲了足够多关于 Watcher 类的内容,接下来是时候看一下 $watch 方法已经 watch 选项的实现了。实际上无论是 $watch 方法还是 watch 选项,他们的实现都是基于 Watcher 的封装。首先我们来看一下 $watch 方法,它定义在 src/core/instance/state.js 文件的 stateMixin 函数中,如下:

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

$watch 方法允许我们观察数据对象的某个属性,当属性变化时执行回调。所以 $watch 方法至少接收两个参数,一个要观察的属性,以及一个回调函数。通过上面的代码我们发现, $watch 方法接收三个参数,除了前面介绍两个参数之后还接受第三个参数,它是一个选项参数,比如是否立即执行回调或者是否深度观测等。我们可以发现这三个参数与 Watcher 类的构造函数中的三个参数相匹配,如下:

export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // 省略...
  }
}

其实这很好理解,因为 $watch 方法的实现本质就是创建了一个 Watcher 实例对象。另外通过官方文档的介绍可知 $watch 方法的第二个参数既可以是一个回调函数,也可以是一个纯对象,这个对象中可以包含 handler 属性,该属性的值将作为回调函数,同时该对象的还可以包含其他属性作为选项参数,如 immediatedeep

现在我们假设传递给 $watch 方法的第二个参数是一个函数,看看它是怎么实现的,在 $watch 方法内部首先执行的是如下代码:

const vm: Component = this
if (isPlainObject(cb)) {
  return createWatcher(vm, expOrFn, cb, options)
}

定义了 vm 常量,它是当前组件实例对象,接着检测传递给 $watch 的第三个参数是否是纯对象,由于我们现在假设参数 cb 是一个函数,所以这段 if 语句块内的代码不会执行。再往下是这段代码:

options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)

首先如果没有传递 options 选项参数,那么会给其一个默认的空对象,接着将 options.user 的值设置为 true ,我们前面讲到过这代表该观察者是实例时用户创建的,然后就到了关键的一步,即创建 Watcher 实例对象,多么简单的实现。

再往下是一段 if 语句块:

if (options.immediate) {
  cb.call(vm, watcher.value)
}

我们知道 immediate 选项用来在属性或函数被侦听后立即执行回调,如果代码就是其实现原理,如果发现 options.immediate 选项为真,那么会执行执行回调函数,不过此时回调函数的参数只有新值没有旧值。同时取值的方式是通过前面创建的观察者实例对象的 watcher.value 属性。我们知道观察者实例对象的 value 属性,保存着被观察属性的值。

最后 $watch 方法还有一个返回值,如下:

return function unwatchFn () {
  watcher.teardown()
}

$watch 函数返回一个函数,这个函数的执行会解除当前观察者对属性的观察。它的原理是通过调用观察者实例对象的 watcher.teardown 函数实现的。我们可以看一下 watcher.teardown 函数是如何解除观察者与属性之间的关系的,如下是 teardown 函数的代码:

export default class Watcher {
  // 省略...
  /**
   * Remove self from all dependencies' subscriber list.
   */
  teardown () {
    if (this.active) {
      // remove self from vm's watcher list
      // this is a somewhat expensive operation so we skip it
      // if the vm is being destroyed.
      if (!this.vm._isBeingDestroyed) {
        remove(this.vm._watchers, this)
      }
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)
      }
      this.active = false
    }
  }
}

首先检查 this.active 属性是否为真,如果为假则说明该观察者已经不处与激活状态,什么都不需要做,如果为真则会执行 if 语句块内的代码,在 if 语句块内首先执行的这段代码:

if (!this.vm._isBeingDestroyed) {
  remove(this.vm._watchers, this)
}

首先说明一点每个组件实例都有一个 vm._isBeingDestroyed 属性,它是一个标识,为真说明该组件实例已经被销毁了,为假说明该组件还没有被销毁,所以以上代码的意思是如果组件没有被销毁,那么将当前观察者实例从组件实例对象的 vm._watchers 数组中移除,我们知道 vm._watchers 数组中包含了该组件在所有的观察者实例对象,所以将当前观察者实例对象从 vm._watchers 数组中移除是解除属性与观察者实例对象之间关系的第一步。由于这个参数的性能开销比较大,所以仅在组件没有被销毁的情况下才会执行此操作。

将观察者实例对象从 vm._watchers 数组中移除之后,会执行如下这段代码:

let i = this.deps.length
while (i--) {
  this.deps[i].removeSub(this)
}

我们知道当一个属性与一个观察者建立联系之后,属性的 Dep 实例对象会收集到该观察者对象,同时观察者对象也会将该 Dep 实例对象收集,这是一个双向的过程,并且一个观察者可以同时观察多个属性,这些属性的 Dep 实例对象都会被收集到该观察者实例对象的 this.deps 数组中,所以解除属性与观察者之间关系的第二步就是将当前观察者实例对象从所有的 Dep 实例对象中移除,实现方法就如上代码所示。

最后会将当前观察者实例对象的 active 属性设置为 false ,代表该观察者对象已经处于非激活状态了:

this.active = false

以上就是 $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)
  }
  // 省略...
}

当参数 cb 不是函数,而是一个纯对象,则会调用 createWatcher 函数,并将参数透传,注意还多传递给 createWatcher 函数一个参数,即组件实例对象 vm ,那么 createWatcher 函数做了什么呢? createWatcher 函数也定义在 src/core/instance/state.js 文件中,如下是 createWatcher 函数的代码:

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

其实 createWatcher 函数的作用就是将纯对象形式的参数规范化一下,然后再通过 $watch 方法创建观察者。可以看到 createWatcher 函数的最后一句代码就是通过调用 $watch 函数并将其返回。来看 createWatcher 函数的第一段代码:

if (isPlainObject(handler)) {
  options = handler
  handler = handler.handler
}

检测参数 handler 是否是纯对象,有的同学可能会问:“在 $watch 方法中已经检测过参数 cb 是否是纯对象了,这里又检测了一次是否多此一举?”,其实这么做并不是多余的,因为 createWatcher 函数除了在 $watch 方法中使用之外,还会用于 watch 选项,而这时就需要对 handler 进行检测。总之如果 handler 是一个纯对象,那么就将变量 handler 的值赋给 options 变量,然后用 handler.handler 的值重写 handler 变量的值。举个例子,如下代码所示:

vm.$watch('name', {
  handler () {
    console.log('change')
  },
  immediate: true
})

如果你像如上代码那样使用 $watch 方法,那么对于 createWatcher 函数来讲,其 handler 参数为:

handler = {
  handler () {
    console.log('change')
  },
  immediate: true
}

所以如下这段代码:

if (isPlainObject(handler)) {
  options = handler
  handler = handler.handler
}

等价于:

if (isPlainObject(handler)) {
  options = {
    handler () {
      console.log('change')
    },
    immediate: true
  }
  handler = handler () {
    console.log('change')
  }
}

这样就可正常通过 $watch 方法创建观察者了。另外我们注意 createWatcher 函数中如下这段高亮代码:

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 除了可以是一个纯对象还可以是一个字符串,当 handler 是一个字符串时,会读取组件实例对象的 handler 属性的值并用该值重写 handler 的值。然后再通过调用 $watch 方法创建观察者,这段代码实现的目的是什么呢?看如下例子就明白了:

watch: {
  name: 'handleNameChange'
},
methods: {
  handleNameChange () {
    console.log('name change')
  }
}

上面的代码中我们在 watch 选项中观察了 name 属性,但是我们没有指定回调函数,而是指定了一个字符串 handleNameChange ,这等价于指定了 methods 选项中同名函数作为回调函数。这就是如上 createWatcher 函数中那段高亮代码的目的。

上例中我们使用了 watch 选项,接下来我们就顺便来看一下 watch 选项是如何初始化的,找到 initState 函数,如下:

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

如上高亮代码所示,在这个 if 条件语句块中,调用 initWatch 函数,这个函数用来初始化 watch 选项,至于判断条件我们就不多讲了,前面讲解中我们已经讲解过类似的判断条件。至于 initWatch 函数,它就定义在 createWatcher 函数的上方,如下是其全部代码:

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

可以看到 initWatch 函数就是通过对 watch 选项的遍历,然后通过 createWatcher 函数创建观察者对象的,需要注意的是上面代码中有一个判断条件,如下高亮代码所示:

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

通过这个条件我们可以发现 handler 常量可以是一个数组, handler 常量是什么呢?它的值是 watch[key] ,也就是说我们在使用 watch 选项时可以通过传递数组来实现创建多个观察者,如下:

watch: {
  name: [
    function () {
      console.log('name 改变了 1')
    },
    function () {
      console.log('name 改变了 2')
    }
  ]
}

总的来说,在 Watcher 类的基础上,无论是实现 $watch 方法还是实现 watch 选项,都变得非常容易,这得益于一个良好的设计。

深度观测的实现

接下来我们将会讨论深度观测的实现,在这之前我们需要回顾一下数据响应的原理,我们知道响应式数据的关键在于数据的属性是访问器属性,这使得我们能够拦截对该属性的读写操作,从而有机会收集依赖并触发响应。思考如下代码:

watch: {
  a () {
    console.log('a 改变了')
  }
}

这段代码使用 watch 选项观测了数据对象的 a 属性,我们知道 watch 方法内部是通过创建 Watcher 实例对象来实现观测的,在创建 Watcher 实例对象时会读取 a 的值从而触发属性 aget 拦截器函数,最终将依赖收集。但问题是如果属性 a 的值是一个对象,如下:

data () {
  return {
    a: {
      b: 1
    }
  }
},
watch: {
  a () {
    console.log('a 改变了')
  }
}

如上高亮代码所示,数据对象 data 的属性 a 是一个对象,当实例化 Watcher 对象并观察属性 a 时,会读取属性 a 的值,这样的确能够触发属性 aget 拦截器函数,但由于没有读取 a.b 属性的值所以对于 b 来讲是没有收集到任何观察者的。这就是我们常说的浅观察,直接修改属性 a 的值能够触发响应,而修改 a.b 的值是触发不了响应的。

深度观测就是用来解决这个问题的,深度观测的原理很简单,既然属性 a.b 中没有收集到观察者,那么我们就主动读取一下 a.b 的值,这样不就能够触发属性 a.bget 拦截器函数从而收集到观察者了吗,其实 Vue 就是这么做的,只不过你需要将 deep 选项参数设置为 true ,主动告诉 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
}

如上高亮代码所示,我们知道 Watcher 类的 get 方法用来求值,在 get 方法内部通过调用 this.getter 函数对被观察的属性求值,并将求得的值赋值给变量 value ,同时我们可以看到在 finally 语句块内,如果 this.deep 属性的值为真说明是深度观测,此时会将被观测属性的值 value 作为参数传递给 traverse 函数,其中 traverse 函数的作用就是递归的读取被观察属性的所有子属性的值,这样被观察属性的所有子属性都将会收集到观察者,从而达到深度观测的目的。

traverse 函数来自 src/core/observer/traverse.js 文件,如下:

const seenObjects = new Set()

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

上面的代码中定义了 traverse 函数,这个函数将接收被观察属性的值作为参数,拿到这个参数后在 traverse 函数内部会调用 _traverse 函数完成递归遍历。其中 _traverse 函数就定义在 traverse 函数的下方,如下是 _traverse 函数的签名:

function _traverse (val: any, seen: SimpleSet) {
  // 省略...
}

_traverse 函数接收两个参数,第一个参数是被观察属性的值,第二个参数是一个 Set 数据结构的实例,可以看到在 traverse 函数中调用 _traverse 函数时传递的第二个参数 seenObjects 就是一个 Set 数据结构的实例,它定义在文件头部: const seenObjects = new Set()

接下来我们看一下 _traverse 函数是如何遍历访问数据对象的,如下是 _traverse 函数的全部代码:

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

注意上面代码中高亮的部分,现在我们把高亮的代码删除,那么 _traverse 函数将变成如下这个样子:

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 函数的实现,在 _traverse 函数的开头声明了两个变量,分别是 ikeys ,这两个变量在后面会使用到,接着检查参数 val 是不是数组,并将检查结果存储在常量 isA 中。再往下是一段 if 语句块:

if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
  return
}

这段代码是对参数 val 的检查,后面我们统一称 val被观察属性的值 ,我们知道既然是深度观测,所以被观察属性的值要么是一个对象要么是一个数组,并且该值不能是冻结的,同时也不应该是 VNode 实例(这是 Vue 单独做的限制)。只有当被观察属性的值满足这些条件时,才会对其进行深度观测,只要有一项不满足 _traverse 就会 return 结束执行。所以上面这段 if 语句可以理解为是在检测被观察属性的值能否进行深度观测,一旦能够深度观测将会继续执行之后的代码,如下:

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

这段代码将检测被观察属性的值是数组还是对象,无论是数组还是对象都会通过 while 循环对其进行遍历,并递归调用 _traverse 函数,这段代码的关键在于递归调用 _traverse 函数时所传递的第一个参数: val[i]val[keys[i]] 。这两个参数实际上是在读取子属性的值,这将触发子属性 get 拦截器函数,保证子属性能够收集到观察者,仅此而已。

现在 _traverse 函数的代码我们就解析完了,但大家有没有想过目前 _traverse 函数存在什么问题?别忘了前面我们删除了一段代码,如下:

if (val.__ob__) {
  const depId = val.__ob__.dep.id
  if (seen.has(depId)) {
    return
  }
  seen.add(depId)
}

这段代码的作用不容忽视,它解决了循环引用导致死循环的问题,为了更好的说明问题我们举个例子,如下:

const obj1 = {}
const obj2 = {}

obj1.data = obj2
obj2.data = obj1

上面代码中我们定义了两个对象,分别是 obj1obj2 ,并且 obj1.data 属性引用了 obj2 ,而 obj2.data 属性引用了 obj1 ,这是一个典型的循环应用,假如我们使用 obj1obj2 这两个对象中的任意一个对象出现在 Vue 的响应式数据中,如果不做防循环引用的处理,将会导致死循环,如下代码:

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

如果被观察属性的值 val 是一个循环应用的对象,那么上面的代码将导致死循环,为了避免这种情况的发生,我们可以使用一个变量来存储那些已经被遍历过的对象,当再次遍历该对象时程序会发现该对象已经被遍历过了,这时会跳过遍历,从而避免死循环,如下代码所示:

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

如上高亮的代码所示,这是一个 if 语句块,用来判断 val.__ob__ 是否有值,我们知道如果一个响应式数据是对象或数组,那么它会包含一个叫做 __ob__ 的属性,这时我们读取 val.__ob__.dep.id 作为一个唯一的 ID 值,并将它放到 seenObjects 中: seen.add(depId) ,这样即使 val 是一个拥有循环引用的对象,当下一次遇到该对象时,我们能够发现该对象已经遍历过了: seen.has(depId) ,这样函数直接 return 即可。

以上就是深度观测的实现以及避免循环引用造成的死循环的解决方案。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

恋竹姑娘

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

玍銹的英雄夢

文章 0 评论 0

我不会写诗

文章 0 评论 0

十六岁半

文章 0 评论 0

浸婚纱

文章 0 评论 0

qq_kJ6XkX

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文