laitimes

Vue's reactive mechanics are a "pit"

author:InfoQ

作者 | jkonieczny

Translator | Nuka-Cola

策划 | Tina

Vue's reactivity reactivity mechanism is really good, but it has a "small" drawback: it messes up references. Everything seemed so good that even TypeScript said it was fine, but suddenly it collapsed.

I'm not talking about nested references with forced input, which are significantly more complex and confusing. Only a master who knows everything well can solve this kind of problem, so I will not mention it in this article for the time being.

Even in everyday use, if you don't understand how it works, reactivity can cause all sorts of crazy problems.

A simple array

Let's take a look at the following code:

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;
}

           

That's all good, right? If autoclose isn't zero, it automatically removes the notification from the list. We can also call the returned function to manually close it. The code is clear and beautiful, and even if it's called twice, removeNotification works just fine, just removing the exact same thing as the element we pushed to the array.

Okay, but it doesn't meet responsive standards. Now look at the following code:

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;
}

           

It's the same thing, so it should work as well, right? We're trying to have the array iterate over the entries and filter out the same entries as the ones we've added. But this is not the case. The reason for this is not complicated: the notification object we receive as a parameter is likely to be a normal JS object, while the entry in the array is a proxy.

So how to deal with it?

Use Vue's API

If we don't want to modify the object for some reason, we can use toRaw to get the actual entries in the array, and the function should look like this:

function removeNotification() {
  notifications.value = notifications.value
    .filter(i => toRaw(i) != notification);
}

           

In a nutshell, the function toRaw returns the actual instance under the Proxy, so we can compare the instances directly. At this point, the problem should go away, right?

I'm sorry, but the problem may still exist, and you'll know why later.

直接使用 ID/Symbol

The simplest and most intuitive solution is to add an ID or UUID to the notification. We certainly don't want to generate an ID every time the code calls a notification, such as showNotification({ title: "Done!", type: "success" }), so here's the following adjustment:

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);
  }
  // ...
}

           

Since the JS runtime environment is single-threaded, we don't send it anywhere else, so we just need to create a counter and generate the ID, as shown in the following code:

let _notificationId = 1;
function getNextNotificationId() {
  const id = _notificationId++;
  return `n-${id++}`;
}
// ...
const stored = {
 ...notification,
 __uuid: getNextNotificationId(),
}

           

Actually, as long as the _uuid here isn't sent elsewhere and doesn't get called more than 2⁵³ times, then there's nothing wrong with the above code. If you have to improve, you can also add a date and time stamp with an incremental value.

If you are concerned that the maximum safe integer value of 2⁵³ is not enough, you can do the following:

function getNextNotificationId() {
  const id = _notificationId++;
  if (_notificationId > 1000000) _notificationId = 1;
  return `n-${new Date().getTime()}-${id++}`;
}

           

That's the problem, but that's not the point of this article.

Use a "shallow" response

Why use a "deep" response when it's not necessary? Seriously, I know it's simple and performs well, but ...... Why use a "deep" response when it's not necessary?

You don't need to change anything in a given object. We may need to show the definition of the notification, some related labels, and maybe some actions (functions), but none of this will have any internal effects. Just replace ref directly with shallowRef, it's that simple!

const notifications = shallowRef<Notification[]>([]);

           

Now notifications.value will return the source array. But what is easy to overlook is that the array itself is no longer responsive, and we can't call .push because it doesn't trigger any effects. So if we replace ref with shallowRef directly, the result is that the entry will only be updated when it is removed from the array, because then we will redistribute the array with a new instance. We need to put:

notifications.value.push(stored);

           

Replace with:

notifications.value = [...notifications.value, stored];

           

This way, notifications.value will return a normal array of normal objects, guaranteeing that we can safely compare with ==.

Let's summarize the previous ones and explain them a little:

  • A normal JS object - just a simple raw JS object without any packager, console.log will just output {title: 'foo'} and that's it.
  • The ref and shallowRef instances directly output an object of a class called RefImpl, which contains a field (or getter).value and some other private fields that we don't have to deal with.
  • ref's .value returns the same thing that would return reactive, i.e., a proxy that mimics a given value, so it will output Proxy(Object){title: 'foo'}. Each non-raw nested field is also a proxy.
  • shallowRef's .value returns that normal JS object. Again, only .value is responsive (more on this later) and doesn't involve nested fields.

We can summarize it as follows:

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'}

           

Now let's look at the following code:

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,
}

           

The result is:

