SoFunction
Updated on 2025-04-12

Detailed explanation of why index should not be used as key in Vue (diff algorithm)

Preface

What is the key in Vue used for? Why is index not recommended as key? I often hear about such problems. This article will take you to find out the principles.
In addition, the conclusion of this article is to destruction of performance based on special circumstances in which the order of sub-elements on the list will be exchanged or the sub-elements are deleted. Explain it in advance and the troll detour is made.

This article has been included in the Github repository. Welcome to Star:
/sl1673495/blogs/issues/39

Example

Take such a list as an example:

<ul>
 <li>1</li>
 <li>2</li>
</ul>

Then its vnode, that is, the virtual dom node, is probably like this.

{
 tag: 'ul',
 children: [
  { tag: 'li', children: [ { vnode: { text: '1' }}] },
  { tag: 'li', children: [ { vnode: { text: '2' }}] },
 ]
}

Suppose that after the update, we changed the order of the child nodes:

{
 tag: 'ul',
 children: [
+  { tag: 'li', children: [ { vnode: { text: '2' }}] },
+  { tag: 'li', children: [ { vnode: { text: '1' }}] },
 ]
}

Obviously, the children part here is the focus of our diff algorithm in this article (knocking the blackboard).

First, after the responsive data is updated, the callback function vm._update(vm._render()) of the rendering Watcher is triggered to drive view updates. vm._render() actually generates vnode, and vm._update will take the new vnode to trigger the __patch__ process.

We go directly to the patch process of ul, the vnode.

Compare whether the new and old nodes are the same type of node:

1. Not the same node:
If isSameNode is false, the old vnode is directly destroyed and the new vnode is rendered. This also explains why diff is a comparison of the same level.

2. It is the same node, and you should reuse the nodes as much as possible (all ul, enter 👈).

The patchVNode method under src/core/vdom/ will be called.

If the new vnode is a text vnode

Just call the browser's dom api and replace the text content directly with the node.

If the new vnode is not a literal vnode
If there is a new child and no old child

The instructions are to add children and addVnodes to add new child nodes.

If there is an old child but no new child

The instructions are to delete children, removeVnodes directly to delete old children.

If both new and old children exist (both exist the li child node list, enter 👈)

Then it is the core point that our diff algorithm wants to examine, that is, the diff process of new and old nodes.

pass

 // Old first node let oldStartIdx = 0
 // New first node let newStartIdx = 0
 // Old tail node let oldEndIdx =  - 1
 // New tail node let newEndIdx =  - 1

These variables point to the head and tail of the old node and the head and tail of the new node respectively.

According to these pointers, the two ends of the new and old nodes are constantly compared in a while loop until there are no nodes to compare.

Before talking about the comparison process, we need to talk about a more important function: sameVnode:

function sameVnode (a, b) {
 return (
   ===  && (
   (
     ===  &&
     ===  &&
    isDef() === isDef() &&
    sameInputType(a, b)
   )
  )
 )
}

It is a key function used to determine whether the node is available. You can see that to determine whether it is the sameVnode, the key passed to the node is the key.
Then we then enter the diff process. Each round is the same comparison. If one of them hits, we recursively enter the process of patchVnode for a single vnode (if there is a child in this vnode, then we will also come to the process of diff children):

  1. Comparison between the old first node and the new first node is done by the sameNode.
  2. Comparison between the old tail node and the new first node with sameNode
  3. Comparison of old first node and new tail node with sameNode
  4. Comparison between old tail node and new tail node using sameNode
  5. If the above logic does not match, then use the keys of all old child nodes to make a mapping table, and then use the keys of the new vnode to find the locations that can be reused in the old node.

Then constantly shrink the matching pointer internally until the pointer at one end of the new and old nodes meets (which means that the nodes at this end have been patched).
After the pointers meet, there are two more special situations:

  • There are new nodes that need to be joined. If oldStartIdx > oldEndIdx after the update is completed, it means that the old nodes have been patched, but there may be new nodes that have not been processed. Then we will determine whether to add new child nodes.
  • There are old nodes that need to be deleted. If the new node is patched first, then the logic of newStartIdx > newEndIdx will be followed, and the redundant old child nodes will be deleted.

Why not use index as key?

Node reverse scene

