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,从而组件被销毁并重新创建。

相关推荐
一 乐25 分钟前
租拼车平台|小区租拼车管理|基于java的小区租拼车管理信息系统小程序设计与实现(源码+数据库+文档)
java·数据库·vue.js·微信·notepad++·拼车
寻找09之夏4 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
多多米10055 小时前
初学Vue(2)
前端·javascript·vue.js
看到请催我学习5 小时前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript
golitter.7 小时前
Vue组件库Element-ui
前端·vue.js·ui
道爷我悟了7 小时前
Vue入门-指令学习-v-on
javascript·vue.js·学习
.生产的驴8 小时前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
老齐谈电商8 小时前
Electron桌面应用打包现有的vue项目
javascript·vue.js·electron
LIURUOYU4213089 小时前
vue.js组建开发
vue.js
九圣残炎9 小时前
【Vue】vue-admin-template项目搭建
前端·vue.js·arcgis