手写Vue3响应式数据原理

Vue3响应式数据


前言

我们想要对一个对象数据进行处理,从而实现更改dom。但如何更改对一个对象数据进行更改呢?

vue2 的双向数据绑定是利⽤ES5 的⼀个 API ,Object.defineProperty()对数据进⾏劫持 结合 发布订阅模式的⽅式来实现的。

vue3 中使⽤了 ES6 的 ProxyAPI 对数据代理,通过 reactive() 函数给每⼀个对象都包⼀层 Proxy,通过 Proxy 监听属性的变化,从⽽ 实现对数据的监控。

这⾥是相⽐于vue2版本,使⽤proxy的优势如下:

    1. defineProperty只能监听某个属性,不能对全对象监听可以省去for...in...、闭包等内容来提升效率(直接绑定整个对象即可)
    1. 监听数组,不⽤再去单独的对数组做特异性操作,通过Proxy可以直接拦截所有对象类型数据的操作,完美⽀持对数组的监听。

我们想要知道如何实现Vue3响应式数据,就要知道proxy这个概念。


一、proxy是什么?

Proxy(代理)是一种计算机网络技术,其作用是充当客户端和服务器之间的中间人,转发网络请求和响应。当客户端发送请求时,代理服务器会接收并转发请求到目标服务器,然后将服务器返回的响应转发给客户端。

相当于明星和经纪人,想要找明星办事,需要找他的经纪人,明星的事都交给经纪人做。明星就是源对象,经纪人就相当于proxy。

proxy用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

1.1 proxy基本使用

javascript 复制代码
// 定义一个源对象
let obj = {
	name: 'qx',
	age: 24
}
// 实现一个Proxy,传入要代理的对象和get和set方法
const proxy =  new Proxy(obj, {
	// get中返回代理对象的,target代表源对象(也是上面的obj),key代表obj中每个属性
	get(target, key) {
		return target[key];
	},
	// set中返回代理对象的,target代表源对象(也是上面的obj),key代表obj中每个属性,value是修改的新值
	set(target, key, value) {
		target[key] = value
		return true
	}
})
console.log(proxy)

obj.name = 'xqx'
// 现在打印的是修改后的proxy,看看会变成什么样?  已经修改好了
console.log(proxy)

二、实现最基本的reactive函数

reactive 用于创建一个响应式对象,该对象可以包含多个属性和嵌套属性。当使用 reactive 创建响应式对象时,返回的对象是一个代理对象,该对象具有与原始对象相同的属性,并且任何对代理对象属性的更改都将触发组件的重新渲染。

既然我们已经知道reactive是个函数,并且返回的是一个代理对象,先把最基本的框架搭出来

javascript 复制代码
function reactive(data) {
	return new Proxy(data, {
		get(target, key) {
			return target[key];
		},
		set(target, key, value) {
			target[key] = value
			return true
		}
	})
}

看似已经完成了,但是当传入非对象时,却报错

提示这个对象是对象类型的,例如数组之类的,并只是{}这个。

javascript 复制代码
const arr = true;
console.log(reactive(arr))


提示proxy要传入一个对象,所以需要先判断是不是对象

javascript 复制代码
function reactive(data) {
	//判断是不是对象,null也是object要排除
	if(typeof data === Object && data !== null) return 
	
	return new Proxy(data, {
		get(target, key) {
			return target[key];
		},
		set(target, key, value) {
			target[key] = value
			return true
		}
	})
}

三、实现基本响应式系统

我们知道处理数据就是为了让视图更新,但一个系统离不开副作用函数。

副作用函数,顾名思义,会产生副作用的函数被称为副作用函数。通俗来说,就是这个函数可以影响其他的变量。

来看最基本的副作用函数

javascript 复制代码
<div id="app"></div>
<script>
	let obj = {
		name: 'qx'
	}
	function effect(){
		app.innerText = obj.name
	}
	
	effect()
</script>

现在我们需要通过前面reactive函数来完善一个基本的响应式系统

