vue.jsrenderingvuejs3virtual-dom

Why vue3 unnecessary re-renders nodes in v-for?


Here's a small test I made to investigate unnecessary node re-rendering for lists in vue3 (vue2 has the same behavior): https://kasheftin.github.io/vue3-rerender/. That's the source code: https://github.com/Kasheftin/vue3-rerender/tree/master.

I'm trying to understand why vue re-renders already rendered nodes in v-for in some cases. I know (and will provide below) some technics to avoid re-rendering, but for me it's crucial to understand the theory.

For tests I added a dummy v-test directive that just logs when mounted/beforeUnmount hooks triggered.

Test 1

<div v-for="i in n" :key="i">
  <div>{{ i }}</div>
  <div v-test="log2">{{ log(i) }}</div>
</div>

Result: all the nodes re-rendered when n increases. Why? How to avoid that?

Test 2

Test2.vue:
<RerenderNumber v-for="i in n" :key="i" :i="i" />

RerenderNumber.vue:
<template>
  <div v-test="log2">{{ log() }}</div>
</template>

Result: It works correctly. Moving the inner content from test1 to a separate component fixes the issue. Why?

Test 3

<RerenderObject v-for="i in n" :key="i" :test="{ i: { i: { i } } }" />

Result: unnecessary re-rendering. It seems it's not allowed to constuct objects on the fly in cycle before sending it to some child component, probably because {} != {} in JavaScript.

Test 4

<template>
  <RerenderNumberStore v-for="item in items" :key="item.id" :item="item" />
</template>

<script>
export default {
  computed: {
    items () {
      return this.$store.state.items
    }
  },
  methods: {
    addItem () {
      this.$store.commit('addItem', { id: this.items.length, name: `Item ${this.items.length}` })
    }
  }
}
</script>

Here the simplest vuex store is in use. It works correctly - no unnecessary re-rendering despite item prop is an object.

Test 5

<RerenderNumberStore v-for="item in items" :key="item.id" :item="{ id: item.id, name: item.name }" />

The same as test 4, but item prop restructured - and we get unnecessary re-rendering.

Test 6

Test6.vue:
<RerenderNumberStoreById v-for="item in items" :key="item.id" :item-id="item.id" />

RerenderNumberStoreById.vue:
<template>
  <div v-test="log">{{ item.name }}</div>
</template>

<script>
export default {
  props: ['itemId'],
  computed: {
    item () { return this.$store.state.items.find(item => item.id === this.itemId) }
  }
}
</script>

Result: unnecessary re-rendering. Why? I Can not find any reason why the behavior differs from test 4. This one is less clear for me - the item computed is not changed in any way when the new item added to items array. It returns the SAME object. It has to be cached, matched to the previous value and do not trigger any update in the DOM.


Solution

  • Vue is a reactive system, so, to answer this question, one should understand how cacheable observables work and what their granularity is. So, please, bear with me.

    Imagine you have an expensive function, e.g.

    getCurrentTotal() { return state.x + state.y; }
    

    and it has no side effects, i.e. for the same x and y the result is exactly the same, and we never need to call it again, unless either value changes.

    To enable observing, you would come up with some wrapper like

    const state = reactive({x:1,y:2,z:3})
    

    This wrapper will create a map of observers:

    --- initial state ---
    x -> []
    y -> []
    z -> []
    

    (it doesn't matter where this map "lives" or in what form, there are many strategies)

    It will also create a cache of results.

    When your function is called for the first time (aka "dry run"), every access to the reactive state object is memorized, and the map of observers is updated to:

    --- after first run of getCurrentTotal() ---
    x -> [getCurrentTotal]
    y -> [getCurrentTotal]
    z -> []
    

    and the cache of results will get getCurrentTotal,{x:1, y:2} -> 3 (simplified).

    Now, if you do something like

    state.x++
    

    the setter for state.x will find it needs to run getCurrentTotal() again, because {x:2, y:2} is not in the cache, et voilĂ , you have an update.

    Now, TLDR:

    In your first example Test1, an observable function is the whole for-loop:

    observedRenderer1() {
       for i in n: 
         add or modify (if :key exists) a div and inside put all the stuff
    } 
    

    Note, it will be called on any change in n and will go through the whole loop. No shortcuts here.

    In your second example Test2,

    observedRenderer2() {
       for i in n: 
          callSomeOtherRenderer(i)
    } 
    

    Aha! The loop is still there. But now our unit of work is more granular. Reactive system checks its cache and doesn't call the renderers for RerenderNumber(1) or RenderNumber(2) if it already has these results.

    The reality is a bit more complex, Vue keeps a copy of all results in Virtual DOM (not to confuse with Shadow DOM!) where it keeps enough information to know shouldComponentUpdate or not. Yes, it would be possible to create a VNode in the virtual tree for every div in the loop iteration. But then for a dense table of 100x100 cells you would have 10k objects in your tree and, as a user of Vue, you will never be able to optimize it.

    While your question feels like a discovery of a bug, it is actually a powerful mechanism giving you exact control of what the granularity of your updates is. Memory/speed trade-off kind of thing.

    Test3 (or Test5) fails for a deeper reason but along the same lines: you are creating new objects every iteration and calling deep equals on them during re-rendering is too expensive in real life. Pass them as separate props like Test4 and you will be fine.

    Test 6 is easy to explain if you think that during dry run each item had to run over the whole collection of items, so, the dependency map of each rendered RerenderNumberStoreById consists of each and every item in the list.