一个属性使vue失去响应式

850 阅读4分钟

1.起因

公司内部系统用户反馈页面无法操作,必须关闭页面重新打开才能解决。按道理业务代码只是做data设置和方法操作,不应该导致整个vue都失效。

2.定位问题

打开控制台,发现输出了 undefined (reading key) image.png

  1. 分析代码发现业务data数据里没有什么key的定义
  2. 推断可能是vue的vnode中的key设置问题
  3. 检查代码发有一个地方在重新绑定code的时候,设置了相同的值
  4. 初步推荐可能是绑定key的时设置了一样的值

3.验证问题 写个demo

1. 初始化重复key

  • 这里加入了一个"测试数据响应式"按钮查看当前响应式是否工作正常。
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.10/vue.min.js"></script>
</head>
<body>
    <div id="app">
        <h1>{{cnt}}<button @click="testReactive">测试数据响应式</button></h1>
        <ul>
            <li v-for="(item,index) in list" :key="item.id">{{item.id}}</li>
        </ul>
        <br>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: function () {
            return {
                cnt: 0,
                list: [
                    { id: 1 },
                    { id: 1 },
                    { id: 1 }
                ],
            }
        },
        methods: {
            testReactive() {
                this.cnt++
                console.log("xxxx")
            }
        },
    })
</script>

</html>

动画1234566677766600.gif 好家伙,页面加载无任何报错信息,稳稳的

2. 初始化重复key,再统一设置其他重复key

<body>
    <div id="app">
        <h1>{{cnt}}<button @click="testReactive">测试数据响应式</button></h1>
        <ul>
            <li v-for="(item,index) in list" :key="item.id">{{item.id}}</li>
        </ul>
        <button @click="change">改变list的所有id为200</button>
        <br>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: function () {
            return {
                cnt: 0,
                list: [
                    { id: 1 },
                    { id: 1 },
                    { id: 1 }
                ],
            }
        },
        methods: {
            change() {
                this.list.forEach(item => {
                    item.id = 200
                });
            },
            testReactive() {
                this.cnt++
                console.log("xxxx")
            }
        },
    })
</script> 

动画1234566677766600.gif 统一把所有key由1设置为200,看似很稳定吖

3.初始化不重复key,再统一设置其他重复key

<body>
    <div id="app">
        <h1>{{cnt}}<button @click="testReactive">测试数据响应式</button></h1>
        <ul>
            <li v-for="(item,index) in list" :key="item.id">{{item.id}}</li>
        </ul>
        <button @click="change">改变list的所有id为200</button>
        <br>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: function () {
            return {
                cnt: 0,
                list: [
                    { id: 1 },
                    { id: 2 },
                    { id: 3 }
                ],
            }
        },
        methods: {
            change() {
                this.list.forEach(item => {
                    item.id = 200
                });
            },
            testReactive() {
                this.cnt++
                console.log("xxxx")
            }
        },
    })
</script>

动画1234566677766600.gif 好家伙,也是我预期的效果。那问题来了,为什么会挂?

4.初始化不重复key,再统一设置其他重复key(这个新key是在原来初始化里存在的)

我们的把新key 统一设置为2

<script>
 change() {
        this.list.forEach(item => {
            item.id = 2
        });
       }, 
</script>

动画1234566677766600.gif 结果问题重现了,nnd藏的够隐蔽的 我们可以看到gif我们可以看到 后台输出了

目前得出的规律是原始是正常的唯一key,重新操作后,只要新key中包含原来的key,并且重复出现,就会导致vue失去响应式

5.特殊情况,只有两条数据时响应式正常

<script>
    new Vue({
        el: '#app',
        data: function () {
            return {
                cnt: 0,
                list: [
                    { id: 1 },
                    { id: 2 }, 
                ],
            }
        }, 
    })
</script>

动画1234566677766600.gif 晕~ 两条又没问题

4.分析问题

我们把vue.min.js 改成 vue.js

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.10/vue.js"></script>
</head>

<body>
    <div id="app">
        <h1>{{cnt}}<button @click="testReactive">测试数据响应式</button></h1>
        <ul>
            <li v-for="(item,index) in list" :key="item.id">{{item.id}}</li>
        </ul>
        <button @click="change">改变list的所有id为2</button>
        <br>
    </div>
</body>
<script>
    new Vue({
        el: '#app',
        data: function () {
            return {
                cnt: 0,
                list: [
                    { id: 1 },
                    { id: 2 },
                    { id: 3 }
                ],
            }
        },
        methods: {
            change() {
                this.list.forEach(item => {
                    item.id = 2
                });
            },
            testReactive() {
                this.cnt++
                console.log("xxxx")
            }
        },
    })