Suppose we have a piece of code like this:

  &lt;div &gt;
   &lt;ul&gt;
    &lt;item
     :key="index"
     v-for="(num, index) in nums"
     :num="num"
     :class="`item${num}`"
    &gt;&lt;/item&gt;
   &lt;/ul&gt;
   &lt;button @click="change"&gt;Change&lt;/button&gt;
  &lt;/div&gt;
  &lt;script src="./"&gt;&lt;/script&gt;
  &lt;script&gt;
   var vm = new Vue({
    name: "parent",
    el: "#app",
    data: {
     nums: [1, 2, 3]
    },
    methods: {
     change() {
      ();
     }
    },
    components: {
     item: {
      props: ["num"],
      template: `
          &lt;div&gt;
            {{num}}
          &lt;/div&gt;
        `,
      name: "child"
     }
    }
   });
  &lt;/script&gt;

It is actually a very simple list component, rendering three numbers 1 2 3. Let's use index as key to track its updates.

Next, we only focus on the update of the item list node. When the first rendering, the rough expression of our virtual node list oldChildren is as follows:

[
 {
  tag: "item",
  key: 0,
  props: {
   num: 1
  }
 },
 {
  tag: "item",
  key: 1,
  props: {
   num: 2
  }
 },
 {
  tag: "item",
  key: 2,
  props: {
   num: 3
  }
 }
];

When we click the button, we will reverse the array. Then the newChildren list we generate at this time is like this:

[
 {
  tag: "item",
  key: 0,
  props: {
+   num: 3
  }
 },
 {
  tag: "item",
  key: 1,
  props: {
+   num: 2
  }
 },
 {
  tag: "item",
  key: 2,
  props: {
+   num: 1
  }
 }
];

Did you find any problems? The order of keys has not changed, and the passed value has completely changed. What problem will this cause?

According to the most reasonable logic, the old first vnode should be completely reused the new third vnode, because they should be the same vnode, and naturally all attributes are the same.

However, during the diff process of child nodes, the old first node and the new first node will be compared with the sameNode. This step hits the logic, because the keys of the first nodes of the old and new are now 0.

Then patchVnode operations on the first vnode in the old node and the first vnode in the new node.

What will happen? I can list it roughly for you:

First, as in my previous articleHow to trigger re-rendering of props update? As mentioned in this article, when patchVnode is performed, we will check whether props have changed. If so, we will update the responsive value through logic such as _props.num = 3, trigger , trigger re-rendering of subcomponent views, and other heavy logic.

Then, the following hooks will be triggered. Assuming that some dom attributes, class names, styles, and directives are defined on our component, they will be fully updated.

  1. updateAttrs
  2. updateClass
  3. updateDOMListeners
  4. updateDOMProps
  5. updateStyle
  6. updateDirectives

All these heavyweight operations (Isn’t one of the purposes of the invention of virtual dom to reduce the operations of real dom?) can be avoided by directly reusing the third vnode, because we are lazy to write index as key, which causes all optimizations to fail.

Node deletion scenario

In addition, in addition to causing performance losses, it will also cause more serious errors in the scenario of deleting child nodes.

You can see this provided by sea_ljfdemo

Suppose we have a piece of code like this:

<body>
 <div >
  <ul>
   <li v-for="(value, index) in arr" :key="index">
    <test />
   </li>
  </ul>
  <button @click="handleDelete">delete</button>
 </div>
 </div>
</body>
<script>
 new Vue({
  name: "App",
  el: '#app',
  data() {
   return {
    arr: [1, 2, 3]
   };
  },
  methods: {
   handleDelete() {
    (0, 1);
   }
  },
  components: {
   test: {
    template: "<li>{{()}}</li>"
   }
  }
 })
</script>

Then the initial vnode list is:

