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

在上一篇我们讲到了数据劫持,和数据观测。那么怎么将数据和相关的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传入的是updateComponent,cb是一个空函数noop,options中定义了一个钩子before,最后传入了isRenderWatcher为true,表明这是一个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就是通过传入exp和cb来实现观测具体某个属性的。
比如:
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。
再来看这张图,至此,Watcher到Render的路径我们也清晰了。

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更新时,就会调用该Watcher的update方法。
所以上面那张图中的render到data这条线也清晰了吧,这也就是官方文档上说的接触(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) {
//...
}
}
介于这里内容比较复杂,暂时就不讲了,我们留着下一篇再见。