Vue数据绑定原理之依赖收集触发

1,326 阅读5分钟

在上一篇我们讲到了数据劫持,和数据观测。那么怎么将数据和相关的DOM关联起来呢?本篇我们将解开这个过程。

从实例化Watcher开始

上一篇讲解中我们知道Watcher是实际执行数据变更之后操作的主要对象,我们先找到它的实例化路径,发现它是在mount的时候进行的操作。

Vue -> this._init -> initLifecycle -> mountComponent

我们在这个方法中找到了关于Watcher的实例化代码

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

结合之前的Watcher构造函数:

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

我们先解释下参数:首先传入了组件实例vm,然后是expOrFn传入的是updateComponentcb是一个空函数noopoptions中定义了一个钩子before,最后传入了isRenderWatchertrue,表明这是一个RenderWatcher,就会将该Wathcer挂载到组件上。

而这里关键的地方就是updateComponent。我们在上一篇分析中提到,当数据变更,依赖会通知所有订阅者Watcher做出相应更新,也就是watcher.update,而watcher.update不管是同步还是异步,其核心是调用wathcer.run去执行相关操作。

run () {
  if (this.active) {
    const value = this.get()
    
    // ...
    this.cb.call(this.vm, value, oldValue)
  }
}

这个函数有两个关键的地方,一个是获取值,调用了get方法,而另一个就是执行回调函数cb,在上一篇我们同样提到,我们自定义的watch就是通过传入expcb来实现观测具体某个属性的。

比如:

new Vue({
  data: {
    msg: ''
  },
  watch: {
    msg: function() {}
  }
})

这里的watch就是通过new Watcher(vm, 'msg', fn)类似这样的方式定义的,这和我们现在看见的完全不一样。

这也是困惑的一点,我们现在看到的RenderWatcher传入了一个空函数作为cb,也就是说执行cb是没有任何作用的,那么在数据更新时是怎么通知到视图层的呢?我们发现在run方法中,除了执行cb外,还执行了get方法。这就是关键!

在上一篇中我们提到get方法其实调用的就是getter,在传入的第二个参数expOrFn类型为function时,getter = expOrFn。那还记得传了什么进去吗?updateComponent

我们理一下思路,并暂时移除掉无关代码:

// function mountComponent
new Watcher(vm, updateComponent, noop)

// class Watcher
class Watcher {
  constructor(vm, fn, cb) {
    this.vm = vm
    this.getter = fn

    this.value = this.get()
  }
  get() {
    this.getter.call(this.vm, this.vm) 
  }
  update() {
    this.run()
  }
  run() {
    const value = this.get()
    
    // ...
    this.cb.call(this.vm, value, this.value)
  }
}

这里有几个要点。第一,在初始化Watcher的时候就调用过一次get;第二,在数据更改触发更新时又会调用get。再根据实际执行的函数名updateComponent,我想你也猜到了,这个函数就是用来渲染DOM的,并且在每次观测到数据变更时都会重新渲染DOM。

再来看这张图,至此,WatcherRender的路径我们也清晰了。

data.png

updateComponent

我们猜测该函数是用来更新DOM的,但我们还是得实际看一下它是如何实现的,因为这里面其实涉及到了更多技术,十分值得学习。

那我们还是一步一步的来,看完相关代码,可以总结出:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

它最主要就是调用了两个函数,_render_update

_render

我们先来看看_render,它是通过renderMixin加在原型上的,所以相关定义会在不同的地方。

Vue.prototype._render = function (): VNode {}

我们先看下这个函数声明,其返回值是一个VNode类型,如果你有仔细读过官方文档,你就会对这个词有点印象。

在创建一个Vue组件的时候我们可以不使用template选项来写DOM模板,而使用render选项。而render函数返回值的类型就是VNode。很显然,从函数名上来看,内部的_render是对传入render的二次包装。

看一看源码概括:

Vue.prototype._render = function (): VNode {
  const { render, _parentVnode } = vm.$options
  // ...
  vnode = render.call(vm._renderProxy, vm.$createElement)
  // ...
  return vnode
}

该函数调用了render并返回了VNode。 这里发什么什么?仅仅是调用render函数这么简单吗? 我们来看看比如下面这个render函数:

render: function (createElement) {
  return createElement('h1', this.blogTitle)
}

他用到了this.blogTitle,很明显这里是访问属性,也就是会调用到该属性的get方法,上一篇我们再讲Observer时讲过,属性的get里面会进行依赖收集。此时,blogTitle有了新的订阅者subs.push(Watcher),而该Watcher的依赖deps也增加了blogTitle,在blogTitle更新时,就会调用该Watcherupdate方法。

所以上面那张图中的renderdata这条线也清晰了吧,这也就是官方文档上说的接触(touched)!

_update

OK,其实到这里,整个数据劫持,依赖收集过程都已经很明了了,我们已经可以实现一个简单并且优雅的数据单向绑定了。接下来就是Vue怎么优化DOM渲染,提升性能的操作了。

我们现在知道_render是创建虚拟DOM的,那么创建完虚拟DOM之后干嘛?当然是渲染成真实DOM啊!这也就是_update的作用,那为什么它叫做update而不是create或者transform呢?这也是有知识点在里面的。

// Vue.prototype._update 
const prevVnode = vm._vnode

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

能看见清晰的注释,initial render/updates,也就是该方法处理了新建和更新两种操作。新建的时候会将VNode挂载在vm上表示已经创建过了,之后只需要更新就行了,减少消耗。

而这里又用到了另一个方法__patch__

// runtime/index.js
Vue.prototype.__patch__ = inBrowser ? patch : noop

// runtime/patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })

// vdom/patch.js
export function createPatchFunction (backend) {
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    //...
  }
}

介于这里内容比较复杂,暂时就不讲了,我们留着下一篇再见。