编辑 | Tina
Vue 的 reactivity 响应式机制确实不错,只是有个“小”缺点:它会搞乱引用。本来一切看起来好好的,连 TypeScript 都说没问题,但突然就崩了。
我这里聊的可不是带有强制输入的嵌套引用,那明显更复杂、更混乱。只有对一切了然于胸的大师才能解决这类问题,所以本文暂且不表。
哪怕在日常使用当中,如果大家不了解其工作原理,reactivity 也可能引发各种令人抓狂的问题。
一个简单数组
让我们看看以下代码:
let notifications = [] as Notification[];
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
notifications.push(notification);
function removeNotification() {
notifications = notifications
.filter((inList) => inList != notification);
}
if (autoclose > 0) {
setTimeout(removeNotification, autoclose);
}
return removeNotification;
}
都挺好的,对吧?如果 autoclose 不为零,它就会自动从列表中删除通知。我们也可以调用返回的函数来手动将其关闭。代码又清晰又漂亮,哪怕调用两次,removeNotification 也能正常起效,仅仅删除掉跟我们推送到数组中的元素完全相同的内容。
好的,但它不符合响应式标准。现在看以下代码:
const notifications = ref<Notification[]>([]);
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
notifications.value.push(notification);
function removeNotification() {
notifications.value = notifications.value
.filter((inList) => inList != notification);
}
if (autoclose > 0) {
setTimeout(removeNotification, autoclose);
}
return removeNotification;
}
这完全就是一回事,所以应该也能正常运行吧?我们是想让数组迭代各条目,并过滤掉与我们所添加条目相同的条目。但情况并非如此。理由也不复杂:我们以参数形式收到的 notification 对象很可能是个普通的 JS 对象,而在数组中该条目是个 Proxy。
那该如何处理?
使用 Vue 的 API
如果我们出于某种原因而不想修改对象,则可以使用 toRaw 获取数组中的实际条目,调整之后该函数应该如下所示:
function removeNotification() {
notifications.value = notifications.value
.filter(i => toRaw(i) != notification);
}
简而言之,函数 toRaw 会返回 Proxy 下的实际实例,这样我们就可以直接对实例进行比较了。到这里,问题应该消失了吧?
不好意思,问题可能仍然存在,后面大家就知道为什么了。
直接使用 ID/Symbol
最简单也最直观的解决方案,就是在 notification 中添加一个 ID 或者 UUID。我们当然不想在每次代码调用通知时都生成一个 ID,比如 showNotification({ title: "Done!", type: "success" }),所以这里做如下调整:
type StoredNotification = Notification & {
__uuid: string;
};
const notifications = ref<StoredNotification[]>([]);
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
const stored = {
...notification,
__uuid: uuidv4(),
}
notifications.value.push(stored);
function removeNotification() {
notifications.value = notifications.value
.filter((inList) => inList.__uuid != stored.__uuid);
}
// ...
}
由于 JS 运行时环境是单线程的,我们不会将其发送到任何其他地方,所以这里只需要创建一个计数器并生成 ID,具体参考以下代码:
let _notificationId = 1;
function getNextNotificationId() {
const id = _notificationId++;
return `n-${id++}`;
}
// ...
const stored = {
...notification,
__uuid: getNextNotificationId(),
}
实际上,只要这里的 _uuid 不会被发送到其他地方,而且调用次数不超过 2⁵³次,那上述代码就没什么问题。如果非要改进,也可以加上带有递增值的日期时间戳。
如果担心 2⁵³这个最大安全整数值还不够用,可以采取以下方法:
function getNextNotificationId() {
const id = _notificationId++;
if (_notificationId > 1000000) _notificationId = 1;
return `n-${new Date().getTime()}-${id++}`;
}
到这里问题就解决了,但本文的重点不在于此。
使用“浅”响应
既然没有必要,为什么要使用“深”响应?说真的,我知道这很简单、性能也不错,但是……为什么要在非必要时使用“深”响应?
无需更改给定对象中的任何内容。我们可能需要显示通知的定义、一些相关标签,也许还涉及某些操作(函数),但这些都不会对内部造成任何影响。只需将 ref 直接替换成 shallowRef,就这么简单!
const notifications = shallowRef<Notification[]>([]);
现在 notifications.value 将返回源数组。但容易被大家忽略的是,如此一来该数组本身不再具有响应性,我们也无法调用.push,因为它不会触发任何效果。所以说如果我们用 shallowRef 直接替换 ref,结果就是条目只有在被移除出数组时才会更新,因为这时我们才会用新实例重新分配数组。我们需要把:
notifications.value.push(stored);
替换成:
notifications.value = [...notifications.value, stored];
这样,notifications.value 将返回一个包含普通对象的普通数组,保证我们可以用 == 安全进行比较。
下面我们总结一下前面这些内容,并稍做解释:
我们可以总结如下:
plain: {title: 'foo'}
deep: RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: Proxy(Object)}
deepValue: Proxy(Object) {title: 'foo'}
shallow: RefImpl {__v_isShallow: true, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: {…}}
shallowValue: {title: 'foo'}
现在来看以下代码:
const raw = { label: "foo" };
const deep = ref(raw);
const shallow = shallowRef(raw);
const wrappedShallow = shallowRef(deep);
const list = ref([deep.value]);
const res = {
compareRawToOriginal: toRaw(list.value[0]) == raw,
compareToRef: list.value[0] == deep.value,
compareRawToRef: toRaw(list.value[0]) == deep.value,
compareToShallow: toRaw(list.value[0]) == shallow.value,
compareToRawedRef: toRaw(list.value[0]) == toRaw(deep.value),
compareToShallowRef: list.value[0] == shallow,
compareToWrappedShallow: deep == wrappedShallow,
}
运行结果为:
{
"compareRawToOriginal": true,
"compareToRef": true,
"compareRawToRef": false,
"compareToShallow": true,
"compareToRawedRef": true,
"compareToShallowRef": false,
"compareToWrappedShallowRef": true
}
解释:
总结:
现在来看第二个条目 ,根据 shallowRef 的值或者直接根据 raw 值进行创建:
const list = ref([shallow.value]);
{
"compareRawToOriginal": true,
"compareToRef": true,
"compareRawToRef": false,
"compareToShallow": true,
"compareToRawedRef": true,
"compareToShallowRef": false
}
看起来平平无奇,所以这里我们只聊最重要的部分:
那又会怎样?
即使我们将列表的 ref 替换为 shallowRef,那么哪怕列表本身并非深响应式,只要以参数形式给定的值为响应式,则该列表也将包含响应式元素。
const notification = ref({ title: "foo" });
showNotification(notification.value);
被添加进数组中的值将是 Proxy,而非{title: ‘foo’}。好消息是 == 仍然能够正确完成比较,因为.value 返回的对象也会随之改变。但如果我们只在一侧执行 toRaw,则 == 将无法正确比较两个对象。
总 结
VUe 中的深响应式机制确实很棒,但也带来了不少值得我们小心警惕的陷阱。请大家再次牢记,在使用深响应式对象时,我们实际上一直在处理 Proxy、而非实际 JS 对象。
请尽量避免用 == 对响应式对象实例进行比较,如果确定必须这样做,也请保证操作正确——比如两侧都需要使用 toRaw。而更好的办法,应该是尝试添加唯一标识符、ID、UUID,或者使用可以安全比较的现有条目唯一原始值。如果对象是数据库中的条目,则很可能拥有唯一的 ID 或者 UUID(如果足够重要,可能还包含修改日期)。
千万不要直接使用 Ref 作为其他 Ref 的初始值。务必使用它的.value,或者通过 ToValue 或 ToRaw 获取正确的值,具体取决于大家对代码可调试性的需求。
方便的话尽量使用浅响应式,或者更确切地说:只在必要时使用深响应式。在大多数情况下,其实我们根本不需要深响应式。当然,通过编写 v-model=”form.name”来避免重写整个对象肯定是好事,但请想好有没有必要在一个只从后端接收数据的只读列表上使用响应式?
对于体量庞大的数组,我在实验渲染时成功实现了性能倍增。虽然 2 毫秒和 4 毫秒之间的差异可有可无,但 200 毫秒和 400 毫秒间的差异却相当明显。而且数据结构越是复杂(涉及大量嵌套对象和数组),这种性能差异就越大。
Vue 的响应式类型可谓乱七八糟,我们完全没必要非去避简就繁。而且只要一旦开始使用奇奇怪怪的机制,就需要更多奇奇怪怪的操作来善后。千万别在这条弯路上走得太远,及时回头方为正道。这里我就不讨论把 Ref 存储在其他 Ref 中的情况了,那容易让人脑袋爆炸。
太长不看:
最后提醒大家,本文内容只供各位参考。如果您明确知晓自己在做什么、能做到什么,那请随意发挥。技术大牛不需要指导意见的无谓束缚。
原文链接:
https://dev.to/razi91/vues-reactivity-is-a-trap-2jci