知识前景:假设你已了解vue3组合式API中,关于setup的知识。如果你刚开始学习,可以看看官方文档,或者我的这篇文章。
目录
第一部分、数据的「响应式」
一、ref和reactive的目标——实现数据的「响应式」
二、vue2的响应式
1.vue2响应式的实现——Object.defineProperty
2.vue2响应式的问题
1)硬伤1:对象直接新添加的属性或删除已有属性, 界面不会自动更新
2)硬伤2:直接通过下标替换数组元素或更新length, 界面不会自动更新
三、Vue3的响应式
1.vue3响应式的实现——Proxy对象
1)Proxy 对象
2)Proxy 对象基本用法
2.Vue3的响应式具体实现
3.Proxy递归代理
四、vue3的reative和ref
1.reative
2.JS的数据类型
1)栈(stack)和堆(heap)
2)数据类型
3)基本数据类型(存放在栈中)
3.ref
4.ref和reactive的区别
5.ref和reative的使用心得
1)写法1:reative声明所有变量,最后return的时候一起toRefs
2)写法2:从头到尾都用ref声明变量,赋值的时候要注意加.value
今天带你来看vue3最重要的两个API:ref和reative,这两个API很重要,如果不理解它们,可以说你没有完全理解vue3的响应式原理。 但这两个API很难理解,已有vue2经验的我,整体过完vue3的文档后,感觉最难理解就是这两货。
难理解是因为这个涉及到很多底层知识:JS的数据类型、内存栈、Object.defineProperty、ES6的Proxy ,那么今天,我就带你来啃这块难啃的骨头。
ref和reactive是干什么用?就是把数据变成「响应式」的。
注意「响应式」这个词,后面会反复提到,因为没有「响应式」,就无法实现vue的数据驱动视图。
「响应式」到底是什么?
如果你熟悉vue,下面这段代码你肯定很熟悉:
Counter: {{ counter }}
export default {
data() {
return {
counter: 0
}
},
mounted() {
setInterval(() => {
this.counter++
}, 1000)
}
}
上面的示例中,其中 counter
会每秒递增,你将看到渲染的 DOM 是如何变化的:
这就是Vue 核心思想——数据驱动视图。你已经看到,数据和 DOM 被建立了关联,所有东西都是「响应式」的,数据(JS)counter变化,视图层(HTML)就会跟着变化
。
这个与DOM 建立了关联的数据counter
,就是「响应式」的数据。
现在你已经理解了vue的思想,但仅仅知道思想远远不够,我们还要了解vue的原理。
因此,我会带你更深一步地探究:vue是怎么实现数据的「响应式」。
vue2的响应式是通过Object.defineProperty(数据劫持)方法,针对对象和数组有两种处理:
当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data
选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。
Object.defineProperty(data, 'count', {
get () {},
set () {}
})
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
虽然实现了数据的「响应式」,但Vue 不能检测数组和对象的变化。这就导致:Object.defineProperty所实现的数据响应式,依然有着一些硬伤:
由于Object.defineProperty只能代理某个属性,对象直接新添加的属性或删除已有属性,这种情况无法监听到。
var vm = new Vue({
data:{
a:1
}
})
// `vm.a` 是响应式的
vm.b = 2
// `vm.b` 是非响应式的
解决方法:
①更新1个属性,使用vm.$set方法
针对这个问题,vue2提供了 vm.$set
实例方法,这也是全局 Vue.set
方法的别名:
this.$set(this.someObject,'b',2)
②更新多个属性,使用Object.assign()
有时你可能需要为已有对象赋值多个新 property,比如使用 Object.assign()
或 _.extend()
。但是,这样添加到对象上的新 property 不会触发更新。在这种情况下,你应该用原对象与要混合进去的对象的 property 一起创建一个新的对象。
// 代替 `Object.assign(this.someObject, { a: 1, b: 2 })`
this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })
举个例子:
var vm = new Vue({
data: {
items: ['a', 'b', 'c']
}
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的
解决方法:
①对于下标替换数组元素,而界面不更新的问题,使用vm.$set或数组的splice方法
以下两种方式都可以实现和 vm.items[indexOfItem] = newValue
相同的效果,同时也将在响应式系统内触发状态更新:
// Vue.set
Vue.set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)
你也可以使用 vm.$set 实例方法,该方法是全局方法 Vue.set
的一个别名:
vm.$set(vm.items, indexOfItem, newValue)
②对于更新数组的length,,而界面不更新的问题,使用数组的splice方法
为了解决第二类问题,你可以使用 splice
:
vm.items.splice(newLength)
既然vue2的「响应式」有这么多的问题,那么vue3是怎么解决这些问题的呢?我们接着往下看。
针对Object.defineProperty的弊病, 在 ES6 中引入了一个新的对象——Proxy(对象代理)
Proxy 对象:用于创建一个对象的代理,主要用于改变对象的默认访问行为,实际上是在访问对象之前增加一层拦截,在任何对对象的访问行为都会通过这层拦截。在这层拦截中,我们可以增加自定义的行为。基本语法如下:
/*
* target: 目标对象
* handler: 配置对象,用来定义拦截的行为
* proxy: Proxy构造器的实例
*/
var proxy = new Proxy(target,handler)
看个简单例子:
// 目标对象
var target = {
num:1
}
// 自定义访问拦截器
var handler = {
// receiver: 操作发生的对象,通常是代理
get:function(target,prop,receiver){
console.log(target,prop,receiver)
return target[prop]*2
},
set:function(trapTarget,key,value,receiver){
console.log(trapTarget.hasOwnProperty(key),isNaN(value))
if(!trapTarget.hasOwnProperty(key)){
if(typeof value !== 'number'){
throw new Error('入参必须为数字')
}
return Reflect.set(trapTarget,key,value,receiver)
}
}
}
// 创建target的代理实例dobuleTarget
var dobuleTarget = new Proxy(target,handler)
console.log(dobuleTarget.num) // 2
dobuleTarget.count = 2
// 代理对象新增属性,目标对象也跟着新增
console.log(dobuleTarget) // {num: 1, count: 2}
console.log(target) // {num: 1, count: 2}
// 目标对象新增属性,Proxy能监听到
target.c = 2
console.log(dobuleTarget.c) // 4 能监听到target新增的属性
复制代码
例子里,我们通过Proxy构造器创建了target的代理dobuleTarget,即是代理了整个target对象,此时通过对dobuleTarget属性的访问都会转发到target身上,并且针对访问的行为配置了自定义handler对象。因此任何通过dobuleTarget访问target对象的属性,都会执行handler对象自定义的拦截操作。
Proxy 对象可以拦截对data任意属性的任意(13种)操作, 包括属性值的读写, 属性的添加, 属性的删除等..这些操作被拦截后会触发响应特定操作的「陷阱函数」。
这13种「陷阱函数」如下图所示:
陷阱函数 | 覆写的特性 |
---|---|
get | 读取一个值 |
set | 写入一个值 |
has | in操作符 |
deleteProperty | Object.getPrototypeOf() |
getPrototypeOf | Object.getPrototypeOf() |
setPrototypeOf | Object.setPrototypeOf() |
isExtensible | Object.isExtensible() |
preventExtensions | Object.preventExtensions() |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor() |
defineProperty | Object.defineProperty |
ownKeys | Object.keys() Object.getOwnPropertyNames()和Object.getOwnPropertySymbols() |
apply | 调用一个函数 |
construct | 用new调用一个函数 |
Proxy 对象比Object.defineProperty更牛逼!它把vue2响应式的硬伤全部解决了!总结一下:
Vue3的响应式实现,正是使用了这个强大的Proxy代理对象,Vue 会将该数据包裹在一个带有 get
和 set
处理程序的 Proxy 中。当Proxy 对象监听到了的数据变更时,通过 Reflect(反射): 动态对被代理对象的相应属性进行特定的操作,具体代码如下:
new Proxy(data, {
// 拦截读取属性值
get (target, prop) {
return Reflect.get(target, prop)
},
// 拦截设置属性值或添加新属性
set (target, prop, value) {
return Reflect.set(target, prop, value)
},
// 拦截删除属性
deleteProperty (target, prop) {
return Reflect.deleteProperty(target, prop)
}
})
proxy.name = 'tom'
案例:
Proxy 与 Reflect
Proxy只代理对象的外层属性。例子如下:
var target = {
a:1,
b:{
// 对象内层的属性值(c、d、e),Proxy无法代理。
c:2,
d:{e:3}
}
}
var handler = {
get:function(trapTarget,prop,receiver){
console.log('触发get:',prop)
return Reflect.get(trapTarget,prop)
},
set:function(trapTarget,key,value,receiver){
console.log('触发set:',key,value)
return Reflect.set(trapTarget,key,value,receiver)
}
}
var proxy = new Proxy(target,handler)
proxy.b.d.e = 4
// 输出 触发get:b , 由此可见Proxy仅代理了对象外层属性。
那么针对内层属性的变更,如何实现代理呢?答案是递归设置代理:
var target = {
a:1,
b:{
c:2,
d:{e:3}
}
}
var handler = {
get:function(trapTarget,prop,receiver){
var val = Reflect.get(trapTarget,prop)
console.log('get',prop)
if(val !== null && typeof val==='object'){
return new Proxy(val,handler) // 代理内层
}
return Reflect.get(trapTarget,prop)
},
set:function(trapTarget,key,value,receiver){
console.log('触发set:',key,value)
return Reflect.set(trapTarget,key,value,receiver)
}
}
var proxy = new Proxy(target,handler)
proxy.b.d.e
// 输出: 均被代理
// get b
// get d
// get e
从递归代理可以看出,如果对象内部要全部递归代理,Proxy可以只在调用时递归设置代理。
只要你把上面的Proxy对象看懂了,我们再去理解vue3的reative就方便多了,下面我们细细来看:
含义:将「引用类型」数据转换为「响应式」数据,即,把值类型的数据包装编程响应式的引用类型的数据
类型:函数
参数:reactive参数必须是对象(json/arr)
本质: 将传入的数据包装成一个Proxy对象
手写reative函数实现:
const reactiveHandler = {
get (target, key) {
if (key==='_is_reactive') return true
return Reflect.get(target, key)
},
set (target, key, value) {
const result = Reflect.set(target, key, value)
console.log('数据已更新, 去更新界面')
return result
},
deleteProperty (target, key) {
const result = Reflect.deleteProperty(target, key)
console.log('数据已删除, 去更新界面')
return result
},
}
/*
自定义reactive
*/
function reactive (target) {
if (target && typeof target==='object') {
if (target instanceof Array) { // 数组
target.forEach((item, index) => {
target[index] = reactive(item)
})
} else { // 对象
Object.keys(target).forEach(key => {
target[key] = reactive(target[key])
})
}
const proxy = new Proxy(target, reactiveHandler)
return proxy
}
return target
}
/* 测试自定义reactive */
const obj = {
a: 'abc',
b: [{x: 1}],
c: {x: [11]},
}
const proxy = reactive(obj)
console.log(proxy)
proxy.b[0].x += 1
proxy.c.x[0] += 1
看到这里,你可能会想:既然reative函数已经实现了数据的「响应式」,那为什么还会有另一个实现「响应式」的函数——ref?
现在我来解答这个问题,你需要注意一下,在reative函数定义中,有这么一句:将「引用类型」数据转换为「响应式」数据。这个「引用类型」是什么?
这个就要从JS的数据类型讲起了。
stack为自动分配的内存空间,它由系统自动释放;而heap则是动态分配的内存,大小也不一定会自动释放
JS分两种数据类型:
基本数据类型:Number、String、Boolean、Null、 Undefined、Symbol(ES6),这些类型可以直接操作保存在变量中的实际值。
引用数据类型:Object(在JS中除了基本数据类型以外的都是对象,数据是对象,函数是对象,正则表达式是对象)
基本数据类型是指存放在栈中的简单数据段,数据大小确定,内存空间大小可以分配,它们是直接按值存放的,所以可以直接按值访问
var a = 10;
var b = a;
b = 20;
console.log(a); // 10
console.log(b); // 20
下图演示了这种基本数据类型赋值的过程:
4)引用数据类型(存放在堆内存中的对象,每个空间大小不一样,要根据情况进行特定的配置)
引用类型是存放在堆内存中的对象,变量其实是保存的在栈内存中的一个指针(保存的是堆内存中的引用地址),这个指针指向堆内存。
引用类型数据在栈内存中保存的实际上是对象在堆内存中的引用地址。通过这个引用地址可以快速查找到保存中堆内存中的对象。
var obj1 = new Object();
var obj2 = obj1;
obj2.name = "我";
console.log(obj1.name); // 我
说明这两个引用数据类型指向了同一个堆内存对象。obj1赋值给obj2,实际上这个堆内存对象在栈内存的引用地址复制了一份给了obj2,但是实际上他们共同指向了同一个堆内存对象,所以修改obj2其实就是修改那个对象,所以通过obj1访问也能访问的到。
var a = [1,2,3,4,5];
var b = a;//传址 ,对象中传给变量的数据是引用类型的,会存储在堆中;
var c = a[0];//传值,把对象中的属性/数组中的数组项赋值给变量,这时变量C是基本数据类型,存储在栈内存中;改变栈中的数据不会影响堆中的数据
alert(b);//1,2,3,4,5
alert(c);//1
//改变数值
b[4] = 6;
c = 7;
alert(a[4]);//6
alert(a[0]);//1
从上面我们可以得知,当我改变b中的数据时,a中数据也发生了变化;但是当我改变c的数据值时,a却没有发生改变。
这就是传值与传址的区别。因为a是数组,属于引用类型,所以它赋予给b的时候传的是栈中的地址(相当于新建了一个不同名“指针”),而不是堆内存中的对象。而c仅仅是从a堆内存中获取的一个数据值,并保存在栈中。所以b修改的时候,会根据地址回到a堆中修改,c则直接在栈中修改,并且不能指向a堆内存中。
下面这张动图形象的解释了传值和传地址的区别,左侧是传地址,右侧是传值,
Proxy对象只能代理引用类型的对象,面对基本数据类型你如何实现响应式呢?
vue的解决方法是把基本数据类型变成一个对象:这个对象只有一个value属性,value属性的值就等于这个基本数据类型的值。然后,就可以用reative方法将这个对象,变成响应式的Proxy对象。
这个带有value属性的ref对象,整个过程的方法vue3封装在了ref函数里,即,ref的本质是
ref(0) --> reactive( { value:0 })
理解了这点,你再看ref就很简单了很多~
作用:1.把基本类型的数据包装编程响应式的引用类型的数据。
2.获取DOM元素: 在Vue3.x中我们也可以通过ref函数来获取元素
类型:函数
参数:1.基本数据类型
2.引用类型类型(最好别传,传了也是内部调用reative)
3.DOM的ref属性值
本质: 将传入的数据包装成一个Proxy对象
使用:1.把基本类型数据转换响应式:通过返回值的 value
属性获取响应式的值 ,修改也需要对 .value进行修改。注意,在js中要.value, 在模板中则不需要(内部解析模板时会自动添加.value)。
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
2.获取DOM元素:
今天是周一
手写ref函数实现(仅实现了响应式功能,获取DOM没写):
/*
自定义ref
*/
function ref(target) {
if (target && typeof target==='object') {
target = reactive(target)
}
const result = {
_value: target, // 用来保存数据的内部属性
_is_ref: true, // 用来标识是ref对象
get value () {
return this._value
},
set value (val) {
this._value = val
console.log('set value 数据已更新, 去更新界面')
}
}
return reactive(result)
}
/* 测试自定义ref */
const ref1 = ref(0)
const ref2 = ref({
a: 'abc',
b: [{x: 1}],
c: {x: [11]},
})
ref1.value++
ref2.value.b[0].x++
console.log(ref1, ref2)
总体来讲,ref函数的流程图如下:
ref是把值类型添加一层包装,使其变成响应式的引用类型的值。
reactive 则是引用类型的值变成响应式的值。
所以两者的区别只是在于是否需要添加一层引用包装
再次声明:本质上,ref(0) 等于 reactive( { value:0 })
一般来说,vue定义响应式变量有两种写法:
一种是把reative当做vue2的data,所有变量用reative一次性生成,最后一起toRefs(这个注意不要漏)。优点是赋值不用写.value
name: {{state.name}}
age: {{state.age}}
wife: {{state.wife}}
第二种,从头到尾都用ref,除了赋值时要.value很麻烦,其他倒没什么。
{{count}}
参考链接:重学JS | Proxy与Object.defineProperty的用法与区别
JS基本数据类型和引用数据类型的区别及深浅拷贝 - 简书