原始值的响应式方案
之前,我们讨论了非原始值的响应式方案,这次我们将讨论原始值的响应式方案。原始值指的是 Boolean
,Number
,BigInt
,String
,Symbol
,undefined
,null
等类型的值。在 JavaScript 中,原始值是按值传递的,而非引用传递。这意味着,如果一个函数接收原始值作为参数,那么形参和实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。另外,JavaScript 中的 Proxy 无法提供对原始值的代理,因此要想将原始值变成响应式数据,就必须对其做一层包裹,也就是我们要介绍的 ref。
引入 ref 的概念
由于 Proxy 的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作,例如:
let str = 'vue';
// 无法拦截对值的修改
str = 'vue3';
对于这个问题,我们能够想到的唯一办法是,使用一个非原始值去 “包裹” 原始值,例如使用一个对象包裹原始值。
const { reactive } = require('../vue/reactive');
const wrapper = {
value: 'vue'
};
// 可以使用 Proxy 代理 wrap,间接实现对原始值的拦截
const name = reactive(wrapper);
// 修改值可以触发响应
name.value = 'vue3';
但这样做会导致两个问题:
- 用户为了一个创建响应式的原始值,不得不顺带创建一个包裹对象;
- 包裹对象由用户定义,这意味着不规范。用户可以随意命名,例如
wrapper.value
,wrapper.val
都是可以的。
为了解决这两个问题,我们可以封装一个函数,将包裹对象的创建工作都封装到该函数中。
const { reactive } = require('./reactive');
function ref (val) {
// 在 ref 函数内部创建包裹对象
const wrapper = {
value: val
};
// 将包裹对象变成响应式数据
return reactive(wrapper);
}
我们把创建 wrapper对象的工作封装到 ref 函数内部,然后使用 reactive
函数将包裹对象编程响应式数据并返回。这样我们就解决了上述两个问题。
const { effect } = require('../vue/effect');
const { ref } = require('../vue/ref');
const refVal = ref(1);
effect(() => {
// 在副作用该函数内通过 value 属性读取原始值
console.log(refVal.value);
});
refVal.value = 2;
上面这段代码可以正常工作,但并不完美。接下来我们面临的第一个问题是,如果区分 refVal
到底是原始值的包裹对象,还是一个非原始值的响应式数据,如以下代码所示:
const refVal1 = ref(1);
const refVal2 = reactive({ value: 2 });
这段代码中的 refVal1
和 refVal2
从我们的实现来看,并没有任何区别。但是我们有必要区分一个数据到底是不是 ref,因为涉及到后面的自动脱 ref 能力。
想要区分一个数据是否是 ref 很简单。
function ref (val) {
// 在 ref 函数内部创建包裹对象
const wrapper = {
value: val
};
// 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举属性
Object.defineProperty(wrapper, '_v_isRef', {
value: true
});
// 将包裹对象变成响应式数据
return reactive(wrapper);
}
我们使用 Object.defineProperty
为包裹对象 wrapper
定义了一个不可枚举且不可写的属性 _v_isRef
,它的值为 true,代表这个对象是一个 ref
,而非普通对象。这样我们就可以通过检查 _v_isRef
属性来判断一个数据是否是 ref 了。
响应丢失问题
ref 除了能够用于原始值的响应式方案之外,还能用来解决响应丢失问题。首先,我们来看什么是响应丢失问题。在编写 Vue.js 组件时,我们通过要把数据暴露在模板中使用,例如:
export default {
setup () {
const obj = reactive({ foo: 1, bar: 2 });
setTimeout(() => {
obj.foo = 100;
}, 1000);
return {
...obj
};
}
}
我们可以在模板中访问 setup 中暴露出的数据:
<template>
<p>{{ foo }}/{{ var }} </p>
</template>
但是这样做,会导致响应丢失。表现是当我们修改响应式数据的值时,不会触发重新渲染。这是由展开远算符(…)导致的。
return {
...obj
};
// =>
return {
foo: 1,
bar: 2
};
可以发现,这其实就是返回了一个普通对象,它不具有任何响应式能力。把一个普通对象暴露到模板中使用,是不会在渲染函数与响应式数据之间建立响应联系的。所以当我们尝试在一个定时器修改 obj.foo
的值时,不会触发重新渲染。我们可以使用另一种方式解决响应丢失问题。
const obj = reactive({ foo: 1, bar: 2 });
const newObj = { ...obj };
effect(() => {
console.log(newObj.foo);
});
obj.foo = 100; // 不会触发响应
如果解决上述问题呢?换句话说,有没有办法能够帮助我们实现:在副作用函数内,即使通过普通对象 newObj
来访问属性值,也能建立响应联系?
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 });
// newObject 对象下具有 与 obj 对象同名的属性,并且每个属性值都是一个对象
// 该对象具有一个访问器属性 value,当读取 value 的值时,其实读取的时 obj 对象下响应的属性值
const newObj = {
foo: {
get value() {
return obj.foo;
}
},
bar: {
get value() {
return obj.bar;
}
}
}
effect(() => {
console.log(newObj.foo.value);
});
obj.foo = 100;
在上面这段代码中,我们修改了 newObj
对象的实现方式。可以看到,在现在的 newObj
对象下,具有与 obj 对象同名的属性,而且每个属性得值都是一个对象,例如 foo 属性的值是否:
{
get value () {
return obj.foo;
}
}
该对象有一个访问器属性 value,当读取 value 的值时,最终读取的是响应式数据 obj 下的同名属性值。也就是说,当在副作用函数内读取 newObj.foo
时,等价于间接读取了 obj.foo
的值。这样响应式数据自然能够与副作用函数建立响应联系。于是,当我们修改 obj.foo
的值时,能够触发副作用函数重新执行。
在 newObj
对象中,foo 和 bar 这两个属性值的结构非常像,我们可以把这种结构抽象出来并封装成函数。
function toRef (obj, key) {
const wrapper = {
get value () {
return obj[key];
}
}
return wrapper;
}
toRef
接收两个参数,第一个参数 obj 是一个响应式数据,第二个参数是 obj 对象的一个键。该函数会返回一个类似于 ref 结构的 wrapper 对象。有了 toRef
函数后,我们就可以重新实现 newObj
对象了。
const obj = reactive({ foo: 1, bar: 2 });
const newObj = {
foo: toRef(obj, 'foo'),
bar: toRef(obj, 'bar')
}
effect(() => {
console.log(newObj.foo.value);
});
obj.foo = 100;
可以看到,代码变得非常简洁。但如果响应式数据 obj 的键非常多,我们还是要花费很大力气做转换。为此我们可以封装 toRefs
函数,批量地完成转换。
function toRefs (obj) {
const ans = {};
for (const key in obj) {
ans[key] = toRef(obj, key);
}
return ans;
}
const obj = reactive({ foo: 1, bar: 2 });
const newObj = { ...toRefs(obj) };
effect(() => {
console.log(newObj.foo.value);
});
obj.foo = 100;
现在,响应丢失问题就被我们彻底解决了。解决问题的思路是,将响应式数据转换成类似于 ref 结构的数据。为了概念上的统一,我们将通过 toRef
或 toRefs
转换后得到的结果视为真的 ref 数据,为此我们需要为 toRef
增加一段代码。
function toRef (obj, key) {
const wrapper = {
get value () {
return obj[key];
}
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
});
return wrapper;
}
可以看到,我们使用 Object.defineProperty
函数为 wrapper
对象定义了 _v_isRef
属性。这样,toRef
函数的返回值就是真正意义上的 ref 了。ref 的作用不仅仅是实现原始值的响应式方案,还用来解决响应丢失问题。
不过上文实现的 toRef
函数还存在缺陷,即通过 toRef
函数创建的 ref 是只读的。
const obj = reactive({ foo: 1, bar: 2 });
const refFoo = toRef(obj, 'foo');
refFoo.value = 100;
console.log(refFoo.value); // 1
这是因为 toRef
返回的 wrapper
对象的 value 属性只有 getter
,没有 setter
。为了功能的完整性,我们应该为它加上 setter
函数。
function toRef (obj, key) {
const wrapper = {
get value () {
return obj[key];
},
set value (val) {
obj[key] = val;
}
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
});
return wrapper;
}
const obj = reactive({ foo: 1, bar: 2 });
const refFoo = toRef(obj, 'foo');
refFoo.value = 100;
console.log(refFoo.value); // 100
可以看到,当设置 value 属性的值时,最终设置的时响应式数据的同名属性的值,这样就能正确地触发响应了。
自动脱 ref
toRefs
函数的确解决了响应丢失问题,但同时也带来了新的问题。由于 toRefs
会把响应式数据的第一层属性值的转换为 ref,因此必须通过 value 属性访问值。
const obj = reactive({ foo: 1, bar: 2 });
const newObj = { ...toRefs(obj) };
console.log(newObj.foo.value); // 1
console.log(newObj.bar.value); // 2
这其实增加了用户的心智负担,因为通常情况下用户是在模板中访问数据的。
<p>{{ foo }} / {{ bar }}</p>
用户肯定不希望这样编写代码。
<p>{{ foo.value }} / {{ bar.value }}</p>
因此,我们需要自动脱 ref 的能力。所以自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。
newObj.foo; // 1
即使 newObj.foo
是一个 ref,也无需通过 newObj.foo.value
来访问它的值。要实现此功能,需要使用 Proxy
为 newObj
创建一个代理对象,通过代理来实现最终目标,这时需要用到 ref 标识,__v_isRef
属性。
function proxyRefs (target) {
return new Proxy(target, {
get (target, key, receiver) {
const value = Reflect.get(target, key, receiver);
return value.__v_isRef ? value.value : value;
}
})
}
const obj = reactive({ foo: 1, bar: 2 });
const newObj = { ...toRefs(obj) };
console.log(newObj.foo.value); // 1
console.log(newObj.bar.value); // 2
const newObj2 = proxyRefs(newObj);
console.log(newObj2.foo); // 1
console.log(newObj2.bar); // 2
我们定义了 proxyRefs
函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用是拦截 get 操作,当读取的属性是一个 ref
时,则直接返回该 ref 的 value 属性值,这样就实现了自动脱 ref。
我们在编写 vue.js 组件时,组件中的 setup 函数所返回的数据会传递给 proxyRefs
函数进行处理。
const myComponent = {
setup () {
const count = ref(0);
// 返回的这个对象会传递给 proxyRefs
return { count };
}
}
这也是为什么我们可以在模板中直接访问一个 ref 的值,而无需通过 value 属性来访问。
<p>
{{ count }}
</p>
既然读取属性的值有自动脱 ref 的能力,相应地,设置属性的值也应该有自动为 ref 设置值的能力。
newObj.foo = 100;
实现此功能很简单,只需要添加对应的 set 拦截函数即可。
function proxyRefs (target) {
return new Proxy(target, {
get (target, key, receiver) {
const value = Reflect.get(target, key, receiver);
return value.__v_isRef ? value.value : value;
},
set (target, key, newValue, receiver) {
const value = target[key];
if (value.__v_isRef) {
value.value = newValue;
return true;
}
return Reflect.set(target, key, newValue, receiver);
}
})
}
const obj = reactive({ foo: 1, bar: 2 });
const newObj = proxyRefs({ ...toRefs(obj) });
console.log(newObj.foo); // 1
console.log(newObj.bar); // 2
newObj.foo = 100;
console.log(obj); // { foo: 100, bar: 2 }
我们为 proxyRefs
函数返回的代理对象添加了 set 拦截函数。如果设置的属性是一个 ref,则间接设置该 ref 的 value 属性的值即可。
实际上,自动脱 ref 不仅存在于上述场景。在 vue.js 中,reactive 函数也有自动脱 ref 的能力。
// 我们实现的 reactive 不具备自动脱 ref 的功能
const count = ref(0);
const obj = reactive({ count });
console.log(obj.count); // 0
可以看到,obj.count
应该是一个 ref,但由于自动脱 ref 能力的存在,使得我们无需通过 value 属性即可读取 ref 的值。这么设计旨在减轻用户的心智负担,因为在大部分情况下,用户并不知道一个值是不是 ref。有了自动脱 ref 的能力后,用户在模板中使用响应式数据时,将不再关心哪些是 ref,哪些不是 ref。
总结
ref 本质是一个 “包裹对象”。因为 JavaScript 无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。由于 “包裹对象” 本质上与普通对象没有任何区别,因此为了区分 ref 与普通响应式对象,我们还为 “包裹对象” 定义了一个值为 true 的属性,即 __v_isRef
,用它作为 ref 的标识。
ref 除了能够用于原始值的响应式方案之外,还能用来解决响应丢失问题。为了解决该问题,我们实现了 toRef
以及 toRefs
这两个函数。它们本质是对于响应式数据做了一层包装,或者叫做 “访问代理”。
最后,我们讲解了自动脱 ref 的能力。为了减轻用户的心智负担,我们自动对暴露在模板中的响应式数据进行脱 ref 处理。这样,用户在模板中使用响应式数据时,就无须关心一个值是不是 ref 了。