vue 数据侦测是vue框架一块最为核心的模块之一,也是vue区别传统前端架构最重要的区别之一,那么它到底是怎么实现的。今天我们一起来看看吧

vue的出现解决了什么问题

大家都知道,angular,vue,react出来以前,我们前端的页面在数据发生变化时,都需要手动去操作dom来更新页面,或者使用模板引擎来进行页面的重新渲染。但是,这在项目越来越大越来越复杂的今天,传统的方式必然会产生很多问题。

  1. 手动操作dom进行更新的话,项目一大,代码会非常杂乱,且不易维护
  2. 使用模板引擎进行数据更新的话,当模板中某个数据发生变化时,依然是要重新渲染整个模板,性能低下。

究其原因,传统的前端页面,它没有一个连接状态数据和dom关系的机制,所以当状态数据发送变化时,就需要我们自己手动去进行dom的更新。这必然是一个痛点,而存在痛点的东西,自然是慢慢会出现更好的东西来解决这些痛点。那么,我们的angular, vue,react就出来了(今天我们就只谈vue,以后再谈react,至于angular嘛,我就不谈了,不熟,以后也不准备去学了,哈哈,学不动了)

vue通过template模板来建立状态数据和dom之间的关系,将数据绑定在模板上,而模板又用于渲染生成dom。故,当状态数据发生变化时,可以通过更新模板来实现dom的自动更新。那这中间必然会存在3个过程。
1、监测数据(对状态数据进行监测,以便当数据发生变化时,我们能知道数据变化了)
2、通知依赖(我们知道数据变化了以后,是不是要通知依赖了我们这个数据的东西去进行更新啊,其实这里应该还有一步,就是收集依赖,因为只有收集了依赖,我们才能知道我们需要通知哪些东西)
3、更新数据(依赖者收到通知后是不是要进行对象的dom更新啊,不过具体的dom更新,我们今天就不说了,因为虚拟dom那块,我们已经说过了)

我们今天主要针对以上的1,2两点来进行讲解。文章可能会比较长,请准备好瓜子,慢慢看。

数据监测(也叫数据变化侦测)

上面我们说了,整个过程最关键的就是当数据发生变化时,我们需要知道数据发生变化了,那么我们必然就要对 相关数据 进行一个侦测。那么 何为 相关数据,这个相信大家应该都知道吧

  • 绑定在模板中的状态数据
  • 通过$watch()函数进行监测的数据

这里有一点需要先跟大家说明下,Object 和 Array的监测方式不同,我们先说Object的侦测方式
变化侦测有两者类型:

  1. push(推) 主动型
  2. pull(拉) 被动型

angular 和 react属于第二种类型 pull,因为angular和react在数据发生变化的时候是属于被动的,他不知道具体什么地方发生了变动,所以他也不知道我该去更新谁,只知道有数据变动了。故他只能暴力的去对比所有的dom树来找到真正变动的地方,然后再去更新。而react是通过虚拟dom的diff去完成这一操作,angular则是通过脏检查机制去完成这一操作。

而vue是属于第一种类型 push,因为vue从数据发生变动的一开始他就已经知道了什么地方变动了(精确到组件),虽然无法知道具体是哪个数据变了,但他知道的信息更多,他能知道哪个组件内的数据变了。故他可以进行更细粒度的更新。那他又是如何知道具体是哪个数据更新了呢,相信看过我 虚拟dom和diff算法原理 这篇文章的人就知道了。组件内,vue会用虚拟dom的diff
通过对比计算出最终需要更新的地方。

说了这么多 “废话”,我们还是进入正题吧。究竟如何进行数据变化侦测呢

Object的数据侦测

如何进行侦测

侦测手段:

javascript有两者变化侦测的手段

  1. Object.defineProperty()
  2. ES6 的 Proxy

vue2中使用的是Object.defineProperty(),vue3中使用的是Proxy。大家都知道,Proxy比Object.defineProperty更为强大,有的人可能会问了,那既然这样,为什么犹大一开始不用Proxy呢。那是因为犹大写vue2的时候,es6还并不普遍,很多浏览器对ES6的支持都还不太好。所以现在浏览器大多支持ES6了,那么vue3的Proxy不就来了吗?

不过今天我们主要讲vue2的数据侦测,至于Proxy,我以后写vue3的时候咱再来看。

vue源码中有一个核心方法叫 defineReactive() ,主要用于将非响应式数据转换为响应式数据,什么意思呢,就是说将没有被侦测的数据,转化为变侦测的数据。这个过程,我比较喜欢叫它数据劫持。

