Vue 组件重复注册导致组件销毁

一、背景

最近还在开发配置化平台,又遇到了一个bug。最后发现这个bug和低代码渲染器重复加载组件库有关系。排查了许久,令人头大。

使用的组件库是公司内部未开源的组件库,为了避免风险,这里使用ElementUI进行代替,问题原理是一致的,相似的用法也能复现该问题。

问题现象

我先简单描述一下这个bug:一个级联选择器(cascader),支持多选。在进入页面后第一次勾选中节点时,选择器能正常选中,但级联选择器的弹窗会消失。当你再次唤起弹窗进行勾选后,弹窗就不会消失了。而正常情况下,级联选择器选中节点的时候弹窗是不会消失的。

这个bug看起来并不是很严重,弹窗消失了再点开就行了,反正值也选中了(又不是不能用.jpg)。但我们技术人员,怎么能止步于此呢,要找出问题的根源。

二、问题定位(可跳过)

这里主要记录我是如何排查该问题的,加深一下印象,提升debug能力,不感兴趣可以不看。

1、在组件文档站进行调试,发现vue版本和组件版本相同的情况下,表现正常,因此推断问题出在业务仓库。

2、怀疑是组件用法有问题。该业务逻辑中对cascader的使用逻辑较为复杂,涉及远程加载。在函数调用中进行断点调试,发现并无异常。(这里耗费了较多时间)

3、将组件库的demo copy到业务仓库运行,发现问题复现,进一步判断问题出在业务仓库,且和业务逻辑无关。

4、怀疑是弹窗组件有问题,使用devTools进行调试。

cascader的弹窗是基于popper.js实现的,并进行了魔改。大致原理是会创建绝对定位的弹窗(以下用poper代替),并将popper插入document.body上。

通过调试,发现在第一次选中时,该popper被移除。而在第二次打开弹窗后,popper被重新创建插入,并且在之后的操作过程中,popper并不会被移除。也就是在第一次选中时,弹窗的dom节点都被销毁了,隐藏弹窗消失也就正常了。

当且仅当cascader的节点第一次被勾选时,popper的dom节点会被移除,因此导致弹窗消失。

那为什么会这样呢?

5、查阅cascader与popper源码,发现popper的隐藏是通过display:none实现的,并非移除dom节点。调用popper的destory方法时才会移除dom节点。而cascader只有在beforeDestory这个生命周期内才会调用destory并移除节点。

这说明ElCcascader组件被销毁过

为验证猜想,在该dom节点被移除时打上断点,进行调试。

发现确实触发了popper的销毁

6、研究调用栈发现如下调用顺序:

经调试后发现,在第一次value更新时,子组件更新后,patch算法认为新老vnode不是同一个,因此移除旧的vnode,并挂载新的vnode,因此cascader经历了一次销毁过程。而在后续的value值更新时,patch算法则认为新老vnode是同一个,因此进行了复用,新的cascader没有被销毁。

7、到这里,就能确定问题出在updateChildren函数这里,在子节点的更新过程中,有什么因素,造成了算法认为vnode无法被复用导致cascader被移除后新建。

8、阅读updateChildren源码,发现在其内部对 removeVnodes 的调用场景,是在各种 sameVnode 判断失败的场景下执行的。而sameVnode函数的判断则比较简单:

对于两个vnode是否为同一个,会判断key(对,就是那个key)等一系列东西。

而问题就出在tag这里。经过我测试,cascader在第一次选中发生更新后,tag是发生了变化的,而在随后的选中过程中,tag则没有再继续发生变化。这一现象和组件后续没有再被销毁是吻合的。

到了这里,能有初步的结论:组件的tag变化导致vue进行diff的时候认为这是一个全新的组件,导致组件被销毁并新建。而tag的变化具体来说是中间的数字发生了变化。

9、那么为什么tag会变化呢,中间的数字是什么含义呢?就要我们再去找找tag是在哪儿生成的。

原来这个数字是cid,而Ctor是我们注册到this.$options.components上的对象,即我们注册的组件。

我们知道,注册组件时调用的Vue.compoent方法,内部还是调用的Vue.extend方法,我们可以查看该方法是如何声明的:

extend方法是在initExtend中创建的,而cid就是在这个闭包内部。每当extend执行一次(例如注册了一个组件),那么cid就会增加。

