Reactive 与 Ref
| reactive | ref |
|---|---|
| ❌只支持对象和数组(引用数据类型) | ✅支持基本数据类型+引用数据类型 |
✅在 <script> 和<template>中无差别使用 | ❌在 <script> 和 <template> 使用方式不同(script中要.value) |
| ❌重新分配一个新对象会丢失响应性 | ✅重新分配一个新对象不会失去响应 |
| 能直接访问属性 | 需要使用 .value 访问属性 |
| ❌将对象传入函数时,失去响应 | ✅传入函数时,不会失去响应 |
| ❌解构时会丢失响应性,需使用toRefs | ❌解构对象时会丢失响应性,需使用toRefs |
reactive 解构
直接给解构后的a赋值,a不会被改变,需要使用toRefs
// index.tsx
<template>
<div @click="change">
值a: {{ value.a }}
</div>
</template>
import { useTest } from './hook';
const value = reactive({
a: 1,
});
const change = () => {
// 这里直接给解构后的a赋值,a不会被改变
let { a } = value
a++
// 使用toRefs将value的属性改为ref,即a改为了ref
let { a } = toRefs(value);
a.value++
};
reactive 的属性传入函数
将reactive的属性值直接传入useTest这个组合式函数,更改value.a时会发现aPlus没有改变
// index.tsx
<template>
<div @click="change">
值a: {{ value.a }}
aPlus: {{ aPlus }}
</div>
</template>
import { useTest } from './hook';
const value = reactive({
a: 1,
});
// 这里直接将reactive对象或reactive对象的属性传入函数,会丢失响应式
const { aPlus } = useTest(value.a);
// 使用toRef将value变为ref类型传入函数
const { aPlus3 } = useTest(toRef(value.a));
// 将整个对象传入函数不会丢失响应
const { aPlus } = useTest2(value);
const change = () => {
value.a++;
};
// hook.ts
export const useTest = (a) => {
const aPlus = computed(() => {
return a + 1;
// 使用toRef后,newObj接受的是一个ref类型的值,需要在后面加value
return a.value + 1
});
return {
aPlus,
};
};
export const useTest2 = (obj) => {
const aPlus = computed(() => {
return obj.a + 1
});
return {
aPlus,
};
};
toRef 与 toRefs
toRef
toRef 可以将值、refs 或 getters 规范化为 refs,它可以接受基本数据类型与对象。
也可以基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然。
const state = reactive({
foo: 1,
bar: 2
})
// 双向 ref,会与源属性同步
const fooRef = toRef(state, 'foo')
// 更改该 ref 会更新源属性
fooRef.value++
console.log(state.foo) // 2
// 更改源属性也会更新该 ref
state.foo++
console.log(fooRef.value) // 3
toRefs
它接受的是一个对象,可以是数组,不可接受基本数据类型
将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。
比如:
const value1 = reactive({
a: 1,
b: {
c: 2,
},
});
console.log(toRefs(value1));
// 返回值是 {a: ObjectRefImpl, b: ObjectRefImpl} 将对象的每一个属性都变为了Ref类型
console.log(toRefs(value1).value.b);
// 返回值是 Proxy(Object) {c: 2} 仅将对象的第一层属性改为Ref,后面的是Proxy类型
const value2 = reactive([1, 2, 3]);
console.log(toRefs(value2));
//返回值是 [ObjectRefImpl, ObjectRefImpl, ObjectRefImpl],将数组的每一项都变为了Ref类型
深入响应式原理
什么是响应式
let A0 = 1
let A1 = 2
let A2
function update() {
A2 = A0 + A1
}
// 在A0或A1变化时,需要执行update更新 A2
update()函数会产生一个副作用,或者就简称为作用 (effect),因为它会更改程序里的状态。A0和A1被视为这个作用的依赖 (dependency),因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的订阅者 (subscriber)。
如何能够在 A0 或 A1 (这两个依赖) 变化时调用 update() (产生作用)?
-
当一个变量被读取时开启监听。例如我们执行了
update,触发了A0 + A1的计算,则A0和A1都被读取到了。 -
如果一个变量在副作用中被读取了,即执行了
update,就将该副作用update设为此变量的一个订阅者。 -
监听一个变量的变化。例如当我们给
A0赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。
如何实现响应式
我们无法直接追踪对上述示例中局部变量的读写,原生 JavaScript 没有提供任何机制能做到这一点。但是,我们是可以追踪对象属性的读写的。
在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 Proxies。Vue 2 使用 getter / setters 完全是出于支持旧版本浏览器的限制。而在 Vue 3 中则使用了 Proxy 来创建响应式对象,仅将 getter / setter 用于 ref。
defineProperty
使用 defineProperty 为当前对象定义 getter
var o = { a: 0 };
Object.defineProperty(o, "b", {
get: function () {
return this.a + 1;
},
});
console.log(o.b); // Runs the getter, which yields a + 1 (which is 1)
使用 defineProperty 为当前对象定义 setter
const o = { a: 0 };
Object.defineProperty(o, "b", {
set: function (x) {
this.a = x / 2;
},
});
o.b = 10; // Runs the setter, which assigns 10 / 2 (5) to the 'a' property
console.log(o.a); // 5
使用defineProperty的缺点
defineProperty在对象新增、删除属性没有响应式,数组新增、删除元素没有响应式;通过下标修改某个元素没有响应式;通过.length改变数组长度没有响应式。
Proxy
Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
const handler = {
get: function (target, key) {
return target[key];
},
set: function (target, key, value) {
target[key] = value
}
};
const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;
console.log(p.a, p.b); // 1, undefined
proxy的优点
- proxy性能整体上优于Object.defineProperty
- vue3支持更多数据类型的劫持(vue2只支持Object、Array;vue3支持Object、Array、Map、WeakMap、Set、WeakSet)
- vue3支持更多时机来进行依赖收集和触发通知(vue2只在get时进行依赖收集,vue3在get/has/iterate时进行依赖收集;vue2只在set时触发通知,vue3在set/add/delete/clear时触发通知),
所以vue2中的响应式缺陷vue3可以实现 - vue3做到了“精准数据”的数据劫持(vue2会把整个data进行递归数据劫持,而vue3只有在用到某个对象时,才进行数据劫持,所以响应式更快并且占内存更小)
- vue3的依赖收集器更容易维护(vue3监听和操作的是原生数组;vue2是通过重写的方法实现对数组的监控)
vue3 中的ref与reactive
reactive:
reactive是基于proxy实现的,其行为就和普通对象一样,不同的是Vue 能够拦截对响应式对象所有属性的访问和修改,以便进行依赖追踪和触发更新。
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
return target[key]
}
})
}
但是reactive有其局限性,
Proxy 的target是对象类型
- 当我们将响应式对象的原始类型属性解构为本地变量时,将丢失响应性连接
这是因为 ,解构赋值是一种从数组或对象中提取值并赋值给变量的语法,所以它会创建一个新的对象,而Proxy对象只能拦截已存在的属性,无法拦截新创建的属性或对象的赋值操作,
- 当我们将响应式对象的属性传递给函数时,我们将丢失响应性连接:
这是因为,state.count作为参数传递的时候,传递的是proxy代理的值(state的原始值)的属性,与proxy失去了联系,必须传入整个对象以保持响应性
// 该函数接收到的是一个普通的数字
// 并且无法追踪 state.count 的变化
// 我们必须传入整个对象以保持响应性
callSomeFunction(state.count)
- 由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地“替换”响应式对象,因为这样的话与第一个引用的响应性连接将丢失。
这个不太好理解,我们来一段伪代码帮助理解。
<template>
<div @click="change">赋值一个新的reactive</div>
<div @click="change2">赋值一个新的object</div>
<div @click="change3">解构赋值后更改</div>
</template>
<script lang="ts" setup>
let trackMap = new Map();
const track = (target, key) => {
trackMap.set(target, key);
};
const trigger = (target, key) => {
if (trackMap.has(target)) {
publishChange();
}
};
const publishChange = () => {
update();
};
const reactive = (obj) => {
return new Proxy(obj, {
get(target, key) {
debugger;
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
debugger;
trigger(target, key);
return target[key];
},
});
};
let test = reactive({ a: 1 });
let value2Comp = test.a + 1; // 此处触发get, trackMap中存储了当前test, a
function update() {
value2Comp = test.a + 100;
}
const change = () => {
test = reactive({ a: 2 }); // 重新赋值的时候,不会触发get,所以不会触发track,新赋值的对象也就不会被跟踪
test.a = 100; // 此处触发set,触发trigger,target为{a:2,b:2}, value为100,trackMap中没有这个target,所以不会触发update
console.log('test', test.a); // 此处打印为100
console.log('value2Comp', value2Comp); //没有触发update,所以打印为2
};
const change2 = () => {
test = { a: 2 }; // 这样赋值的时候,也不会触发get,所以不会触发track,新赋值的对象也就不会被跟踪
test.a = 100; // 此处触发set,触发trigger,target为{a:2,b:2}, value为100,trackMap中没有这个target,所以不会触发update
console.log('test', test.a); // 此处打印为100
console.log('value2Comp', value2Comp); //没有触发update,所以打印为2
};
const change3 = () => {
let { a } = test; // 此处可以触发get,
a = 100; // 但此处不会触发set,因为a是一个新的变量,不是test的属性
console.log('test', a); // 此处打印为100
console.log('value2Comp', value2Comp); //没有触发update,所以打印为2
};
</script>
总结:
- 在创建proxy的时候是不会触发get,只有读取属性的时候才触发get,所以vue3的文档上说【 Vue 的响应式跟踪是通过属性访问实现的】
- 整体赋值对象的时候,实际上是赋值了一个对象的引用地址,此时的test对象指向的也就是这个新的对象的引用地址了,他就不是一个proxy对象了,自然也就没有响应式
Ref
ref依然是使用的getter/setter的模式,对于基本数据类型,在读写时会触发get和set方法,以此来追踪变量,实现响应式,对于引用数据类型,有着与defineProperty相同的问题,对于对象的新增删除,数组的新增、删除元素等无效,所以需要将它们转化成Proxy,实现深层响应式。
const toReactive = (value) =>
isObject(value) ? reactive(value) : value
class RefImpl {
constructor(value) {
this._value = toReactive(value) // value的值是reactive包裹的
}
get value() {
track(this)
return this._value
}
set value(newVal) {
this._value = toReactive(newVal)
trigger(this, newVal)
}
}