一、背景
最近还在开发配置化平台,又遇到了一个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,从而组件被销毁并重新创建。