function defineReactive(data, key, val) {
	Object.defineProperty(data, key, function() {
		enumerable: true, // 该属性是否可被枚举
		configurable: true, // 该属性的配置后续是否可进行更改
		get: function () { // 用于获取属性值
			return val
		},
		set: function (newVal) { // 设置属性值
			if (newVal === val) return
			// 用新值替换旧值
			val = newVal
		}
	})
}
// 假如vue下有个这样的数据  data: {a: 123}
// 我们需要劫持data下的a属性
// 那么只需要 defineReactive(data, 'a', 123) 就完成了对data.a的数据劫持(或者叫数据侦测)
// 那么当用data.a 去获取a的值时,便会触发以上的get函数
// 通过data.a = 234 去更改a的值时, 就会触发以上的set函数

好了,现在数据劫持已经做好了,当数据发生变化的时候,我们已经可以立马知道数据的变化了。那数据变化了,我们下一步是不是要去通知给所有依赖了这个数据的依赖者,告诉他们,我这个数据已经变了,并把最新的值传给他们啊。好,明白这一点后,我们继续往下看

什么是依赖(谁才是依赖者)

首先,我们必须要明白,数据发生变化后,我们到底是要通知谁。谁才是依赖者。
顾名思义,依赖了某个数据的意思,那肯定是使用了这个数据对吧,并且是要数据变化我也跟着变化的东西对吧。
那很显然,
第一:我们的template模板中的dom上是不是有可能会依赖data中的某个数据啊
第二:script中监听了这个数据的watch函数,是不是也依赖了这个数据啊

watch: {
	aaa (newVal, oldVal) {
		console.log(newVal)
	}
} // 这种watch看过吧,也算是个依赖aaa的依赖者吧


vm.$watch(data.aaa, function (newVal, oldVal) {
	console.log(newVal) // 这种方式应该也看过吧
})

以上都可能是依赖者。
那既然这样,我们是不是可以抽象出一个类,来统一管理这些依赖啊。vue中管这个类叫Watcher。

那怎么管理呢
每一个依赖,我们是不是可以new Watcher() 一个watcher实例出来啊。这个实例专门负责管理这个依赖。他会存在一个update方法,实例调用这个update就可以触发具体的更新函数。此时,数据发生变化的时候,我们是不是就可以直接通过watcher了,然后watcher调用自己的update方法,是不是就可以实现更新啦。

export default class Watcher {
	/*
	 * vm 是vue实例
	 * expr 是属性的key。如 data.a.b.c
	 * cb 是回调函数
	 */
	 
	constructor (vm, expr, cb) {
		this.vm = vm
		this.value = this.getValue () // new 实例时,先存储一下oldVal
		// 这里我解释下,假设expr的值是data.a.b.c,是不是说明, 这个数据就是vue  data
		对象下的a对象下的b对象下的c属性啊。那我们要获取这个值的话。是不是可以直接一层一层去读取他的值啊
		this.getter = getPathData(expr)
		
		getValue () {
			return this.getter(this.vm)
		}
		
		update () {
			// 这个时候我们是不是需要去获取一下oldVal啊
			const oldValue = this.value // 定义一个变量存储旧的值
			this.value= this.getValue() // 重新获取新的值
			this.cb(newVal, oldVal) // 触发回调函数,然后在回调函数中就可以做具体的更新了
		}
	}
}