javascript 复制代码
<body>
	<div id="app"></div>
	<script>
		let obj = {
			name: 'qx',
			age: 24
		}
		function reactive(data) {
			if(typeof data === Object && data !== null) return 
			return new Proxy(data, {
				get(target, key) {
					return target[key];
				},
				set(target, key, value) {
					target[key] = value
					return true
				}
			})
		}
		const state = reactive({name:'xqx'});
		
		function effect(){
			app.innerText = state.name
		}
		
		effect()
	</script>
</body>

到现在一个最基本的响应式系统出现

四、完善基本响应式系统

如果多个副作用函数同时引用一个变量,我们需要当变量改变时,每一个副作用函数都要执行。

4.1 执行每一个副作用函数

可以把多个副作用函数放在一个列表里,在每次对对象操作时,执行proxy中的set方法时,对每一个副作用函数进行遍历。

javascript 复制代码
<body>
	<div id="app"></div>
	<script>
		let obj = {name: 'qx'}
		let effectBucket = [];
		
		function reactive(data) {
			if(typeof data === Object && data !== null) return 
			return new Proxy(data, {
				get(target, key) {
					return target[key];
				},
				set(target, key, value) {
					target[key] = value
					effectBucket.forEach(fn=>fn())
					return true
				}
			})
		}
		
		const state = reactive({name:'xqx'});
		
		function effect(){
			app.innerText = state.name
			console.log('副作用函数1被执行')
		}
		effectBucket.push(effect)
		
		function effect1(){
			app.innerText = state.name
			console.log('副作用函数2被执行')
		}
		effectBucket.push(effect1)
		
		state.name = 'zs'
	</script>
</body>

但是我们要是传两个同样的副作用函数怎么办。

javascript 复制代码
function effect(){
	app.innerText = state.name
	console.log('副作用函数1被执行')
}
effectBucket.push(effect)
effectBucket.push(effect)


发现列表里有两个重复的effect函数,如果列表很长,foreach也会浪费时间,那么大大浪费性能。es6有个Set数据结构可以帮助我们解决这个问题。

javascript 复制代码
let effectBucket = new Set();

const state = reactive({name:'xqx'});

function effect(){
	app.innerText = state.name
	console.log('副作用函数1被执行')
}
effectBucket.add(effect)  //添加两次
effectBucket.add(effect)

function effect1(){
	app.innerText = state.name
	console.log('副作用函数2被执行')
}
effectBucket.add(effect1)

console.log(effectBucket)

我们把effect添加两次,看看结果是什么样的

4.2 实现依赖收集

前面我们只是对一个对象中的属性进行处理,如果多个属性都要更改呢?我们以上的操作会让每一个副作用函数都执行。

假设我们有这样一个结构

javascript 复制代码
let obj = {name: 'qx',age:24}

我想改name属性时,只更新有name的副作用函数,不必把列表里所有副作用函数都更新。这就需要依赖收集。

4.2.1 基本实现

对每一个副作用函数进行一个保存,当调用副作用函数时,会执行proxy中的get方法,在get方法把当前副作用函数添加列表,就实现了当前依赖属性和副作用函数关联在一起。

具体实现步骤如下:

javascript 复制代码
let obj = {name: 'qx',age:24}
let effectBucket = new Set();

let activeEffect = null;   //1.保存当前的副作用函数状态

function reactive(data) {
	if(typeof data === Object && data !== null) return 
	return new Proxy(data, {
		get(target, key) {
			if(activeEffect != null){              //4. 将当前保存的副作用函数添加到副作用函数列表中
				effectBucket.add(activeEffect)  
			}
			return target[key];
		},
		set(target, key, value) {
			target[key] = value
			effectBucket.forEach(fn=>fn())
			return true
		}
	})
}
const state = reactive(obj);
function effectName(){
	console.log('副作用函数1被执行',state.name)
}
activeEffect = effectName()  // 2.将当前副作用函数赋值给activeEffect 
effectName()                 // 3.调用副作用函数,相当于访问proxy的get方法
activeEffect = null;         // 5.将副作用函数状态置空,给下一个副作用函数用

function effectAge(){
 	console.log('副作用函数2被执行',state.age)
}
activeEffect = effectAge()
effectAge()
activeEffect = null;