{
  "compareRawToOriginal": true,
  "compareToRef": true,
  "compareRawToRef": false,
  "compareToShallow": true,
  "compareToRawedRef": true,
  "compareToShallowRef": false,
  "compareToWrappedShallowRef": true
}

           

Interpretation:

  • compareOriginal (toRaw(list.value[0]) == raw): toRaw(l.value[0]) 将返回与 raw 相同的内容:一个普通 JS 对象实例。 这也证实了我们之前的假设。
  • compareToRef (list.value[0] == deep.value): deep.value is a proxy, the same proxy to be used for this array, and there is no need to create an additional packager here. In addition, there is another mechanism here.
  • compareRawToRef (toRaw(list.value[0]) == deep.value): 我们是在将“rawed”原始对象与 Proxy 进行比较。 之前我们已经证明了 toRaw(l.value[0]) 与 raw 相同,因此它肯定不是 Proxy。
  • compareToShallow (toRaw(list.value[0]) == shallow.value): However, here we are comparing raw (returned via toRaw) to the value stored by shallowRef, which is not responsive, so Vue does not return any proxy here, but only that normal object, which is raw. As expected, there are no problems here.
  • 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): This is ...... For some reason, if you give a ref to shallowRef, it will only return that ref. If the source ref and the expected ref are both of the same type (light or dark), that's fine. But here...... But it's weird.

Summary:

  • 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 )

Now let's look at the second entry, which is created based on the value of shallowRef or directly from the raw value:

const list = ref([shallow.value]);

           

Copy the code

{
  "compareRawToOriginal": true,
  "compareToRef": true,
  "compareRawToRef": false,
  "compareToShallow": true,
  "compareToRawedRef": true,
  "compareToShallowRef": false
}

           

Copy the code

It may seem unremarkable, so here we will talk about only the most important parts:

  • compareToRef (list.value[0] == deep.value): We compare the proxy returned by the list with the .value of the ref created from the same source. Outcome...... Internally, Vue uses a WeakMap to store references to all reactives, so when a reactive is created, it checks to see if it has been duplicated and reused before. Because of this, two separate refs created from the same source will affect each other. These refs will all have the same .value.
  • compareRawToRef (toRaw(list.value[0]) == deep.value): 我们再交将普通对象与 RefImpl 进行比较。
  • compareToShallowRef (list.value[0] == shallow): Even if the entry is created from the value of shallowRef, the list is still "deep" responsive and returns a deeply responsive RefImpl - where all fields are responsive. So the left side of the comparison contains a proxy, while the right side is an instance.

So what?

Even if we replace the ref of the list with shallowRef, the list will contain a responsive element, even if the list itself is not deeply responsive, as long as the value given as a parameter is responsive.

const notification = ref({ title: "foo" });


showNotification(notification.value);

           

Copy the code

The value added to the array will be Proxy, not {title: 'foo'}. The good news is that == still does the comparison correctly, because the object returned by the .value will change as well. But if we only do toRaw on one side, == won't be able to compare the two objects correctly.

summary

The deep reactivity mechanics in VUe are great, but they also come with a lot of pitfalls that should be guarded against. Again, keep in mind that when using deep reactive objects, we're actually dealing with proxies instead of actual JS objects.

Try to avoid comparing reactive object instances with ==, and if you decide you have to, make sure you do it correctly - e.g. use toRaw on both sides. A better approach would be to try adding a unique identifier, ID, UUID, or using a unique raw value for an existing entry that can be safely compared. If the object is an entry in the database, it will most likely have a unique ID or UUID (and possibly a modified date if important enough).

Never use Ref as the initial value of other Refs. Be sure to use its .value, or get the correct value via ToValue or ToRaw, depending on your need for code debuggability.

Try to use shallow responsiveness when it's convenient, or rather: use deep responsiveness only when necessary. In most cases, we don't need to be deeply responsive at all. Of course, it's certainly a good thing to avoid rewriting the entire object by writing v-model="form.name", but please think about whether it's necessary to use reactive on a read-only list that only receives data from the backend?

For large arrays, I managed to multiply the performance when I experimented with rendering. While the difference between 2 ms and 4 ms is dispensable, the difference between 200 ms and 400 ms is quite significant. And the more complex the data structure (involving a large number of nested objects and arrays), the greater the performance difference.

Vue's reactive types are a mess, and there's no need to avoid simplification. And as soon as you start using weird mechanics, you'll need more weird actions to clean up the aftermath. Don't go too far on this detour, and turn back in time to be the right way. I'm not going to talk about storing refs in other refs here, because it's easy to blow your head up.

Original link: Vue's reactive mechanism is a "pit"_Architecture/Framework_InfoQ featured article