Better Vue performance with Selective Object Reuse

timeline view

Excessive component rendering is one of the reasons why the vue application can perform slow. In this article we review how to detect rendering, why it happens and how to fix the most complex cases using a small utility package selective-object-reuse.

There’re two repositories created for this article, one is for vue2, another one is for vue3:

Rendering detection

Component render is the process consisting on several steps. Render function can be defined explicitly, or it can be autogenerated on the compilation step for single file components with templates. Vue builds the dependency tree and runs render function when dependencies update. Render function provides virtual DOM that is compared to the current version and updates the real DOM if there’re any changes. Our goal is to avoid unnecessary render function calls.

Component rendering can be easily detected using vue devtools. Another way is to use updated hook. We’ll base our investigation on the following Dummy 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>

Dummy’s template compiles to the following render function:

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

Here’s the sandbox where we can check how different templates are converted to the corresponding rendering functions. Render function can be considered as computed: the dependency tree built on the dry run stage. If showProp is true, it depends on showProp and prop1 reactive variables. Otherwise it depends only on showProp1.

In the following examples we’ll use Dummy component with different props and check when it calls it’s render function.

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 the counter is changed, Dummy‘s render function should not run because <Dummy :prop1="{a: 1}" /> does not depend on the counter. This case we get the expected result, it works correctly in vue2 and in vue3.

Example 2 (-vue2, +vue3)

Now let’s check what happens when Dummy displays prop1. We’ll use show-prop1 flag for this:

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

Test for vue2 shows that now every counter update triggers Dummy‘s render. That’s how the root component render function looks:

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

This function depends on the counter reactive variable (it shows the value, hence it should run when counter changes). Inside the render function it creates a new {a: 1} object and sends it to Dummy. This object matches the previous one, but vue does not use expensive nested object prop comparison, new object means change and since it’s used in Dummy‘s render function, the last re-renders. Hovewer this code works correctly in vue3.

Example 3 (+vue2, -vue3)

Now let’s add a bit of dynamic to the prop1:

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

This works correctly in vue2 because prop1 is not used in Dummy‘s render function. But it fails in vue3. The last caches component props used in the template, but even if we wrap every prop into it’s own computed, every counter‘s update recreates a new {a: counter ? 1 : 0} object.

Example 4 (-vue2, -vue3)

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

This code triggers excessive re-rendering in vue2 by the same reason as example 2. It does not run correctly in vue3 by the same reason as example3.

The real-world example: arrays in props

I hope the previous examples clearly show that object-type props can trigger an unexpected rendering. But one may say that it’s simple to avoid: just send number a=1 instead of object {a: 1}. In this example we’ll show that using primitive-type only props is not always possible.

Let’s have 2 entities, users and tags, with many-to-many relationship. The task is to render the list of users and display user name and user assigned tags. Here’s our state:

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

That’s the getter that provides the data for the list in a convenient way:

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

Every time when state changes, it triggers getter to run, the last provides a new array consisting on a new objects, every object has tags property which is a new array.

Let’s start with displaying a user list with only one first user tag shown:

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

We are interested in what happens when a new user<->tag connection is created. This case it works correctly in vue2 and in vue3. A new connection triggers usersWithTags getter to run, but the :user=usersWithTags.user" and :tag=usersWithTags.tags[0] objects sent to UserWithFirstTag component are still unchanged.

Now let’s display all the user tags:

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

Here we pass the entire usersWithTags.tags array. Every time when usersWithTags getter runs, it builds a new array for every user entity. Intuitively, any new user<->tag connection should update only one UserWithTags row, but in practice all the rows are updated both in vue2 and in vue3. For example, if there’re 1000 items in the state, every new user<->tag connection triggers 1000 UserWithTags components to re-render.

excessive vue re-rendering that may cause performance issue
Every UserWithTags component is updated when only one user<->tag connection is created

There’re several ways to fix it:

  • Send :tags-json="JSON.stringify(usersWithTags.tags)" prop instead and use JSON.parse inside the target component. It’s heavy. It does not look nice. But it works without exception.
  • Consider every case separately and try to avoid passing object-type props. For example, instead of sending :tags (array of objects), we can send only the comma-separated string containing tag ids. Then we can split it back to array and map it with state.tags inside every row. This will help to avoid rendering when the new user<->tag relation is created. Hovewer, any tag rename will trigger state.tags to update, and since every row refers the the entire array, every row will be re-rendered when only one tag is renamed, even if the last is not related to any user at all.
  • It’s possible to create a separate tags array variable inside the component and smarterly update it using the watcher. By smart update we mean deep/smart comparison old vs new version and update the variable only if there’re changes.
  • Finally, it’s possible to construct new objects/arrays from the old ones using a simple function below.

Selective Object Reuse

Let’s update the getter. When the new result is generated, let’s store the link to the result object somewhere in the global scope. Then, during the next run we still have the link to the old object. It gives an ability to compare the newly generated object with the previous version.

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 on the equal properties (on the first level, without recursion), we replace back 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
}

Two functions above are exactly the content of selective-object-reuse package. That’s how to use it in vuex:

import SelectiveObjectReuse from 'selective-object-reuse'
const sor = new SelectiveObjectReuse()
export const getters = {
  usersWithTags: (state) => state.userIds.map(userId => ({
    id: userId,
    user: state.users[userId],
    tags: userTags
      .filter(userTag => userTag.userId === userId)
      .map(userTag => state.tags[userTag.tagId])
  })),
  usersWithTagsWrapped: (_, getters) => sor.wrap(getters.usersWithTags, 'usersWithTags')
}

The updated version of users + array of tags works correctly in vue2 and in vue3.

It’s simple to use this wrapper directly in the computed. Here’s an updated version of example 4 that works correctly in vue2 and vue3:

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

Cautions using Selective Object Reuse

Selective Object Reuse is an advanced technic that is very efficient in a very limited case. It shows the best in a huge heavy dynamic front-ends like kanban boards with thousand of items. For quite a while we did not have any better solution except massive usage of JSON.stringify for every non-primitive type prop. Now we have. Hovewer it would be incorrect to wrap everything. It has to be used with caution:

  • The wrapper is read-only, for computed and getters only. It’s incorrect to take a writeable object from the data, wrap it, and then update.
  • It works for primitive objects. In vue2 any computed wrapped into proxy. The wrapper should be applied before proxy, on the stage when a new object is being created.
  • It’s essential to manually clear up expired wrapped objects. There’s dispose method for that.

Liked an article? Share it with your friends.

Teamhood uses cookies, to personalize content, ads and analyze traffic. By continuing to browse or pressing "Accept" you agree to our Cookie Policy.