state.name = 'zs'


再简化一下,对上面重复代码进行一个封装。调用的时候直接调封装后的方法

javascript 复制代码
function registEffect(fn) {
	if (typeof fn !== 'function') return;
	activeEffect = fn();
	fn();
	activeEffect = null;
}

4.3 改进桶结构

Set结构像数组,只是能做到去重,并不能实现不同属性对应不同集合。需要我们改进成一个属性对应多个集合。

另一个数据结构Map出现在面前,它是键值对的集合,但是"键"的范围不限于字符串,各种类型的值(包括对象)都可以当作键。

创建一个这样的结构。

javascript 复制代码
let a = {
	name: Set(fn,fn),
	age:Set(fn,fn)
}
javascript 复制代码
let effectBucket = new Map();  //{name:Set(fn,fn),age:Set(fn,fn)}
let activeEffect = null;

function reactive(data) {
	if (typeof data === Object && data !== null) return
	return new Proxy(data, {
		get(target, key) {
			if (activeEffect !== null) {
				let deptSet;
				if(!effectBucket.get(key)){                       //没有得到key,说明没有添加过
					deptSet = new Set();            //重新创建一个集合
					effectBucket.set(key,deptSet);  //每次添加一个属性{name:Set(fn,fn)}结构
				}
				deptSet.add(activeEffect)
			}
			return target[key];
		},
		set(target, key, value) {
			target[key] = value
			//从副作用桶中依次取出每一个副作用函数执行
			let deptSet = effectBucket.get(key);
			if(deptSet){                   
				deptSet.forEach(fn => fn())
			}
			return true
		}
	})
}

继续封装收集依赖
get

javascript 复制代码
function track(target, key) {
	if (!activeEffect) return
	let deptSet;
	if (!effectBucket.get(key)) { //没有得到key,说明没有添加过
		deptSet = new Set(); //重新创建一个集合
		effectBucket.set(key, deptSet);
	}
	deptSet.add(activeEffect)
}

set

javascript 复制代码
function trigger(target, key) {
	let deptSet = effectBucket.get(key);
	if (deptSet) {
		deptSet.forEach((fn) => fn())
	}
}
javascript 复制代码
function track(target, key) {
	if (!activeEffect) return
	let deptMap =effectBucket.get(key);
	if (!deptMap) { //没有得到key,说明没有添加过
		deptMap = new Map(); //重新创建一个集合
		effectBucket.set(target, deptMap);
	}
	let depSet = deptMap.get(key)
	if(!depSet){
		depSet = new Set()
		deptMap.set(key,depSet)
	}
	deptSet.add(activeEffect)
}

function trigger(target, key) {
	let depMap = effectBucket.get(target)
	if(!depMap) return
	let deptSet = effectBucket.get(key);
	if (deptSet) {
		deptSet.forEach((fn) => fn())
	}
}

五、相关面试题

1.Object.defineProperty 和 Proxy 的区别?

  1. Proxy 可以直接监听对象而非属性;
  2. Proxy 可以直接监听数组的变化;
  3. Proxy 有多达 13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等 是 Object.defineProperty 不具备的;
  4. Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty 只能遍历对象属性直接修改
  5. Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准 的性能红利
  6. Object.defineProperty 兼容性好,支持 IE9,而 Proxy 的存在浏览器兼容性问题, 而且无法用 polyfill 磨平,因此 Vue 的作者才声明需要等到下个大版本( 3.0 )才能用 Proxy 重 写

2.vue2.0 和 vue3.0 有什么区别? 双向绑定更新?

vue2 的双向数据绑定是利⽤ES5 的⼀个 API ,Object.defineProperty()对数据进⾏劫持 结合 发布订阅模式的⽅式来实现的。

vue3 中使⽤了 ES6 的 ProxyAPI 对数据代理,通过 reactive() 函数给每⼀个对象都包⼀层 Proxy,通过 Proxy 监听属性的变化,从⽽ 实现对数据的监控。

