5 Advanced Tips for Vue Performance

Over the last year, I’ve been working mainly on TeamHood.com. We put a lot of effort into the optimization of its Vue front-end. This is because our screens don’t have pagination, and some clients have over a thousand cards shown on one kanban/Gantt board.
In this article, we will review some of the tips, tricks, and guidelines related to reducing unnecessary re-rendering and memory optimization that can be applied to any vue2/vue3 application.

There are two repos created especially for this article: one for vue2, another one for vue3:
- https://github.com/Kasheftin/vue-rerendering-optimization — vue2 source code
- https://kasheftin.github.io/vue-rerendering-optimization/#/ — vue2 demo
- https://github.com/Kasheftin/vue3-performance-tips — vue3 source code
- https://kasheftin.github.io/vue3-performance-tips/#/ — vue3 demo
(Deep) Object Watchers
The rule is simple — do not use a deep modifier, and do not watch non-primitive-type variables at all. Let’s consider some items array in vuex store, where each item can be checked. Let’s keep isChecked
client-related property separately. Assume there’s the getter that combines item data and isChecked
prop:
Suppose, items can be reordered, and the new order has to be sent to the back-end:
Then checking/unchecking item will incorrectly trigger itemIds
watcher to run. That happens because every time item is checked the getter will construct a new object for each item, this will trigger itemIds computed to rebuild a new array, and despite this array has the same values in the same order, [1, 2, 3] !== [1, 2, 3]
in javascript. The watcher will run.
The solution is to avoid using watchers for object-type variables. Every time we need some non-trivial watcher to run we should construct a separate primitive-type computed especially for that case. For example, if we need to watch an array of items with{id, name, userId}
properties, we can watch the following string:
Obviously, the more precise condition for a watcher to run gives the more precise trigger. From that point of view, deep watcher is even worse than usual object watcher. Using deep modifier is a clear sign that the developer does not care about objects he is watching.
Restricting reactivity using Object.freeze
This tip is very efficient for vue2 application. Using that only thing reduced the memory usage by 50% on TeamHood.com. Sometimes vuex contains a lot of rarely changing data, especially if api responses cache is stored there (that’s more relevant to ssr applications). By default vue recursively observes every object property, the last can be memory-consuming. Sometimes it’s better to loose each object property reactivity and save some memory instead:
Here are examples for vue2 and vue3. Vue3 has the different reactivity system, the effect of that optimization is less relevant there. Also it’s very promising that the overall memory usage is much lower in vue3 comparing to vue2 (it’s 80mb for vue2 and 15mb for vue3 examples above). Sadly, vuex4 has some issues with clearing up (you can check it on the provided example or here), but I hope it will be fixed soon.
Functional Getters
Sometimes it’s overlooked in the documentation. Functional getters are not cached. This code will run state.items.find
every time it’s called:
This code will build itemsByIds
object the first time it’s called, and then will reuse it:
Component Distribution
Component is the core feature of the entire vue ecosystem. Understanding component’s life cycle and update rules is essential for building an efficient application. Usually when we start working with vue, we use some common sense about how to distribute the code over the components. For example, if there’s a list of repeating entries, then probably it’s good to move each entry-related code to the separate component.
Besides of that, the component distribution provides a powerful mechanism giving the exact control of what the granularity of the update is. It’s a core performance-related feature. Let’s consider this terrible code:
That’s the demo: vue2, vue3. Try to rename, check or reorder items. Any update, targeted to one item only, causes any other item in the list to re-render. The reason is that <Item>
component refers to the extendedItemsByIds
object, and the last is rebuilt when any item any property changes.
Every vue component is a function that provides some virtual DOM and caches the result depending on it’s arguments. The argument list is determined on the dry-run stage and consists on props and some variables in $store. If the argument is an object that is rebuilt after update, caching does not work.
Our initial store structure is bad. We started using normalizr approach, separated item and isChecked prop, but we have not finished. Our extendedItems
getter is not good as well — it copies item properties instead of just referring to the initial item object. Let’s fix that:
This code works correctly for renaming an item (check vue2, vue3 examples), but checking/unchecking item still causes every item to re-render. And, surprisingly, item reordering works in vue2 but not in vue3.
The last is a breaking change, it’s intended behavior, but it’s not documented. The reason is referring to the scope variable (id) in the event handlers @set-checked
and @rename
. In vue3 a scope variable in an event handler cannot be cached, which means that every update will create a new event function, which will cause the component to update. Luckily, it’s easy to fix, it’s enough to send already prepared event that contains scope variable (id in our case) by itself. Check this example4p3 for vue3.
Upd. 2020–02–25: One more way to fix rerendering caused by referring to a scope variable in event handler is to specify all the events the component can emit in the new vue3 property called emits
. Our ItemWithRenameById2
component can emit 2 events — @set-checked and @rename — if we add both likeemits: ['set-checked', 'rename']
, item reordering starts working correctly. Here’s the example4p3-with-emits for vue3.
The last thing to fix is isChecked
computed — currently it refers to the entire $store.state.checkedIds
array (it tries to find a value there). Checking an item changes that array, that why every <Item>
has to repeat the search. It can be fixed by sending isChecked
as a boolean prop to every <Item>
component instead of searching for the value inside:
Here are the examples of the final correctly working solution for vue2 and vue3.
IntersectionObserver vue directive
Sometimes DOM is large and slow by itself. We use several techniques to reduce DOM size. For example, the gantt chart calculates every item position and size, and it’s easy to skip items outside of the viewport. There’s one easy IntersectionObserver trick for the case when sizes are not known. By the way, vuetify has v-intersect directive out of the box, but the last creates a separate IntersectionObserver instance for each use, that’s why it does not fit the case when a lot of nodes need to be observed.
Let’s consider this example (vue2, vue3) we are going to optimize. There’re 100 entries (only 10 are visible on the screen). Every entry contains a heavy svg that blinks every 500ms. We measure the latency between calculated and real blinks. Let’s create one IntersectionObserver instance and throw it to every node we need to observe using that simple directive:
Now we know which entries are not visible, and we need to simplify them. The good question is how to do that. For example, we can define some variable in vue and replace a heavy part with some simplified placeholder. But it’s essential to understand that complex component initialization is heavy. It may happen that the page will lag during a fast scrolling because the last triggers too many heavy initializations. Practical experiments say that toggling on a css level is fast and very light. We can just use css display: none for each heavy svg outside of the viewport, and it will increase the performance:
Links
- https://github.com/Kasheftin/vue-rerendering-optimization — source code for vue2
- https://github.com/Kasheftin/vue3-performance-tips — source code for vue3