本文会继续和大家一起探究数据响应式的原理,虽然文中会出现大量Vue源码,但我已经添加了注释,努力做到让读者可以轻松阅读。感兴趣的读者请继续往下看吧!
Vue2.0与Vue1.0相比有个很大的改进,就是引入了虚拟DOM。那么,虚拟DOM究竟是为了解决什么问题的呢?
在Vue中,每个变量都可能出现在模板中的任意位置,例如下面这段代码:
<body>
<div id="app">
<span>{{myAge}}span>
<div>{{myAge}}div>
div>
<script>
const myVue = new Vue({
el: '#app',
data: {
myAge: 12,
}
})
script>
body>
myAge出现在了两个地方,一个地方是span标签内,另一个地方是div标签内。将来当myAge发生变化的时候,两个DOM节点的textContent都需要更新。
对于这个过程,Vue1.0的做法是创建两个watcher实例,每个实例保存一对myAge与DOM元素的映射关系。上面这段代码就产生了两个watcher实例,分别保存两对映射。对于小项目来说这么做不会出现什么问题,但项目大了之后会出现两个问题。一是过多的watcher实例会占用大量内存,二是直接对DOM的操作会让网页频繁地重新渲染,这就有可能会出现严重卡顿,影响用户体验。
为了解决上述两个问题,Vue借鉴了React的思路,在2.0中引入了虚拟DOM。这么做有两个影响
其实上面两点是一回事,但这里不展开讲了,开篇部分只是为了做个铺垫,说得再多也不如一会儿直接看源码来的清晰,不过这里我放个图片给大家,让大家感受下虚拟DOM这个角色处于哪个位置。
JS通过响应式(reactive)操作虚拟DOM,虚拟DOM通过diff算法(patch)确定要修改的DOM元素,并修改DOM。最后DOM将操作的结果通过事件方式(events)反馈到JS代码。
本文用到Vue源码版本为 v2.6.10,此版本是本文发布日能下载到的最新版。
我打算从虚拟DOM入手阅读源码。而Vue项目的main.js
中的$mount
正是生成虚拟DOM的方法,于是,打开Vue源码项目中的@\src\platforms\web\runtime\index.js
找到$mount
,源码如下:
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
可以看到,$mount只是将el对应的真实DOM查找出来了,真正做事的是mountComponent
方法。
打开@\src\core\instance\lifecycle.js
找到mountComponent
方法,源码如下:
export function mountComponent (
vm: Component, //当前组件的引用
el: ?Element, //当前组件的根节点,也叫挂载点,是一个真实的DOM节点
hydrating?: boolean
): Component {
/*
把刚刚传进来的真实DOM节点(根节点)的引用赋给vm.$el。
换句话说,组件的$el属性保存的是当前组件的根节点。
将来我们可以通过$el来直接操作组件的真实DOM,虽然大多数时候不需要这么做。
*/
vm.$el = el
/* 如果创建Vue实例时候没有传递render函数则进行一些错误判断,跳过 */
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
)
}
}
}
/*
当代码执行到这一行的时候,Vue调用了生命周期钩子beforeMount,表示准备挂载
此时用户在组件内定义的beforeMount函数被触发。
*/
callHook(vm, '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)
}
}
/*
这里才是重点,
vm._render的返回值为虚拟DOM,
vm._update参照虚拟DOM,对真实DOM进行了更新,
推测updateComponent函数的作用就是根据虚拟DOM更新真实DOM
*/
else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
/*
又一个关键点,刚刚提到的Watcher类终于出现。
这里要注意刚刚的updateComponent被当做第二个参数传递进去,
也就是说当 vm._update(vm._render(), hydrating)表达式的结果发生变化的时候
就会触发回调函数noop
*/
new Watcher(vm, updateComponent, noop, {
before () {
/* 如果组件处于已经挂载完毕到销毁之前的这段生命周期内 */
if (vm._isMounted && !vm._isDestroyed) {
/* 则在每次修改虚拟DOM之前都执行beforeUpdate函数 */
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher 这是一个渲染watcher*/)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
/*
如果没有虚拟DOM节点则表示我们是手动挂载,
也就是手动调用render函数
此时vue仍然会正常调用callHook钩子
*/
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
从这段mountComponent的源码可以看出来,Vue在挂载组件实例的同时创建了一个watcher实例,而且给watcher的构造函数的最后一个参数传递了true,这表示Vue创建的watcher是一个渲染watcher。那究竟什么是渲染watcher?这要等分析完watcher的源码才能知晓。
总结:
打开@\src\core\observer\watcher.js
,找到构造函数。源码如下:
/* Watcher的构造函数 */
constructor(
/* 当前组件实例 */
vm: Component,
/* 这个watcher可以是表达式或者函数 */
expOrFn: string | Function,
/* 回调函数 */
cb: Function,
/* 配置项 */
options?: ?Object,
/*
是不是渲染watcher(重要),
true表示这是mountComponent创建的渲染watcher,而不是用户创建的普通watcher
*/
isRenderWatcher?: boolean
) {
/*
这句代码将当前Watcher实例与它所在的组件关联起来了。
今后我们可以在Watcher实例内访问到其所在组件的引用了。
*/
this.vm = vm
/*
同时,如果当前watcher是组件的渲染Watcher的话,
我们还需要将当前watcher的引用存到组件中,
将来可以在组件内访问到渲染watcher
*/
if (isRenderWatcher) {
vm._watcher = this
}
/*
之后呢,将当前watcher实例加到了组件的watcher数组中,
也就是说,一个组件可以和多个watcher对应,
但只能和一个渲染watcher对应。
*/
vm._watchers.push(this)
// 如果定义watcher时候传递了options
if (options) {
/* 当我们需要监听对象内部值的时候可以将deep设为true */
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.before = options.before
}
//否则给些默认值
else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
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.value = this.lazy
? undefined
: this.get()
}
总结:
export function noop (a?: any, b?: any, c?: any) {}
这么做的目的只是为了在不确定真正的回调函数之前跳过类型检查。那么
注意这个构造函数的末尾调用了this.get方法,这是依赖收集的关键,get方法中的关键代码只有三句,如下:
1. pushTarget(this)
2. value = this.getter.call(vm, vm)
3. popTarget()
不知道读者还记不记得我上篇文章提到的defineReactive
函数,在这个函数内部有这么一段代码:
/*
下面对当前属性做响应式改造,本质上就是对属性的访问和赋值操作做拦截。
当用户将来通过this.xxx访问某个响应式的对象的时候,就会触发它的get。
当用户给this.xxx赋值的时候就会触发它的set。
*/
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
/*
如果这个属性自带属性描述对象,并且对象里面指定了get的话,
就将用户定义的那个get的返回值作为此处get的返回值
否则直接将obj.$options.data.key的值作为返回值
*/
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
/*
如果obj.$options.data.key的值是一个数组,
则需要对数组进行递归遍历,让数组的每一项都执行depend(),
*/
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
}
})
这是函数的一个片段,可以看到Vue对vm.$options.data.xxx
设置了描述对象,重点是get属性中使用到了Dep.target
,是不是和刚刚我们分析的watcher的getter方法瞬间联系到一起了?
Dep.target在watcher的初始化过程中被赋值为当前watcher,而又在vm.$options.data.xxx
的描述对象的get中被使用。
因此,this.getter表面上是在调用路径字符串或计算函数,实际上是为了访问一下路径字符串所对应变量或是访问计算函数中引用到的全部变量,再说得直白一些,其实是为了触发这些变量的描述对象的get属性,也就是上面这段代码。这段代码首先判断Dep.target是否为null,如果是从刚刚watcher类的getter函数跳转进来的话,这里是肯定不为null 的,因为我们在执行this.getter之前已经通过pushTarget(this)
将Dep.target设置为当前watcher了,之后必定会执行childOb.dep.depend(),depend()源码如下:
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
其实就是在Dep.target保存的watcher实例上调用实例方法addDep(this) 将dep的引用存到watcher实例中。也就是说,每个watcher将它们需要监听的全部变量对应的dep实例的引用都存到自己这边了,那读到这里我有个疑问,是不是说如果我代码中创建了多个一模一样的watcher,这些watcher都监听同一个变量,当这个被监听的变量发生变化的时候,这些watcher都会被依次触发呢? 从源码来推测的话,它们会被依次触发。不如做个小测试。我的代码如下:
data() {
return {
b:{haha:'3'}
};
},
mounted() {
this.$watch('b.haha',()=>{console.log(this.b.haha)});
this.$watch('b.haha',()=>{console.log(this.b.haha)});
this.$watch('b.haha',()=>{console.log(this.b.haha)});
this.b.haha=1;
},
项目运行起来之后,在控制台可以看到三句输出
看来我们推测的没错!
继续分析。
addDep源码如下:
addDep(dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
//每个watcer实例都有个单独的数组来存放所有需要监听的dep实例的引用
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
/*
这里是重点!watcher又反过来调用Dep实例的addSub方法,
将自己加进Dep实例中存起来。
*/
dep.addSub(this)
}
}
}
稍微想一下就可以发现两件事。
Watcher在初始化的最后阶段会执行观察 Vue 实例变化的一个表达式或计算属性函数
(如果是Vue自己创建的renderWatcher的话就是执行vm._update(vm._render(), hydrating)
这句表达式。否则就是用户传递给vm.$watch
的第一个参数。)。并且还会将自身的引用赋给Dep.target。
变量或表达式中的变量被访问的时候会触发其上的描述对象的get属性,get属性中会调用Dep.target.addDep,其实际作用就是将dep的引用放到Dep.target存放的watcher实例中保存起来。
而watcher.addDep函数又调用了dep的addSub函数,而addSub函数实际上就是将Dep.target中存放的引用保存到自己这里。
整个过程就是依赖收集。