这⾥是相⽐于vue2版本,使⽤proxy的优势如下:

  1. defineProperty只能监听某个属性,不能对全对象监听 可以省去for in、闭包等内容来提升效率(直接绑定整个对象即可)

  2. 监听数组,不⽤再去单独的对数组做特异性操作,通过Proxy可以直接拦截所有对象类型数据的操作,完美⽀持对数组的监听。

获取props

vue2在script代码块可以直接获取props,vue3通过setup指令传递

API不同

Vue2使⽤的是选项类型API(Options API),Vue3使⽤的是合成型API(Composition API)

建立数据data

vue2是把数据放入data中,vue3就需要使用一个新的setup()方法,此方法在组件初始化构造得时候触发。

生命周期不同

vue2 vue3
beforeCreate setup() 开始创建组件之前,创建的是data和method
created setup()
beforeMount onBeforeMount 组件挂载到节点上之前执行的函数
mounted onMounted 组件挂载完成后执行的函数
beforeUpdate onBeforeUpdate 组件更新之前执行的函数
updated onUpdated 组件更新完成之后执行的函数
beforeDestroy onBeforeUnmount 组件挂载到节点上之前执行的函数
destroyed onUnmounted 组件卸载之前执行的函数
activated onActivated 组件卸载完成后执行的函数
deactivated onDeactivated

关于v-if和v-for的优先级:

vue2 在一个元素上同时使用 v-if 和 v-for v-for会优先执行

vue3 v-if 总会优先于 v-for生效

vue2和vue3的diff算法

vue2

vue2 diff算法就是进行虚拟节点对比,并返回一个patch对象,用来存储两个节点 不同的地方,最后用patch记录的消息去局部更新Dom。

vue2 diff算法会比较每一个vnode,而对于一些不参与更新的元素,进行比较是有 点消耗性能的。

vue3

vue3 diff算法在初始化的时候会给每个虚拟节点添加一个patchFlags,patchFlags 就是优化的标识。

只会比较patchFlags发生变化的vnode,进行更新视图,对于没有变化的元素做静 态标记,在渲染的时候直接复用。

3.Vue 是如何实现数据双向绑定的?

Vue 数据双向绑定主要是指:数据变化更新视图

  • 输入框内容变化时,Data 中的数据同步变化。即 View => Data 的变化。
  • Data 中的数据变化时,文本节点的内容同步变化。即 Data => View 的变化

Vue 主要通过以下 4 个步骤来实现数据双向绑定的:

  • 第一步:需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter 和 getter。这样的话,给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变化。

  • 第二步:compile 解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。

  • 第三步:Watcher 订阅者是 Observer 和 Compile 之间通信的桥梁,主要做的事情是:

    • 1、在自身实例化时往属性订阅器(dep)里面添加自己
    • 2、自身必须有一个 update()方法
    • 3、待属性变动 dep.notice()通知时,能调用自身的 update()方法,并触发 Compile中绑定的回调,则功成身退。
  • 第四步:MVVM 作为数据绑定的入口,整合 Observer、Compile 和 Watcher三者,通过 Observer 来监听自己的 model 数据变化,通过 Compile 来解析编译模板指令,最终利用 Watcher 搭起 Observer 和 Compile 之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据 model 变更的双向绑定效果。

4.介绍下 Set、Map、WeakSet 和 WeakMap的区别?

Set

Set是一种叫做集合的数据结构,是由一堆无序的、相关联的,且不重复的内存结构组成的组合。集合是以[值,值]的形式存储元素

  1. 成员不能重复;
  2. 只有键值,没有键名,有点类似数组;
  3. 可以遍历,方法有 add、delete、has、clear

WeakSet

  1. WeackSet只能成员只能是引用类型,而不能是其他类型的值;
  2. 成员都是弱引用,随时可以消失(不计入垃圾回收机制)。可以用来保存 DOM 节点,不容易造成内存泄露;
  3. 不能遍历,没有size属性,方法有 add、delete、has ;

Map

Map是一种叫做字典的数据结构,每个元素有一个称作key 的域,不同元素的key 各不相同。字典是以[键,值]的形式存储。

  1. 本质上是键值对的集合,类似集合。Map的键可以时任何类型数据,就连函数都可以。;
  2. 可以遍历,方法很多,可以跟各种数据格式转换;
  3. Map的键实际上是跟内存地址绑定的,只要内存地址不一样,就视为两个键;