</script>

</html>

动画1234566677766600.gif

1.分析提示

可以看到vue已经做了提示

  1. 显示2次警告 [Vue warn]: Duplicate keys detected: '2'. This may cause an update error. 说有重复的key定义,将可能导致更新错误
  2. 代码执行错误TypeError: Cannot read properties of undefined (reading 'key') 具体代码异常在 at sameVnode (vue.js:5806:9)

2.分析源码

企业微信截图_16678691257395.png

这里发现a对象居然是undefined的 所以直接挂掉了,跟踪代码我们可以看到在updateChildren调用的sameVnode

关于具体diff实现参考这里...

  //判断节点是否相同
  function sameVnode (a, b) {
    return (
      a.key === b.key && (  //这里发现a对象居然是undefined的 所以直接挂掉了
        (
          a.tag === b.tag &&
          a.isComment === b.isComment &&
          isDef(a.data) === isDef(b.data) &&
          sameInputType(a, b)
        ) || (
          isTrue(a.isAsyncPlaceholder) &&
          a.asyncFactory === b.asyncFactory &&
          isUndef(b.asyncFactory.error)
        )
      )
    )
  }
  

    function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
      var oldStartIdx = 0;
      var newStartIdx = 0;
      var oldEndIdx = oldCh.length - 1;
      var oldStartVnode = oldCh[0];
      var oldEndVnode = oldCh[oldEndIdx];
      var newEndIdx = newCh.length - 1;
      var newStartVnode = newCh[0];
      var newEndVnode = newCh[newEndIdx];
      var oldKeyToIdx, idxInOld, vnodeToMove, refElm;

      // removeOnly is a special flag used only by <transition-group>
      // to ensure removed elements stay in correct relative positions
      // during leaving transitions
      var canMove = !removeOnly;

      {
        checkDuplicateKeys(newCh);
      }

      while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
          oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
        } else if (isUndef(oldEndVnode)) {
          oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
          patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
          oldStartVnode = oldCh[++oldStartIdx];
          newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
          patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
          oldEndVnode = oldCh[--oldEndIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
          patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
          canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
          oldStartVnode = oldCh[++oldStartIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
          patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
          canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
          oldEndVnode = oldCh[--oldEndIdx];
          newStartVnode = newCh[++newStartIdx];
        } else {
          if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
          idxInOld = isDef(newStartVnode.key)
            ? oldKeyToIdx[newStartVnode.key]
            : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
          if (isUndef(idxInOld)) { // New element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
          } else {
            vnodeToMove = oldCh[idxInOld];
            if (sameVnode(vnodeToMove, newStartVnode)) {
              patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
              oldCh[idxInOld] = undefined;
              canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
            } else {
              // same key but different element. treat as new element
              createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
            }
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }
      if (oldStartIdx > oldEndIdx) {
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      } else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }

1.第一次diff

我们可以看到第一次diff的时候元素2,被移动到第一个位置

image.png

企业微信截图_1667891883914.png 通过执行了canMove 实现移动。

这时候 oldCh 数组已经芭比Q了,变成了 [vnode,undefined,vnode]

企业微信截图_16678919339688.png

2.第二次diff

这样当第二次diff的时候,由于vnode直接是undefined,所以undefined.key当然就挂掉了。

企业微信截图_16678850924107.png

3.vue遇到异常时统一处理

当遇到运行时异常,vue会做统一拦截并把需要后续执行的逻辑终止。并且调用this.cleanupDeps(); 把所有响应式的dep释放,这也是为什么报错后,响应式失效的原因

image.png

  /**
   * Evaluate the getter, and re-collect dependencies.
   */
  Watcher.prototype.get = function get () {
    pushTarget(this);
    var value;
    var vm = this.vm;
    try {
      value = this.getter.call(vm, vm);
    } catch (e) {
      if (this.user) {
        handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value);
      }
      popTarget();
      this.cleanupDeps();
    }
    return value
  };

3.diff流程图回顾

第一次diff

image.png

第二次diff

image.png

5. 解决方案

方法1

直接在代码中把:key 去掉,但是会降低diff的效率,因为所有节点都是undefined

方法2

后台确保返回的code是唯一,比如利用后台的数据库id值(不能确保100%不稳定)

方法3

统一使用自己生成的code,不要依赖业务代码的code,比如自己使用 Math.random生成唯一key

6.结论

key是vue中为了提高diff算法所使用的vnode的唯一标识,我们应该要理解其中的原理,并且合理使用。 如果因为一点性能提升,导致业务页面无法交互使用,可能会得不偿失。