[
 {
  tag: "li",
  key: 0,
  // Here, the subcomponent corresponds to the first one. Assume that the text of the subcomponent is 1 },
 {
  tag: "li",
  key: 1,
  // Here, the subcomponent corresponds to the second one. Assume that the text of the subcomponent is 2 },
 {
  tag: "li",
  key: 2,
  // Here, the subcomponent corresponds to the third one. Assume that the text of the subcomponent is 3 }
];

There is a detail to note, as mentioned in my previous postWhy is Vue's responsive update faster than React? , Vue does not care about the internal implementation of the subcomponent for component diff. It only depends on whether some properties passed to the subcomponent that you declare on the template are updated.

That is, the part that is on par with v-for. When judging the sameNode, it will only judge whether the key, tag, whether there is data (not caring about the specific internal value), whether it is an annotated node, and whether it is the same input type to determine whether this node can be reused.

&lt;li v-for="(value, index) in arr" :key="index"&gt; // The properties declared here &lt;test /&gt;
&lt;/li&gt;

With this pre-knowledge, let’s take a look at what the vnode list will look like after clicking to delete the child elements.

[
 // The first one was deleted {
  tag: "li",
  key: 0,
  // Here, the previous round of subcomponent corresponds to the second one. Assume that the text of the subcomponent is 2 },
 {
  tag: "li",
  key: 1,
  // Here, the subcomponent corresponds to the third one. Assume that the text of the subcomponent is 3 },
];

Although we know clearly in the comments that the first vnode has been deleted, for Vue, it cannot sense what kind of implementation is in the child component (it will not go deep into the child component to compare the text content). So how will Vue patch at this time?

Because the corresponding key uses index, it will

  1. The original first node text: 1 is directly reused.
  2. The original second node text: 2 is directly multiplexed.
  3. Then I found that one missing in the new node, and I directly threw away the additional third node text: 3.

So far, we should have deleted the text: 1 node, and then reused the text: 2. Text: 3 nodes, which turned out to be the wrong way to delete the text: 3 nodes.

Why not use random numbers as keys?

<item
 :key="()"
 v-for="(num, index) in nums"
 :num="num"
 :class="`item${num}`"
/>

Actually, I have heard a saying that since the official requests a unique key, can we use () as a key to be lazy? This is a very terrible idea, let's see what happens.

First of all, oldVnode looks like this:

[
 {
  tag: "item",
  key: 0.6330715699108844,
  props: {
   num: 1
  }
 },
 {
  tag: "item",
  key: 0.25104533240710514,
  props: {
   num: 2
  }
 },
 {
  tag: "item",
  key: 0.4114769152411637,
  props: {
   num: 3
  }
 }
];

After the update is:

[
 {
  tag: "item",
+  key: 0.11046018699748683,
  props: {
+   num: 3
  }
 },
 {
  tag: "item",
+  key: 0.8549799545696619,
  props: {
+   num: 2
  }
 },
 {
  tag: "item",
+  key: 0.18674467938937478,
  props: {
+   num: 1
  }
 }
];

As you can see, the key has become a completely new 3 random numbers.

As mentioned above, if the head and tail comparison of diff child nodes does not hit, it will enter the detailed comparison process of key. Simply put, it is to use the key -> index of the old node to create a map mapping table, and then use the key of the new node to match. If it is not found, the createElm method will be called to re-create a new node.

The specific code is here:

// Create the key -> index mapping table for the old nodeoldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);

// Find index that can be reused in the mapping tableidxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// It must not be found because the key of the new node is randomly generated.if (isUndef(idxInOld)) {
 // Create a real child node completely through vnode createElm();
}

In other words, our update process can be described as follows:

123 -> Recreate three subcomponents in the front -> 321123 -> Delete and destroy the next three subcomponents in the front -> 321.
Have you found a problem? This is a devastating disaster. The cost of creating new components and destroying components you know... It was originally an update that could be completed just by moving the component, but it was destroyed by us.

Summarize

After such a journey, the huge process of diff is over.

What have we gained?

  1. Use the component's unique id (usually returned by the backend) as its key. If there is really no, you can create a key for the list through some rules when you get the list, and ensure that the key remains stable throughout the entire life cycle of the component.
  2. If your list order will change, don't use index as key, it is basically no different from not writing, because no matter how the order of your array is reversed, index is arranged in this way 0, 1, 2, which causes Vue to reuse the wrong old child nodes and do a lot of extra work. Try not to use the list order as it remains the same as it may mislead newcomers.
  3. Never use random numbers as keys, otherwise all old nodes will be deleted and new nodes will be recreated, and your boss will be pissed to death by you.

This is the article about this detailed explanation of why you should not use index as key (diff algorithm) in Vue. For more related content, please search for my previous articles or continue browsing the related articles below. I hope you will support me in the future!