• Vue的reactivity响应式机制隐藏的“坑”
  • 发布于 1周前
  • 32 热度
    0 评论
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 将返回一个包含普通对象的普通数组,保证我们可以用 == 安全进行比较。

下面我们总结一下前面这些内容,并稍做解释:普通 JS 对象——就是一个简单的原始 JS 对象,没有任何打包器,console.log 将只输出{title: ‘foo’},仅此而已。ref 与 shallowRef 实例会直接输出名为 RefImpl 的类的对象,其中包含一个字段(或者说 getter).value 和一些其他我们无需处理的私有字段。

ref 的.value 所返回的,就是会返回 reactive 的相同内容,即用于模仿给定值的 Proxy,因此它将输出 Proxy(Object){title: ‘foo’}。每个非原始嵌套字段也都是一个 Proxy。

shallowRef 的.value 返回该普通 JS 对象。同样的,这里只有.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
}
解释:compareOriginal (toRaw(list.value[0]) == raw): toRaw(l.value[0]) 将返回与 raw 相同的内容:一个普通 JS 对象实例。这也证实了我们之前的假设。

compareToRef (list.value[0] == deep.value): deep.value 是一个 Proxy,与该数组要使用的 proxy 相同,这里无需创建额外的打包器。此外,这里还存在另一种机制。

compareRawToRef (toRaw(list.value[0]) == deep.value): 我们是在将“rawed”原始对象与 Proxy 进行比较。之前我们已经证明了 toRaw(l.value[0]) 与 raw 相同,因此它肯定不是 Proxy。

compareToShallow (toRaw(list.value[0]) == shallow.value): 然而,这里我们将 raw(通过 toRaw 返回)与 shallowRef 存储的值进行比较,而后者并非响应式,因此 Vue 在这里不会返回任何 Proxy,而仅返回该普通对象,也就是 raw。跟预期一样,这里没有问题。

compareToRawedRef (toRaw(list.value[0]) == toRaw(deep.value)): 但如果我们将 toRaw(l.value[0]) 与 toRaw(deep.value) 进行比较,就会发现二者拥有相同的原始对象。总之,我们之前已经证明 l.value[0] 与 deep.value 是相同的。可 TypeScript 会将此标记为错误。

compareToShallowRef (list.value[0] == shallow): 明显为 false,因为 shallowRef 的 Proxy 不可能与 ref 的 Proxy 相同。compareToWrappedShallowRef (deep == wrappedShallow): 这是……什么玩意?出于某种原因,如果向 shallowRef 给定一个 ref,它只会返回该 ref。而如果源 ref 与预期 ref 均属于同一类型(浅或深),那就完全没问题。但这里……可就奇了怪了。

总结:
deep.value == list[0].value (一个内部 reactive)
shallow.value == raw (普通对象,没什么特别)
toRef(deep.value) == toRef(list[0].value) == raw == shallow.value (获取普通对象)
wrappedShallow == deep , 因此 wrappedShallow.value == deep.value (重用为该目标创建的 reactive )

现在来看第二个条目 ,根据 shallowRef 的值或者直接根据 raw 值进行创建:
const list = ref([shallow.value]);
{
  "compareRawToOriginal": true,
  "compareToRef": true,
  "compareRawToRef": false,
  "compareToShallow": true,
  "compareToRawedRef": true,
  "compareToShallowRef": false
}
看起来平平无奇,所以这里我们只聊最重要的部分:
compareToRef (list.value[0] == deep.value): 我们将列表返回的 Proxy 与根据同一来源创建的 ref 的.value 进行比较。结果……为 true?这怎么可能?Vue 在内部使用 WeakMap 来存储对所有 reactive 的引用,所以当创建一个 reactive 时,它会检查之前是否已经重复创建并进行重用。正因为如此,从同一来源创建的两个单独 ref 才会彼此产生影响。这些 ref 都将拥有相同的.value。

compareRawToRef (toRaw(list.value[0]) == deep.value): 我们再交将普通对象与 RefImpl 进行比较。
compareToShallowRef (list.value[0] == shallow): 即使条目是根据 shallowRef 的值创建而成,列表也仍为“深”响应式,且会返回深响应式 RefImpl——其中所有字段均为响应式。因此比较式左侧包含 Proxy,而右侧是一个实例。

那又会怎样?
即使我们将列表的 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 中的情况了,那容易让人脑袋爆炸。

太长不看:
别嵌套 Ref。使用值(myRef.value)来代替,但请注意其中可能包含 reactive,哪怕是从 shallowRef 获取也无法避免。

如果大家(出于某种原因)需要用 == 来比较对象实例,请使用 toRaw 以确保实际比较的是普通 JS 对象。只要可能,最好只比较原始唯一值,例如 ID 或者 UUID。

最后提醒大家,本文内容只供各位参考。如果您明确知晓自己在做什么、能做到什么,那请随意发挥。技术大牛不需要指导意见的无谓束缚。
用户评论