开篇
关于Vue 1.0中响应式的分析可以看我这篇博文。
这次我想和大家分享下自己对Vue中响应式的理解。究竟什么是响应式,Vue又是如何实现它的?文中会引用非常多的Vue源码,但读者不必担心,我会为源码加上注释,并仔细分析,保证读者可以轻松阅读。
另外,读者们会在源码中见到诸如下面这样的代码。
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
){}
其中形参名: 类型名
是flow的语法,目的是让bable和eslint在做静态分析的时候可以及时发现类型不匹配的语法错误。bable在编译代码的时候会自动删除全部类型定义(这是通过@babel/preset-flow插件完成的)。
源码版本
我手上的Vue源码版本为 v2.6.10 是本文发布日能下载到的最新版。
正文
如果大家手上有Vue源码并且和我的版本一致的话,可以随着我一起在编辑器里面浏览。
首先打开@\src\core\instance\state.js
文件,定位到initData函数。从这里开始分析。initData是Vue构造函数中用来初始化$options.data
用的方法。也就是我们平时在.vue
文件中写的下面这部分内容。
data() {
return {
myName:"YangGuang"
};
},
这里直接贴出initData源码,我添加了大量注释,希望读者可以轻松读通。
function initData(vm: Component) {
//首先从options里面取出data
let data = vm.$options.data
/*
如果data是函数则执行它,getData里面只是简单的执行了一下data指向的函数
如果data是对象则直接返回 ,如果用户没定义data则返回{}
*/
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
//这里是用来检测错误的,可以跳过
if (!isPlainObject(data)) {
data = {}
process.env.NODE_ENV !== 'production' && warn(
'data functions should return an object:\n' +
'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
vm
)
}
/*
下面开始的代码其实是将data上面的属性代理到Vue实例上。
目的只是方便用户访问this.$options.data中的数据
用户只需要通过this.xxx就可以访问到this.$options.data.xxx
*/
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
const key = keys[i]
if (process.env.NODE_ENV !== 'production') {
/*
如果用户定义了methods,
并且methods里面的属性名和data里面的属性名重复了则报错
*/
if (methods && hasOwn(methods, key)) {
warn(
`Method "${key}" has already been defined as a data property.`,
vm
)
}
}
/*
如果用户定义了props,
并且props里面的属性名和data里面的属性名重复了则报错
*/
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
}
/*
前面做了那么多判断,实际上真正要做事的只是下面这两行
*/
else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
// observe data
observe(data, true /* asRootData */)
}
可以看到,实际做事的有两个函数,一个是proxy一个是observe。
proxy
先看proxy。
proxy函数十分简单,sharedPropertyDefinition作为一个默认的描述对象我们可以不关心里面的内容。object指代的是当前vue实例,所以Object.defineProperty(target, key, sharedPropertyDefinition)
意思是将当用户访问this.xxx的时候,直接返回this._data.xxx。当用户给this.xxx赋值的时候,直接将值赋给this._data.xxx。这便是代理。代码如下
/* 将data上面的属性代理到Vue实例上 */
export function proxy(target: Object, sourceKey: string, key: string) {
//sharedPropertyDefinition是一个默认的描述对象
sharedPropertyDefinition.get = function proxyGetter() {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter(val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
接下来看observe
函数(没什么内容就不贴源码了),此函数的返回值是Observer
实例,函数本身只是通过判断各种条件,来决定是创建并返回一个新的Observer实例,还是返回this.$options.data.__ob__
,此时猜测__ob__
有可能是Observer
实例的缓存。无论这个实例以什么方式获取到,ob.vmCount都要加1。目前尚且不清楚Observer
类的作用,于是继续分析Observer
类。
Observer
打开@\src\core\observer\index.js
找到Observer类的定义,首先分析构造函数。源码如下
//这里传进来的value指的是vm.$options.data
constructor (value: any) {
this.value = value
/* 保存数组类型数据的依赖 */
this.dep = new Dep()
this.vmCount = 0
/*
def的作用只是通过defineProperty将this挂到vm.__ob__上
使得自己可以通过vm.__ob__访问到当前Observer实例
*/
def(value, '__ob__', this)
/*
如果vm.$options.data是一个数组
则继续做判断
*/
if (Array.isArray(value)) {
/*
如果当前浏览器环境下数组是有原型的
则使用protoAugment对数组进行处理。
protoAugment函数内部将数组的原型替换为了arrayMethods,
这步操作很奇怪,但是别急,这将是本文介绍的重点
*/
if (hasProto) {
protoAugment(value, arrayMethods)
}
/*
如果当前浏览器环境下数组是没有原型的,
则对数组定义7个方法,分别是push,pop,shift,unshift,sort,reverse,splice。
*/
else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
}
/*
如果vm.$options.data是一个对象
则使用walk对这个变量进行进一步处理。
walk也将是本文重点分析的目标
*/
else {
this.walk(value)
}
}
屡一下思路,首先Vue构造函数对用户传进来的vm.$options.data
进行遍历,对其中的每一个属性都执行observe方法,而observe方法的目标就是给没有挂载Observer实例的属性挂载上Observer实例。挂载实例前要先创建实例,然后我们就开始分析Observer实例的构造函数,然后我们发现,Observer的构造函数对当前需要挂载Observer实例的属性做了判断,对数组和对象区分处理,而数组更是分有原型和无原型,对两种情况的处理不同。其实,到目前为止Vue在做的都是为在vm.$options.data.xxx
之上挂载Observer实例要做的准备工作。接下来,我将重点分析protoAugment
,copyAugment
以及walk
。
walk
先来看walk
看一下walk定义,无比的简单,就是将vm.$options.data
里面的属性都拿出来,分别对他们执行了
defineReactive(vm.$options.data,属性名)
函数。源码如下
/* 参数的obj指的是vm.$options.data */
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
这么说,玄机就在这个defineReactive里面,我猜测defineReactive就是真正做响应式处理的地方,继续看defineReactive方法的代码。
defineReactive
直接贴出源码。
/*
obj指的是vm.$options.data
key指的是vm.$options.data中的某个属性名
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
/* 每一个Dep实例对应一个响应式的变量 */
const dep = new Dep()
/*
尝试着取出当前属性(obj.$options.data.key)的描述对象,
*/
const property = Object.getOwnPropertyDescriptor(obj, key)
/* 如果已经有描述对象并且configurable属性为false则不作任何操作 */
if (property && property.configurable === false) {
return
}
/*
这里尝试取出用户定义属性时候传入的描述对象的get和set
实际开发过程中我从没有过在指定data中某个属性的时候顺便给它设置getter和setter的情况。
相信绝大部分开发者也都是这样,所以实际上大多数时候这两句代码中的getter和setter都是undefined
*/
const getter = property && property.get
const setter = property && property.set
/*
如果没有getter或者有setter 并且 只传进来2个参数
则将obj.$options.data.key的值存到参数val中
*/
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
/*
注意这里又调用了observe,说明动态绑定是一个递归操作,
Vue会对obj.$options.data.key的值继续进行判断,如果是对象或数组 仍然会做响应式处理。
注意这里childOb指代的是子元素对象
*/
let childOb = !shallow && observe(val)
/*
下面对当前属性做响应式改造,本质上就是对属性的访问和赋值操作做拦截。
当用户将来通过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
/*
关于Dep这个类的详细内容,之后再研究。
*/
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
/*
如果obj.$options.data.key的值是一个数组,
则需要对数组进行递归遍历,让数组的每一项都执行depend(),
*/
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
/* 先获取一下当前的值 */
const value = getter ? getter.call(obj) : val
/*
如果新传进来的值和之前的值相等
或者
新值旧值任意一个为NaN则直接return
*/
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
/*
注意这里又对新值做了响应式处理。
考虑一种情况,本来某个属性的值是字符串 如 this.name='this is my name'
但是用户将这个属性重新赋值为对象 this.name={familyName:'y',firstName:'g'}
对于这种情况则需要重新进行响应式处理
*/
childOb = !shallow && observe(newVal)
/*
notify里面会重新设置与当前属性对应的虚拟DOM的值
*/
dep.notify()
}
})
}
通过分析defineReactive
可以得知这么几件事,
- Vue是在描述对象的get中对属性进行依赖收集的(Dep负责依赖收集)
- 在set中,Vue会对新值重新做响应式处理,这主要是为了应对下面这种情况的
data() {
return {
name:"yangguang"
}
},
mounted() {
this.name={familyName:'yang',givenName:"guang"}
},
protoAugment
protoAugment是Vue对携带原型的数组的处理,而copyAugment则是对不带原型的数组的处理,那原型究竟在这两个函数中起到了什么作用呢?
首先分析protoAugment,直接贴源码。
function protoAugment (target, src: Object) {
target.__proto__ = src
}
短短一行,这个函数只是用src这个原型替换掉了用户给Vue的数组的原型。
这么说关键点在src中,找到src对应的实参arrayMethods
(重要)。发现它是使用数组的原型来创建的
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
接下来看看Vue对arrayMethods
做了什么操作。
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
/* 每轮循环取出一个方法名 */
const original = arrayProto[method]
/* 将arrayMethods的对应属性设置为mutator方法 */
def(arrayMethods, method, function mutator (...args) {
/*
首先执行用户期待的那个方法,
比如用户是通过 arr.push('3')来调用到mutator方法的
那么这行代码实际上执行的就是数组原始的push方法。
*/
const result = original.apply(this, args)
/* 将属性上的observer实例拿出来,方便之后通知虚拟DOM做修改 */
const ob = this.__ob__
let inserted
/*
当要执行为数组添加新元素的方法的时候,
比如执行push,unshift或splice的时候,
需要对新加入的元素做响应式处理
*/
switch (method) {
/* 对push和unshift来说,新元素就是调用方法时候传进来的第一个参数 */
case 'push':
case 'unshift':
inserted = args
break
/* 对splice来说,新元素是从第三个参数开始的N个参数,因此使用slice截取一下,得到它们 */
case 'splice':
inserted = args.slice(2)
break
}
/* 如果给数组添加了新元素,则对新元素做响应式 */
if (inserted) ob.observeArray(inserted)
/* 通知数组内的元素,数组发生了变化,需要更新DOM各处的值 */
ob.dep.notify()
/* 最后把数组原始的原型方法的执行结果返回给用户 */
return result
})
})
这个函数实际上在做的事情就是将js的Array.prototype
中的7个方法替换为自己的mutator
方法,通过这种方式来拦截用户对数组的操作。这样一来,用户再使用push,pop,shift,unshift,sort,reverse,splice这7个方法对数组做修改的时候,Vue就可以监测到这个动作,然后做一些和响应式有关的动作。
对于Object类型的变量,Vue可以通过设置描述对象的set和get来拦截用户的下面这两种操作 (见mounted)
data() {
return {
myObj:{name:"yg",age:"23"}
}
},
mounted() {
this.myObj.name="yang-guang"; //对应this.myObj.name的get
console.log(this.myobj.name) //对应this.myObj.name的set
},
而js中数组元素是没有描述对象的,这是Object的才有的东西。因此Vue只得通过重写那7个方法来拦截用户操作。
当然,无论是数组还是对象,我们都可以通过Vue.set或者vm.$set来对其属性或对象进行操作,因为我们使用的是Vue自己的方法,Vue当然可以拦截我们的操作了。所以,Vue做去重新定义7个方法也好,去使用Object.defineProperty给对象的属性设置描述对象也好,都是为了方便我们这些用户,为了顺应我们已有的书写习惯做出的努力。
所以,Vue的响应式的前提是拦截用户操作。下面我自己设计了几个简单的小问题,大家可以稍稍花点时间解答一下。
请思考这四个操作能否触发响应式?(无先后顺序的区别)
data() {
return {
arr:['a','b',{name:'yg',myFriends:['LiLei','HanMeiMei']}]
}
},
mounted() {
//无先后顺序的区别
1. this.arr=['c','d']
2. this.arr[1]='ohh'
3. this.arr[2].name='yang-guang'
4. this.arr[2].myFriends[1]='otherOne'
5. this.arr[2].age='24'
},
答案:
-
yes
解析 arr作为对象里面的其中一个属性,对它进行重新赋值可以触发它的描述对象的set方法,因此本次赋值操作可以被Vue拦截,那么本次操作当然是响应式的了。 -
no
解析:
这是对数组中某个元素的操作,通过之前对源码的分析我们知道,Vue无法拦截通过下标(索引)直接对数组元素进行的操作。
为什么无法拦截?帮大家回忆一下。首先arr是一个数组,Vue没法在其上通过defineProperty对每个元素设置描述对象。用户只能通过Vue重写过的那7个方法或者vm.$set
对数组中的元素进行操作,因此将 this.arr[1]='ohh’改成 this.arr.splice(1,1,‘ohh’)或this.$set(this.arr,1,‘ohh’)即可。 -
yes
解析
数组第三个元素是一个对象,而name是这个对象的属性。
重复一下,注意{name:'yg',myFriends:['LiLei','HanMeiMei']}
这个对象本身才是数组元素,而this.arr[2].name不是数组元素,他是对象内的一个属性。
既然他是一个对象属性,那对它直接做赋值操作是可以触发get的,因此this.arr[2].name=‘yang-guang’ 这个操作当然可以触发响应式。 -
no
解析
无论嵌套多深,我们只需要注意当前正在操作的目标究竟是数组元素还是对象的属性。很明显this.arr[2].myFriends[1]='otherOne’这句代码操作的目标是一个数组元素,而且又是通过下标来直接赋值的,那么它当然不可以触发响应式了。 -
no
解析
这里要注意,在我们执行this.arr[2].age='24’的时候,{name:'yg',myFriends:['LiLei','HanMeiMei']}
这个对象里面还没有名为age的属性呢,所以更不要谈什么age的描述对象了。没有描述对象当然也没get和set什么事了。
因此Vue无法对这个操作做出拦截(因为拦截靠的是描述对象)。
此处改为this.$set(this.arr[2],"age","24")
就可以了。因为$set
可以拦截对Object添加新属性的操作
copyAugment
刚刚的protoAugment如果搞清楚了,那这个函数就一目了然了。其实和protoAugment是一样的。如果Array有prototype就把方法定义到Array.prototype
上,如果没有则把方法定义到Array
上
/*
target是vm.$options.data
src是Vue改造过的数组原型
keys是Vue改造过的7个函数的名字[push,pop,shift,unshift,sort,reverse,splice]
*/
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
/*
将7个改造过的方法通过Object.defaultProperty直接附加到数组上。
在ES6中相当于给class Array添加了7个静态方法。
*/
def(target, key, src[key])
}
}
总结一下:
- 依赖收集的过程是从watcher被创建的时候开始的,这是依赖收集的起点。watcher的构造函数里面会执行一个get方法,在这个get方法里面会调用this.getter(),其实这个getter就是watcer的监听函数。函数被执行之前,当前watcher会将Dep.target指向自身。之后,监听函数一旦被执行,函数执行过程中调用的变量如果是响应式的变量,就会触发变量的属性描述对象中的get方法,而在get方法里面就会执行真正的依赖收集过程。依赖收集完毕后,Dep.target会指向target栈中的上一个watcher,由此也得出另一个结论,依赖watcher的创建过程也是递归的。其实很好理解,拿renderWatcher举例,每个renderWatcher对应一个组件实例,那某个组件实例内部可能使用了用户自定义的其他组件,于是又会创建子组件实例对应的renderWatcher。等子组件完成依赖收集,target出栈,当前的Dep.target重新指向父组件的renderWatcher。
- 刚刚提到了真正的依赖收集过程,其实这个过程很简单,watcher监听的函数(对于renderWatcher来说就是__render方法)被执行的过程中不是引用了一些变量吗,每个变量对应一个dep实例,那这些变量就取出自己的dep,dep先调用Dep.target指向的watcher实例的addDep方法并传递自身,watcher.addDep将dep引用存到自己的newDeps数组里,然后调用dep的addSub方法并传递自身,dep实例就将watcher添加到自己的subs数组里管理起来。至此依赖收集就完成了。dep实例和watcher实例相互保存了对方的引用,从这里我发现两者其实是多对多的关系。结合实际情况联想一下,
dep对多个watcher正是父组件将自身的变量通过propers传递给子组件,结果父子两个renderWatcher对应同一个dep。(除此之外还有用户利用多个$watch监听同一变量的情况)
一个watcher对多个dep就是用户在一个.vue里面定义了多个变量的情况,这是最常见的。
全文完,感谢您的阅读。