Improving vue performance with selective-object-reuse

One of the reasons for the lag in a vue application is over-rendering of components. We figure out what this is usually associated with in vue2 and vue3, and then we apply one simple technique that cures all these cases and not only them. This technique has been working well in production for half a year.

The examples for this article are collected in two repositories (one for vue2, another for vue3), with identical source code.

Revealing rendering

Actually, rendering a component is a call to the render function, which renders a virtual dom. For single-file components with a template, the render function is generated at compile time. The updated hook is called every time it renders. The easiest way to track the rendering is to use this hook. Let’s start testing with the example of such a component:

<template>
  <div>
    Dummy: {{ showProp1 ? prop1 : 'prop1 is hidden' }}
  </div>
</template>

<script>
export default {
  props: {
    prop1: Object,
    showProp1: Boolean
  },
  updated () {
    console.log('Dummy updated')
  }
}
</script>

This component’s template is compiled into a render function like this:

render(h) {
  return h('div', [
    `Dummy: #{this.showProp1 ? this.prop1 : 'prop1 is hidden'}`
  ])
}

Here you can play with the online compiler. In vue2, this function works like computed. The dry run phase defines a dependency tree. If showProp1 = true, rendering starts when showProp1 and prop1 change, otherwise it does not depend on prop1. For vue3, the situation is slightly different, rendering is triggered when prop1 changes even if showProp1 = false.

Example 1 (+ vue2, + vue3)

<template>
  <div>
    <button @click="counter++">
      {{ counter }}
    </button>
    <Dummy :prop1="{a: 1}" />
  </div>
</template>

<script>
export default {
  data () {
    return {
      counter: 0
    }
  }
}
</script>

Intuition says that when counter changes, the Dummy component should not update, because <Dummy :prop1="{a: 1}" /> does not depend on counter. In this case, this is so, in vue2 and vue3

Example 2 (-vue2, + vue3)

Now let prop1 be drawn in Dummy, for this we add the show-prop1 flag:

<Dummy :prop1="{a: 1}" show-prop1 />

Test for vue2 shows that now every change to counter causes the Dummy to be rendered. The render function of this component looks like this:

render(h) {
  return h('div', {}, [
    h('button', {on: {click: ...}}, [this.counter]),
    h(Dummy, {props: {prop1: {a: 1}, showProp1: true}})
  ])
}

This function is run when counter changes in order to draw the new value on the button. This creates and sends a new object {a: 1} to the Dummy component. It is the same as the old one, but Dummy does not compare objects element by element. {a: 1}! == {a: 1}, Dummy rendering now depends on prop1, so Dummy starts too. This example works in vue3 correctly

Example 3 (+ vue2, -vue3)

Let’s add some dynamics to prop1:

<Dummy :prop1="{a: counter ? 1 : 0}" />

As in the first example, vue2 is working correctlybecause prop1 is not used in the Dummy render function. However now messes up vue3… Even if you wrap every property sent to the Dummy into its own computed, changing counter re-creates the {a: counter? 1: 0}, rendering starts.

Example 4 (-vue2, -vue3)

<Dummy :prop1="{a: counter ? 1 : 0}" show-prop1 />

Doesn’t work correctly in vue2 for the same reason as example 2. Doesn’t work correctly in vue3 for the same reason as example 3.

Example 5: Arrays in props

Hope the previous examples explain the situation well. But they are too synthetic, one might say the idiot himself, there is nothing to pass parameters to objects created on the fly in the template. Let’s look at a real-world example. Users are associated with tags through many-to-many. We want to display a list of users and sign their tags for everyone. Let’s keep everything in a nice normalized form:

export interface IState {
  userIds: string[]
  users: { [key: string]: IUser },
  tags: { [key: string]: ITag },
  userTags: {userId: string; tagId: string}[]
}

Let’s write a getter that collects everything as it should:

export const getters = {
  usersWithTags (state) {
    return state.userIds.map(userId => ({
      id: userId,
      user: state.users[userId],
      tags: userTags
        .filter(userTag => userTag.userId === userId)
        .map(userTag => state.tags[userTag.tagId])
    }))
  }
}

Each time the getter runs, it creates a new array of new objects that have a tags property, which is a new array.

First, we will display a list of users, where each will show only the first tag:

<UserWithFirstTag
  v-for="usersWithTags in usersWithTags"
  :key="usersWithTags.id"
  :user="usersWithTags.user"
  :tag="usersWithTags.tags[0]"
/>

This works correctly in vue2 and vue3… When a new link is created between the user and the tag, the getter is rebuilt, but its parts that go into the UserWithFirstTag component are the same objects as before. Therefore, unnecessary rendering of the UserWithFirstTag components does not occur.