10、问题还没解决,即使知道了注册组件时cid会自增,但是我们的cascader组件应该是同一个,多次创建vnode时,tag应该是相等的,cid不应该发生变化。因此到了这里开始怀疑组件被重复注册。

11、最后一步比较简单,因为项目的低代码背景,简单排查之后,确认组件重复注册。

三、最小复现代码minirepo

对于这个问题,如果有兴趣,可以使用这个最简单的demo进行验证。

javascript 复制代码
import Vue from 'vue';
import App from './App.vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);
Vue.use(VCA);
Vue.mixin({
  mounted() {
    console.log('有组件被挂载了',this.$vnode?.tag)
  },
  beforeDestroy() {
    console.log('有组件被销毁了',this.$vnode?.tag);
  }
})

window.Vue = Vue

new Vue({
  render: (h) => h(App),
}).$mount('#app');

我们在项目中注册ElementUI,并将Vue挂载到window上,这样做是为了CDN方式引入的elementUI能够自动完成组件注册。

xml 复制代码
<template>
  <div id="app">
    <el-cascader
      v-model="value"
      :options="options"
      :props="props"
      clearable></el-cascader>
  </div>
</template>

<script>
  import HelloWorld from "./components/HelloWorld.vue";

  export default {
    components: {
      HelloWorld,
    },
    data() {
      return {
        value:[],
        options: [{
          value: 17,
          label: '西北',
          children: [{
            value: 18,
            label: '陕西',
            children: [
              { value: 19, label: '西安' },
              { value: 20, label: '延安' }
            ]
          }, {
            value: 21,
            label: '新疆维吾尔族自治区',
            children: [
              { value: 22, label: '乌鲁木齐' },
              { value: 23, label: '克拉玛依' }
            ]
          }]
        }],
        props: { multiple: true },
      };
    },
    mounted() {
      const script = document.createElement("script");
      // css重新重复加载不会有影响,所以这里就懒得引了。
      script.src = "https://unpkg.com/element-ui@2.10.1/lib/index.js";
      document.head.appendChild(script);
      console.log("父组件以CDN的形式再次加载一个Element");
    }
  };
</script>

<style>
</style>

在页面挂载之后,我们以CDN的方式再引入一次ElementUI,因为window上挂载了Vue,因此组件的注册工作不需要我们手动操作。

等待一切准备就绪,我们进行选择,就能够复现最开始demo中提到的问题。

四、原因总结

我们知道,vue的更新原理是在数据发生变化时,重新生成虚拟dom(即vnode),通过diff虚拟dom的差异,再选择性地更新真实dom。

总结:由于ElementUI进行了重复注册,且两次注册之间存在时间差,导致同一个组件更新时创建的新vnode,相对于旧vnode,由于cid不同导致tag不同,进而被判定为非同类vnode,从而组件被销毁并重新创建。

相关推荐
迂 幵27 分钟前
vue el-table 超出隐藏移入弹窗显示
javascript·vue.js·elementui
上趣工作室31 分钟前
vue2在el-dialog打开的时候使该el-dialog中的某个输入框获得焦点方法总结
前端·javascript·vue.js
家里有只小肥猫31 分钟前
el-tree 父节点隐藏
前端·javascript·vue.js
_xaboy2 小时前
开源项目低代码表单设计器FcDesigner扩展自定义的容器组件.例如col
vue.js·低代码·开源·动态表单·formcreate·低代码表单·可视化表单设计器
_xaboy2 小时前
开源项目低代码表单设计器FcDesigner扩展自定义组件
vue.js·低代码·开源·动态表单·formcreate·可视化表单设计器
mez_Blog2 小时前
Vue之插槽(slot)
前端·javascript·vue.js·前端框架·插槽
爱睡D小猪2 小时前
vue文本高亮处理
前端·javascript·vue.js
paopaokaka_luck2 小时前
基于Spring Boot+Vue的多媒体素材管理系统的设计与实现
java·数据库·vue.js·spring boot·后端·算法
开心工作室_kaic2 小时前
ssm102“魅力”繁峙宣传网站的设计与实现+vue(论文+源码)_kaic
前端·javascript·vue.js
放逐者-保持本心,方可放逐2 小时前
vue3 中那些常用 靠copy 的内置函数
前端·javascript·vue.js·前端框架