WeakMap

  1. 只接收对象为键名(null 除外),不接受其他类型的值作为键名;
  2. 键名指向的对象,不计入垃圾回收机制;
  3. 不能遍历,没有clear清空方法,方法同 get、set、has、delete ;

5.Vue2.0为什么不能检查数组的变化?该怎么解决?

  • 无法检测数组/对象的新增?

Vue检测数据的变动是通过Object.defineProperty实现的,所以无法监听数组的添加操作是可以理解的,因为是在构造函数中就已经为所有属性做了这个检测绑定操作。

  • 无法检测通过索引改变数组的操作?即vm.items[indexOfItem] = newValue?
javascript 复制代码
function defineReactive(data, key, value) {
	Object.defineProperty(data, key, {
		enumerable: true,
		configurable: true,
		get: function defineGet() {
			console.log(`get key: ${key} value: ${value}`)
			return value
		},
		set: function defineSet(newVal) {
			console.log(`set key: ${key} value: ${newVal}`)
			value = newVal
		}
	})
}

function observe(data) {
	Object.keys(data).forEach(function(key) {
		console.log(data, key, data[key])
		defineReactive(data, key, data[key])
	})
}

let arr = [1, 2, 3]
observe(arr)

原来的Object.defineProperty发现通过索引是可以赋值的,并且也触发了set方法,但是Vue为什么不行呢?

对于对象而言,每一次的数据变更都会对对象的属性进行一次枚举,一般对象本身的属性数量有限,所以对于遍历枚举等方式产生的性能损耗可以忽略不计,但是对于数组而言呢?数组包含的元素量是可能达到成千上万,假设对于每一次数组元素的更新都触发了枚举/遍历,其带来的性能损耗将与获得的用户体验不成正比,故vue无法检测数组的变动。

解决方案

  • 数组
  1. this.$set(array, index, data)
javascript 复制代码
//这是个深度的修改,某些情况下可能导致你不希望的结果,因此最好还是慎用
this.dataArr = this.originArr
this.$set(this.dataArr, 0, {data: '修改第一个元素'})
console.log(this.dataArr)        
console.log(this.originArr)  //同样的 源数组也会被修改 在某些情况下会导致你不希望的结果 
  1. splice
javascript 复制代码
//因为splice会被监听有响应式,而splice又可以做到增删改。
  1. 利用临时变量进行中转
javascript 复制代码
let tempArr = [...this.targetArr]
tempArr[0] = {data: 'test'}
this.targetArr = tempArr
  • 对象
  1. this.$set(obj, key ,value) - 可实现增、改
  2. watch时添加deep:true深度监听,只能监听到属性值的变化,新增、删除属性无法监听
javascript 复制代码
this.$watch('blog', this.getCatalog, {
    deep: true
    // immediate: true // 是否第一次触发
  });
  1. watch时直接监听某个key
javascript 复制代码
watch: {
  'obj.name'(curVal, oldVal) {
    // TODO
  }
}
相关推荐
Fighting_p几秒前
【记录】列表自动滚动轮播功能实现
前端·javascript·vue.js
前端Hardy2 分钟前
HTML&CSS:超炫丝滑的卡片水波纹效果
前端·javascript·css·3d·html
技术思考者6 分钟前
HTML速查
前端·css·html
缺少动力的火车6 分钟前
Java前端基础—HTML
java·前端·html
Domain-zhuo19 分钟前
Git和SVN有什么区别?
前端·javascript·vue.js·git·svn·webpack·node.js
雪球不会消失了24 分钟前
SpringMVC中的拦截器
java·开发语言·前端
李云龙I35 分钟前
解锁高效布局:Tab组件最佳实践指南
前端
m0_7482370539 分钟前
Monorepo pnpm 模式管理多个 web 项目
大数据·前端·elasticsearch
JinSoooo42 分钟前
pnpm monorepo 联调方案
前端·pnpm·monorepo
m0_748244961 小时前
【AI系统】LLVM 前端和优化层
前端·状态模式