现象
element-ui 版本是 2.15.9,vue 版本是 2.7.8 。
在 el-dialog 中使用 el-tabs ,并且 el-dialog 添加 destroy-on-close 属性,当关闭弹窗的时候页面就直接无响应了。
1 | <template> |
效果如下:

再等一会儿 Chrome 就直接抛错了:

操作过程中控制台也没有任何报错,去 github 的 issues 看一眼发现已经有 3 个人遇到过这个问题了:
[bug report] El dialog [destroy on close] El tabs page crashes #21114
[Bug Report] el-tabs in el-dialog with destroy-on-close=‘true’ ,dialog can’t be closed
看表现应该是哪里陷入了死循环,猜测是 el-tabs 的 render 函数在无限执行。
为了证实这个猜测,我们直接在 node_modules 中 el-tabs 的 render 函数添加 console 。

打开控制台观察一下是否有输出:

直接原因找到了,下边需要排查一下 render 进入死循环的原因。
问题排查
可能出现问题的点,el-dialog、el-tabs、el-tab-pane,当然如果上述都没问题的话,也不排除 Vue 的问题,虽然可能性很低。
el-dialog
如果我们把 destroy-on-close 属性去掉,然后一切就恢复正常了。所以我们先看一下 destroy-on-close 做了什么。
1 | <template> |
最关键的的是 <el-dialog__body> 的外层 div 中设置了一个 key 。
1 | watch: { |
当我们把 dialog 的 visible 置为 false 的时候,会判断 this.destroyOnClose 的值,然后修改 key 的值。
当 key 值修改以后,div 中的元素就会整个重新渲染了,这就是官网中所说明 this.destroyOnClose 的作用。

为了排除 el-dialog 的问题,我们写一个自定义组件来替代 el-dialog 。
1 | <template> |
接着我们将 el-dialog 换为上边的组件。
1 | <template> |
运行之后发现问题依旧存在,因此我们可以排除是 el-dialog 的问题了。
el-tabs el-tab-pane
接下来就是一个二选一问题了,问题代码是在 el-tabs 还是 el-tab-pane 中。
我们把 el-tab-pane 从 el-tabs 去掉再来看一下还有没有问题。
1 | <template> |
运行一下发现一切正常了:

至此,可以基本确认是 el-tab-pane 问题了。
直接原因
我们来定位是哪行代码出现了问题,看一下 el-tab-pane 的整个代码。
1 | <template> |
定位 bug 所在行数一般无脑采取二分注释法很快就出来了,经过两次尝试,我们只需要把 updated 中的代码注释掉就一切正常了。
1 | updated() { |
根本原因
子组件发送了 tab-nav-update 事件,看一下父组件 el-tabs 接收 tab-nav-update 事件的代码。
1 | created() { |
这里会执行 calcPaneInstances 方法:
1 | calcPaneInstances(isForceUpdate = false) { |
主要是比较前后的 panes 是否一致,如果不一致就直接用新的覆盖旧的 this.panes 。
由于 render 函数中使用了 panes ,当修改 panes 的值的时候就会触发 el-tabs 的 render 。
1 | render(h) { |
打印一下关闭弹窗的时候发生了什么:

当关闭弹窗的时候,触发了 el-tabs 的 render ,但此时除了触发了 el-tabs 的 updated ,同时也触发到了 el-tabs-pane 的 updated 。
在 el-tab-pane 的 updated 中我们发送 tab-nav-update 事件
1 | updated() { |
tab-nav-update 事件的回调是 calcPaneInstances ,除了改变 this 指向,同时传了一个默认参数 true 。
1 | this.$on('tab-nav-update', this.calcPaneInstances.bind(null, true)); |
对于 calcPaneInstances 第一个参数的含义是 isForceUpdate 。
1 | calcPaneInstances(isForceUpdate = false) { |
如果 isForceUpdate 为 true 就会更新 panes 的值,接着又触发 el-tabs 的 render 函数,又一次引发 el-tab-pane 的 updated ,最终造成了 render 的死循环,使得浏览器卡死。
bug 最小说明
一句话总结:某些场景下如果父组件重新 render,即使子组件没有变化,但子组件传递了 slot ,此时就会触发子组件的 updated 函数。
上边的逻辑确实不符合直觉,我们将代码完全从 Element 中抽离,举一个简单的例子来复现这个问题:
App.vue 代码,依旧用 wrap 包裹。
1 | <template> |
Tabs.vue ,提供一个 slot ,并且提供一个方法更新自己包含的 data 属性 i 。
1 | <template> |
Pane.vue ,提供一个 slot 。
1 | <template> |
操作路径:
打开弹窗 -> 关闭弹窗 -> 再打开弹窗(此时 pane 就会触发 updated ) -> 更新 Tabs 的值,会发现 pane 一直触发 updated 。

如果我们在 Pane 的 updated 中引发 Tabs 的 render ,就会造成死循环了。
解决方案
关于这个问题网上前几年已经讨论过了:
https://segmentfault.com/q/1010000040171066
https://github.com/vuejs/vue/issues/8342
但是上边网站的例子试了下已经不能复现了,看起来这个问题被修过一次了,但没有完全解决,可能是当做 feature 了。
Vue 2.6+
如果你的版本是 Vue 2.6 以上,当时尤大提过了一个解决方案:

指明 slot 的名字,这里就是 default 。
代码中我们在 Pane 中包裹一层 template 指明 default 。
1 | <template> |
再运行一下会发现 pane 的 updated 就不会触发了。

Vue 2.6 以下
仔细想一下,我们第一次渲染的时候并不会出现问题,因此我们干脆在关闭弹窗的时候把 Pane 销毁掉(Pane 添加 v-if ),再打开弹窗的时候现场就和第一次保持一致,就不会引起 Element 的死循环了。
1 | <template> |
同样的,Pane 的 updated 也不会被触发了。

等 Element 兼容
讲道理,这个问题其实也不能算作是 Element 的,但在 updated 生命周期触发渲染其实 Vue 官方已经给出过警告了。

Element 兼容的话,需要分析一下当时为什么在 updated 更新父组件状态,然后换一种方式了。
等 Vue 修复?
应该不会再修复了,毕竟有方案可以绕过这个问题,强制更新子组件应该是某些场景确实需要更新。
但 slot 为什么会引发这个问题,源代码到时候我会再研究下,最近也一直在看源代码相关的,目前 Vue2 响应式系统和虚拟 dom 两大块原理解析已经完成了,模版编译已经开始写了,关于 slot 应该也快写到了,感兴趣的同学也可以到 vue.windliang.wang 一起学习,文章会将 Vue 的每个点都拆出来并且配有相应的源代码进行调试。
总
在业务开发中,如果业务方能解决的问题,一般就自己解决了,一方面底层包团队更新速度确实慢,另一方面,因为业务代码依赖的包可能和最新版本差很多了,即使底层库修复了,我们也不会去更新库版本,罗老师镇楼。
