Vue Performance Tips (Part 1)

Kanban board OKR template

We put a lot of effort into the Vue.js front-end performance optimization. In this article, we share some of the beginner vue performance tips and guidelines that can be used in any vue2/vue3 application. We have been using such approach for a long time in production on Teamhood.com.

vue performance tips

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

1. Avoid using object watchers

Well, in 1 case out of 100 watching objects is essential, but it’s very advanced topic we’ll consider further. In general, watching objects is a bad practice when it’s related to performance. Here’s an example. Let’s consider some items placed into the vuex store. Allow user to select multiple items, and use some selectedItemsIds array for storing the data:

export const state = () => ({
  items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }, { id: 3, name: 'Item 3' }],
  selectedItemIds: [1, 2]
})
export const getters = {
  extendedItems (state) => state.items.map(item => ({
    ...item,
    isSelected: state.selectedItemIds.includes(item.id)
  }))
}

Suppose, items can be reordered, and the new order has to be sent to the back-end:

export default {
  computed: {
    extendedItems () { return this.$store.getters.extendedItems },
    itemsOrder () { return this.extendedItems.map(item => item.id) }
  },
  watch: {
    itemsOrder (value) {
      axios.post('/setItemsOrder', { itemsOrder: value })
    }
  }
}

itemsOrder computed provides an array, a non-primitive type. Then we add a watcher on this non-primitive. And here’s the issue: itemsOrder watcher trigger not only when it should (when the items are reordered) but also when any item is being selected:

This happens because every time the item is selected, extendedItems getter builds a new array consisting on new objects, this triggers itemsOrder computed to build a new array and despite the result is an array with the same values in the same order, [1,2,3] != [1,2,3] in JavaScript. This triggers watcher to run.

The solution is simple – instead of watching itemsOrder array we have to prepare another computed that provides a primive type (string on number) and watch it instead:

export default {
  computed: {
    extendedItems () { return this.$store.getters.extendedItems },
    itemsOrder () { return this.extendedItems.map(item => item.id) },
    itemsOrderTrigger () { return this.itemsOrder.join(',') }
  },
  watch: {
    itemsOrderTrigger () {
      axios.post('/setItemsOrder', { itemsOrder: this.itemsOrder })
    }
  }
}

When we need to watch object, we can use JSON.stringify and watch a string, it’s much more predictable. Using deep modifier is even worse from performance pov. Usually, it a sign that the developer just want a watcher to run but does not care if it runs multiple times instead of one.

Here’s the demo for vue2 and vue3.

2. Restrict reactivity with Object.freeze

This technic shows the best in vue2 application. Sometimes vuex contains large objects that rarely change. A good example could be google address autocomplete – a user searches for an address, and the response contains a list of objects that are stored in vuex for future use. Every object contains geolocation data, full/short name, state and other properties that we read, but don’t modify. By default vue recursively converts every property into the reactive object using Proxy. This is quite memory consuming. Sometimes it’s better to lose the reactivity of individual properties and save memory instead. It can be done using Object.freeze – vue does not iterate over the object properties if it’s frozen:

// Instead of
state: () => ({ items: [] }),
mutations: {
  setItems (state, items) {
    state.items = items
  },
  updateItemTitle (state, { id, title }) {
    const item = state.items.find(item => item.id === id)
    if (item) {
      item.title = title
    }
  }
}
// Do this
state: () => ({ items: [] }),
mutations: {
  setItems (state, items) {
    state.items = items.map(item => Object.freeze(item))
  },
  updateItemTitle (state, { id, title }) {
    const itemIndex = state.items.findIndex(item => item.id === id)
    if (itemIndex !== -1) {
      // It's not possible to update item.title directly - the entire object has to be replaced
      const newItem = {
        ..state.items[itemIndex],
        title
      }
      state.items.splice(itemIndex, 1, Object.freeze(newItem))
    }
  }
}

Using this only optimization helped to decrease the memory usage by 2 times on TeamHood.com. However, it’s not very efficient in vue3, since the reactivity model is different there.

Screenshot from 2022 02 22 01 13 30 2
Regular vs frozen memory consumption in vue2

3. Avoid functional getters

Sometimes it’s overlooked in the documentation. Functional (method-style) getters are not cached. This code will run state.files.filter every time when it’s called:

getters: {
  filesCountByItemId: (state) => (itemId) => state.files.filter(file => file.itemId === itemId).length
}

Suppose we have to display the item list consisting on N items where every item should show the number of item files, and there’re M files in total. If we use the functional getter above, it will iterate over the state.files for every item doing NxM iterations in total. In this case, it’s much cheaper to use an object map getter that will run over M iterations once and cache the result:

getters: {
  filesCountByItemIds: (state) => state.files.reduce((out, file) => {
    out[file.itemId] = (out[file.itemId] || 0) + 1
    return out
  }, {})
}

Here’s the demo for vue2 and vue3.

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.