Now let us display a list of all the tags for each user, that is, send an array to the component, the same one that is new every time you rebuild usersWithTags:

<UserWithTags
  v-for="usersWithTags in usersWithTags"
  :key="usersWithTags.id"
  :user="usersWithTags.user"
  :tags="usersWithTags.tags"
/>

Now, when creating a new connection user <-> tag, all UserWithTags components are rendered, in vue2 and in vue3… How to fix it:

  1. JSON.stringify is our everything. It doesn’t look good, but it always works. Until recently, critical parts of the system were literally full of JSON.stringify / parse. Some getters gave stringify right away, because it was known that everything would be converted to primitive types anyway.

  2. Cast to primitive types, but you need to be careful here. For example, you can send the string userTags.filter(userTag => userTag.userId === userId).join(','), and then parse the string into UsersWithTags and extract tags from state.tags. Then there will be no extra rendering when creating a new user <-> tag link. However, then any change to any tag (renamed a tag, added a new one, etc.) will cause rendering of all UsersWithTags even if the changed tag is not used in it. The reason is the same – a reference to state.tags in the render function of the UsersWithTags component.

  3. You can pass <: first-tag = ..,: second-tag = "..,: third-tag =" ..>, but that’s just ugly.

  4. You can store a copy of the array in a variable and add a watcher to the getter that will compare the old and new array and update the copy only if there are changes. The downside is that for each object parameter you need to create its own variable and write a lot of code.

  5. And finally, you can universally assemble new objects from pieces of old ones with a couple of simple functions.

Selective Object Reuse

Let’s save a reference to it in the getter before giving the result (new object). Then the next time you call the same getter, you can take the old object from the saved link and compare. In our case, we will compare two arrays of the form {id: string; user: IUser, tags: ITag[]}[]… Let’s say a new user <-> tag association has been created. Then, when comparing the old and new getters, user and tag will be the same objects as before, and they do not need to be compared element by element (i.e., this is faster than a completely recursive comparison of the isEqual type from lodash):

function entriesAreEqual (entry1, entry2) {
  if (entry1 === entry2) {
    return true
  }
  if (!isObject(entry1) || !isObject(entry2)) {
    return false
  }
  const keys1 = Object.keys(entry1)
  const keys2 = Object.keys(entry2)
  if (keys1.length !== keys2.length) {
    return false
  }
  return !keys1.some((key1) => {
    if (!Object.prototype.hasOwnProperty.call(entry2, key1)) {
      return true
    }
    return !entriesAreEqual(entry1[key1], entry2[key1])
  })
}

If the objects are different, but consist of the same elements (at the first level, without recursion, it does not matter whether they are the same object, or are equal as primitive types), then we replace the new object with the old one:

function updateEntry (newEntry, oldEntry) {
  if (newEntry !== oldEntry && isObject(newEntry) && isObject(oldEntry)) {
    const keys = Object.keys(newEntry)
    keys.forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(oldEntry, key) && isObject(newEntry[key]) && isObject(oldEntry[key])) {
        if (entriesAreEqual(newEntry[key], oldEntry[key])) {
          newEntry[key] = oldEntry[key]
        } else {
          updateEntry(newEntry[key], oldEntry[key])
        }
      }
    })
  }
  return newEntry
}

It remains to wrap these functions in some class, and you get selective-object-reuse

If now we take the getter from the 5th example and wrap the result in SelectiveObjectReuse, the extra rendering will disappear in vue2 and vue3

You can also use a wrapper directly in a template or in a computed, for example, from example 4:

<template>
  <div>
    <button @click="counter++">
      {{ counter }}
    </button>
    <Dummy :prop1="sor.wrap({a: counter ? 1 : 0})" show-prop1 />
  </div>
</template>

<script>
import SelectiveObjectReuse from 'selective-object-reuse'

export default {
  data () {
    return {
      counter: 0,
      sor: new SelectiveObjectReuse()
    }
  }
}
</script>

Works correctly in vue2 and vue3

Cons of SelectiveObjectReuse

SelectiveObjectReuse is an advanced technique that has worked well for a very narrow task. Actually, for a while, I had no other way to avoid over-rendering other than the ugly JSON.stringify of everything and everything. However, this wrapper cannot be applied thoughtlessly, it would be wrong to wrap all object-like properties in vue at the engine level.

  1. The wrapper only works on read, i.e. applies to computed and getters. You don’t need to take an object directly from data (), wrap it, and then change its properties.

  2. The wrapper works for primitive objects. For example vue3 wraps computed in a proxy. You need to apply the wrapper before the proxy.

  3. You need to make sure that there are no references to expired objects in memory. For this, the library has a dispose method.

Similar Posts

Leave a Reply