前面讨论了非原始值的响应式实现,接下来这节将讨论原始值的响应式实现。原始值指的是:Boolean、String、Number、BigInt、Symbol、undefined和null
等类型的值。
在JS中,原始值是按值传递的,而非按引用传递。这意味着如果一个函数接收原始值作为参数,那么形参和实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。
另外JS中的Proxy无法提供对原始值的修改,因此想要将原始值变成响应式数据,就需要做一层包裹,vue.js里面是借助ref实现的,接下来就让我们来认识它。
由于Proxy的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作,例如:
let str = 'hello world';
// 无法拦截对值的修改
str = 'hhh'
对于这个问题,我们能够想到的唯一办法:使用一个非原始值去包裹原始值,例如使用一个对象包裹原始值:
const wrapper = {
value: 'hello world'
}
// 可以使用Proxy代理 warpper,间接实现对原始值的拦截
const name = reactive(wrapper);
// 修改值可以触发响应
name.value = 'hhh'
但是这样做有两个问题:
wrapper.value
、wrapper.val
都是可以的。为了解决这两个问题,我们封装一个ref函数,将包裹对象的创建工作都封装到该函数中:
// 封装一个ref函数
function ref(val) {
// 在 ref函数内部创建包裹对象
const wrapper = {
value: val
}
// 将包裹对象变成响应式数据
return reactive(wrapper);
}
这样便解决了上面的两个问题,但是还不够完善。
比如:如何区分refVal到底是原始值的包裹对象,还是一个非原始值的响应式数据?
const refVal1 = ref(1);
const refVal2 = reactive({ value: 1 })
从结果实现来看,它们没有任何区别,但是有必要区分下,一个数据到底是不是ref。
那么该如何区分一个数据是否是ref呢?
// 封装一个ref函数
function ref(val) {
// 在 ref函数内部创建包裹对象
const wrapper = {
value: val
}
// 使用Object.defineProperty再wrapper对象上定义一个不可枚举的属性
// __v_isRef,并且返回值为true
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
// 将包裹对象变成响应式数据
return reactive(wrapper);
}
通过检查 __v_isRef
属性便知道一个数据是否是ref了。
ref除了能够用于原始值的响应式之外,还能用来解决响应丢失问题。
什么是响应丢失问题:
const obj = reactive({ foo: 1, bar2 })
// 将响应式数据展开到一个新的对象newObj里
const newObj = {
...obj
}
effect(() => {
// 在副作用函数内通过新的对象 newObj读取foo的值
console.log(newObj.foo);
})
// 此时修改obj.foo不会触发响应
obj.foo = 100;
使用拓展运算符展开的新对象newObj是一个普通对象,不具备任何响应能力,所以当我们修改 obj.foo
的值时,不会触发副作用函数重新执行。
那么有什么办法可以让我们实现:在副作用函数内,即使通过普通对象 newObj
来访问属性值,也能够建立响应联系?
const obj = reactive({ foo: 1, bar2 })
// 将响应式数据展开到一个新的对象newObj里
const newObj = {
foo: {
get value() {
return obj.foo
}
},
bar: {
get value() {
return obj.bar
}
}
}
effect(() => {
// 在副作用函数内通过新的对象 newObj读取foo的值
console.log(newObj.foo.value);
})
// 这样就能够触发响应了
obj.foo = 100;
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key];
}
}
// 定义 __v_isRef 属性,表面这是一个ref
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper;
}
// 借助toRef重新实现newObj对象
const newObj = {
foo: toRef(obj, 'foo'),
bar: toRef(obj, 'bar')
}
进一步实现,如果obj的键非常多,我们可以封装toRefs函数,来批量完成转换:
function toRefs(obj, key) {
const ret = {};
// 使用 for...in 循环遍历对象
for (const key in obj) {
// 逐个调用toRef完成转换
ret[key] = toRef(obj, key);
}
return ret;
}
这样一步操作便可完成对一个对象多个键的转换了:
const newObj = { ...toRefs(obj) }
如此一来,响应丢失问题就彻底解决了。思路是:将响应式数据转换成类似于ref结构的数据,并且为了概念上的统一,将通过toRef或toRefs转换后得到的结果视为真正的ref数据。
上文实现的toRef函数还存在缺陷,即通过toRef函数创建的ref是只读的,因为我们只添加了getter,没有添加setter,最终实现如下:
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key];
},
// 允许设置值
set value(newValue) {
obj[key] = newValue;
}
}
// 定义 __v_isRef 属性,表面这是一个ref
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper;
}
toRefs函数解决了响应丢失的问题,但是toRefs会把响应式数据的第一层属性值转换为ref,因此必须通过 value属性访问之,如:
const obj = reactive({ foo: 1, bar2 });
// obj.foo , obj.bar 即可访问属性值
const newObj = { ...toRefs(obj) };
newObj.foo.value;
newObj.bar.value;
这明显写起来很繁琐,增加了心智负担,毕竟通常是在模板里面访问数据,肯定不希望写得太烦吧, {{foo.value}} 、 {{bar.value}}
。
因此我们需要自动脱ref的能力。所谓自动脱ref,指的是属性的访问行为,即如果读取的属性是一个ref,则直接将该ref对应的value属性值返回即可:
newObj.foo.value -> newObj.foo
要实现这个功能,需要使用Proxy为newObj创建一个代理对象,通过代理来实现最终目标,这时就需要用到上文中的ref标识:
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
// 自动脱ref实现:如果读取的值是ref,则返回它的value属性值
return value.__v_isRef ? value.value : value;
}
})
}
// 调用proxyRefs 函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) })
这样,当我们读取的属性是一个ref时,直接返回该ref的value属性值,就实现了自动脱ref了。
console.log(newObj.foo);
console.log(newObj.bar);
在Vue3中,我们用到的setup函数所返回的数据其实是给 proxyRefs处理的。(script setup真的好香
const myComponent = {
setup() {
const count = ref(0);
// 返回的这个对象会传递给 proxyRefs
return { count };
}
}
这便是在访问直接访问ref值时,无需通过value属性来访问。上面我们只做了读取的,还没做设置的,添加对应的set拦截函数即可:
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
// 自动脱ref实现:如果读取的值是ref,则返回它的value属性值
return value.__v_isRef ? value.value : value;
},
set(target, key, newValue, receiver) {
// 通过target读取真实值
const value = target[key];
// 如果值是Ref,则设置其对应的value属性值
if (value.__v_isRef) {
value.value = newValue;
return true;
}
return Reflect.set(target, key, newValue, receiver);
}
})
}
在Vue.js中,其实reactive也有自动脱ref的能力,比如:
const count = ref(0);
const obj = reactive({ count });
obj.count; //0
这样我们不用知道一个值到底是不是ref,在模板做使用响应式数据时,无需关心它是不是ref。
在本章中,介绍了ref的概念,它本质就是一个 “包裹对象”,因为JS的Proxy无法提供对原始值的代理,所以需要使用一层对象作为包裹,间接实现原始值的响应式方案,并且定义一个属性 __v_isRef
作为ref的标识。以及解决响应丢失,自动脱ref的能力,减轻了心智负担,暴露到模板中的响应式数据不用写 xxx.value
了。
至此,响应式系统的作用与实现便介绍到这里。