// 这个函数主要用于解析路径,并返回一个获取属性值的函数,利用了闭包
export function getPathData(expr) {
	const reg = /[^\w.$/
	if (!reg.test(expr)) return
	
	const arr = expr.split('.')
	return function (vm) {
		if (!vm) return
		let obj = vm // vm是vue实例
		for (let i = 0; i < arr.length; i++) {
			// 分析一下,假如expr是data.a.b.c, 那么arr就是['data', 'a', 'b', 'c']
			// 那么,第一次循环,obj = vm.data  相当于vm就拿到了vue下的data对象
			// 第二次循环 obj = obj['a'] 相当于就是vm.data.a的值
			// 第三次循环 obj = obj['b'] 相当于vm.data.a.b的值
			// 第四次循环 obj = obj['c'] 是不是就拿到了vm.data.a.b.c的值啊
			obj = obj[arr[i]]
		}
		return obj // 返回获取到的值
	}
}

好了,现在更新也有了,那我们怎么更新呢,是不是数据发生变化的时候更新啊

function defineReactive(data, key, val) {
	Object.defineProperty(data, key, function() {
		enumerable: true, // 该属性是否可被枚举
		configurable: true, // 该属性的配置后续是否可进行更改
		get: function () { // 用于获取属性值
			return val
		},
		set: function (newVal) { // 设置属性值
			if (newVal === val) return
			// 用新值替换旧值
			val = newVal
			// 是不是在这里调用啊
			watch.update()
		}
	})
}

如上图,是不是应该在set中去调用啊。但是这样一次性是不是只能调用一个watch的update方法。可现实情况是,我们一个数据可以对应了对个依赖。比如template模板中有v-html依赖了这个数据,v-text也依赖了,v-model也依赖了。watch函数也依赖了。那一个数据就会存在多个依赖。这个时候,我们是不是要提前收集依赖啊。下面我们来看看怎么收集依赖。上面刚加的watch.update()请忽略。

如何收集管理依赖

每个数据都可能存在多个依赖者,那我们是不是可以给每个数据都加一个依赖的管理者啊,我们叫它Dep。

export default class Dep () {
	constructor() {
		this.sub = [] // 依赖者数组,用来存放依赖者watcher实例
	}

	addSub (sub) { // 添加依赖
		this.sub.push(sub)
	}

	remove (sub) { // 删除依赖
		remove(this.sub, sub)
	}

	// 通知方法,用于通知各个依赖者进行数据更新
	notify () {
		const arr = this.subs.slice() // 先拷贝一份
		for (let i = 0; i < arr.length; i++) {
			arr[i].update() // 让每一个watcher去调用自己的更新函数
		}
	}
}

export function remove (subs, sub) {
	if (subs.length && subs.length > 0) {
		const index = subs.indexOf(sub)
		if (index > -1) {
			return subs.splice(index, 1)
		}
	}
}

此时,Dep类,我们已经完成。
这时,我们要明白。Dep和数据是一对一的关系,那么每劫持一个数据,我们是可以就可以添加一个dep啊。这样一来,我们再数据劫持的时候新建一个类 Dep。用来管理数据的依赖者

function defineReactive(data, key, val) {
	let dep = new Dep()
	Object.defineProperty(data, key, function() {
		enumerable: true, // 该属性是否可被枚举
		configurable: true, // 该属性的配置后续是否可进行更改
		get: function () { // 用于获取属性值
			// 收集依赖
			if (window.target) {
				dep.addSub(window.target)
			}
			return val
		},
		set: function (newVal) { // 设置属性值
			if (newVal === val) return
			// 用新值替换旧值
			val = newVal
			// 数据发生变化时,通知依赖者
			dep.notify()
		}
	})
}

如上图,我们在set中用dep实例去调用notify()去发送通知就可以了。
这个时候,请看,我们在get中加了一条dep.addSub(window.target) 去进行依赖收集。为什么要在get中去收集依赖呢。那是因为,我们前面是不是每一个watcher,在实例化的时候都会先获取一下这个数据的值啊,那么是不是一定会触发我们的get啊。我们回过头来看一下
在这里插入图片描述
上图中,watcher内,第四步的时候,我们是不是通过vm.data.a.b.c的方式去获取了c的值啊。那么是不是会触发c的get啊。那既然一定会触发get,我们是不是就可以在get中去添加这个watcher实例啊。

那有人可能又疑惑了,那window.target是啥。
从dep.addSub(window.target)这句代码我们就能看出,window.target应该就是一个watcher实例。那他是什么时候挂在window.target下的呢。请继续往下看。

export default class Watcher {
	/*
	 * vm 是vue实例
	 * expr 是属性的key。如 data.a.b.c
	 * cb 是回调函数
	 */
	 
	constructor (vm, expr, cb) {
		this.vm = vm
		this.value = this.getValue () // new 实例时,先存储一下oldVal
		// 这里我解释下,假设expr的值是data.a.b.c,是不是说明, 这个数据就是vue  data
		对象下的a对象下的b对象下的c属性啊。那我们要获取这个值的话。是不是可以直接一层一层去读取他的值啊
		this.getter = getPathData(expr)
		
		getValue () {
			window.target = this
			let value = this.getter(this.vm)
			window.target = undefined
			return value
		}
		
		update () {
			// 这个时候我们是不是需要去获取一下oldVal啊
			const oldValue = this.value // 定义一个变量存储旧的值
			this.value= this.getValue() // 重新获取新的值
			this.cb(newVal, oldVal) // 触发回调函数,然后在回调函数中就可以做具体的更新了
		}
	}
}

大家请看上面的getValue,我们在原来的基础上做了一点改动。
首先,
第一步、让window.target = this,此时的this是谁啊,构造函数内的this是不是执行new出来的实例啊。所以这个this就是new Watcher() 出来的watcher实例。
第二步、获取当前数据的值。这个时候,是不是会触发数据的get函数啊,那么此时get函数内的dep.addSub(window.target)是不是就触发了。那么这个watcher依赖者就成功加入到依赖者数组中了。
第三步、我们再清除window.tareget,为什么要清除啊。大家注意了。数据的get()方法是只要有地方通过a.b.c的形式获取了数据值,就会触发get的。如果我们不清除window.target,那是不是每次人家去获取数据值的时候,我们都会去执行一遍dep.addSub(window.target)啊。那是不是就会一直重复添加啊。
在这里插入图片描述

至于什么时候去new Watcher ,那肯定是在发现依赖者的时候啦。
比如
vm.$watch(‘a.b.c’, function (newVal, oldVal)) {

}
在watch函数内部,我们是不是就会new Watcher(vm,a.b.c, cb) 去为a.b.c这个数据添加一个依赖者啊。
又比如,模板中解析执行时,解析到v-text = ‘a.b.c’ 。在解析函数内,我们是不是也可以去new Watcher啊
只是实际vue应该是一个组件内的template对应一个watcher实例的。这样不至于绑定的watcher太多。而组件内任何一个状态数据发生变化,都会去触发这个watcher。vue就能快速知道是哪个组件发生了数据变化了。然后再通过虚拟dom的diff算法去进一步的查找最详细的变化。

好了。现在我们已经完成了侦测数据,收集依赖,并且通知依赖更新的一整条流程。
但是,我们目前好像就写了一个defineReactive去劫持一个数据啊。实际我们vue的data对象下面是会存在很多数据的。那我们是不是希望每个数据都进行劫持啊。并且,对象下面可能还有对象,我们是不是要一层一层的递归去侦测每一个属性啊。这个时候,我们可以再封装一个类 叫Observer。这个类的作用就是将vue data对象下的所有数据都转换为响应式数据(被侦测)的数据

Observer递归侦测所有数据和属性

export default class Observer {
	// 参数就是需要被劫持(侦测)的数据。首次进来参数肯定是vue的data对象无疑
	constructor (data) {
		this.value = data

		if (Array.isArray(this.value)) {
			// 劫持数组,我们暂且放这。
		} else {
			// 如果不是数组,必然就是对象
			this.walk(this.value) // vue内部用来递归劫持数据的方法
		}
	}

	walk (obj) {
		const keys = Object.keys(obj)
		for (let i = 0; i < keys.length; i++) {
			defineReactive(obj, keys[i], obj[keys[i]])
		}
	}
}

function defineReactive(data, key, val) {
	// 新增判断当前属性值是否是对象,如果是对象,继续递归劫持
	if (typeof val === 'object') {
		new Obsever(val)
	}
	let dep = new Dep()
	Object.defineProperty(data, key, function() {
		enumerable: true, // 该属性是否可被枚举
		configurable: true, // 该属性的配置后续是否可进行更改
		get: function () { // 用于获取属性值
			// 收集依赖
			if (window.target) {
				dep.addSub(window.target)
			}
			return val
		},
		set: function (newVal) { // 设置属性值
			if (newVal === val) return
			// 用新值替换旧值
			val = newVal
			// 数据发生变化时,通知依赖者
			dep.notify()
		}
	})
}

Observer定义好以后,我们在defineReactive中新增一条判断,如果当前属性值还是object,那么我们继续new Observer(val)去递归进行侦测。

那么第一次执行new Observer(),我们是不是可以在Vue构造函数的构造器里面去new啊

export default Vue {
	constructor (options = {}) {
        // 获取参数,保存参数
        this.$data = options.data

        // 对data下的数据做数据劫持
        new Observer(this.$data)
    }
}

这样一来,我们是不是在new Vue()实例的时候,就可以对data下的数据进行劫持啦!

常见面试题

说到这了,我再说一个可能大多人都碰到过的一个面试题吧

问题:为什么vue对象新增或者删除属性后,页面不会更新,而需要我们手动$set()。

或者这么问:什么情况下,修改对象,页面不会更新

如果上面的你都看懂了,相信你已经有答案了。

从上面我们可以看出,vue初始,只对data对象下的所有属性,以及属性的值进行了数据劫持。那么如果你给某个对象新增了一个属性,并给这个新增属性赋一个值。这个新增的属性,我们是不是并没有对这个新增的属性进行过数据劫持啊。那么给这个属性赋值的话,会触发set函数吗,很显然不会。所以就不会进行更新。
而我们通过手动$set(),实际上,set函数内部是调用了defineReactive()方法,去对这个新增的属性进行了数据劫持。所以$set才会触发更新。

而删除一个属性,并不是修改属性值,故不会触发set。
所以 什么情况下,修改对象,页面不会更新。那就是给对象新增属性,或者删除属性时不会更新。

好了。Object的数据侦测我们就说到这么。很晚了,回去了。Array的数据侦测,我另外写一篇文章吧,而且,两个写一起,文章可能会很长,所以我们还是分开写吧。
另外,两三个月前我有在我的资源中上传过一个MVVM响应式原理,其中的数据侦测就是写的Object的数据侦测。大家感兴趣的可以下载来看。
也欢迎各位大佬来提意见,大家相互探讨。

Logo

前往低代码交流专区

更多推荐