现象
element-ui
版本是 2.15.9
,vue
版本是 2.7.8
。
在 el-dialog
中使用 el-tabs
,并且 el-dialog
添加 destroy-on-close
属性,当关闭弹窗的时候页面就直接无响应了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| <template> <div id="app"> <el-dialog title="提示" :visible.sync="dialogVisible" width="30%" destroy-on-close > <el-tabs type="border-card"> <el-tab-pane label="用户管理">用户管理</el-tab-pane> <el-tab-pane label="配置管理">配置管理</el-tab-pane> <el-tab-pane label="角色管理">角色管理</el-tab-pane> <el-tab-pane label="定时任务补偿">定时任务补偿</el-tab-pane> </el-tabs> <span slot="footer" class="dialog-footer"> <el-button @click="dialogVisible = false">取 消</el-button> <el-button type="primary" @click="dialogVisible = false" >确 定</el-button > </span> </el-dialog> <el-button @click="dialogVisible = true">打开弹窗</el-button> </div> </template>
<script> export default { name: "App", data() { return { dialogVisible: false, }; }, }; </script>
|
效果如下:
再等一会儿 Chrome
就直接抛错了:
操作过程中控制台也没有任何报错,去 github
的 issues
看一眼发现已经有 3
个人遇到过这个问题了:
[bug report] El dialog [destroy on close] El tabs page crashes #21114
[Bug Report] When set a attribute “destory-on-close=’true’” on a el-dialog which has a child el-tabs component will cause the browser crash #20974
[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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <template> <transition name="dialog-fade" @after-enter="afterEnter" @after-leave="afterLeave"> <div v-show="visible" class="el-dialog__wrapper" @click.self="handleWrapperClick"> <div role="dialog" :key="key" :style="style"> ... <div class="el-dialog__body" v-if="rendered"><slot></slot></div> ... </div> </div> </transition> </template>
|
最关键的的是 <el-dialog__body>
的外层 div
中设置了一个 key
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| watch: { visible(val) { if (val) { ... } else { this.$el.removeEventListener('scroll', this.updatePopper); if (!this.closed) this.$emit('close'); if (this.destroyOnClose) { this.$nextTick(() => { this.key++; }); } } } },
|
当我们把 dialog
的 visible
置为 false
的时候,会判断 this.destroyOnClose
的值,然后修改 key
的值。
当 key
值修改以后,div
中的元素就会整个重新渲染了,这就是官网中所说明 this.destroyOnClose
的作用。
为了排除 el-dialog
的问题,我们写一个自定义组件来替代 el-dialog
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <template> <div v-show="showDialog" :key="key"> <slot></slot> </div> </template>
<script>
export default { components: {}, data() { return { key: 1, showDialog: false }; }, methods: { open() { this.showDialog = true; }, close() { this.key += 1 this.showDialog = false; }, }, }; </script>
<style scoped></style>
|
接着我们将 el-dialog
换为上边的组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| <template> <div id="app"> <wrap ref="wrap"> <el-tabs type="border-card"> <el-tab-pane label="用户管理">用户管理</el-tab-pane> <el-tab-pane label="配置管理">配置管理</el-tab-pane> <el-tab-pane label="角色管理">角色管理</el-tab-pane> <el-tab-pane label="定时任务补偿">定时任务补偿</el-tab-pane> </el-tabs> <el-button @click="close">关闭</el-button> </wrap> <el-button @click="open">打开弹窗</el-button> </div> </template>
<script> import Wrap from "./Wrap.vue"; export default { name: "App", components: { Wrap, }, data() { return { }; }, methods: { open() { this.$refs.wrap.open(); }, close() { this.$refs.wrap.close(); }, }, }; </script>
<style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
|
运行之后发现问题依旧存在,因此我们可以排除是 el-dialog
的问题了。
el-tabs el-tab-pane
接下来就是一个二选一问题了,问题代码是在 el-tabs
还是 el-tab-pane
中。
我们把 el-tab-pane
从 el-tabs
去掉再来看一下还有没有问题。
1 2 3 4 5 6 7 8 9 10 11
| <template> <div id="app"> <wrap ref="wrap"> <el-tabs type="border-card"> hello World </el-tabs> <el-button @click="close">关闭</el-button> </wrap> <el-button @click="open">打开弹窗</el-button> </div> </template>
|
运行一下发现一切正常了:
至此,可以基本确认是 el-tab-pane
问题了。
直接原因
我们来定位是哪行代码出现了问题,看一下 el-tab-pane
的整个代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| <template> <div class="el-tab-pane" v-if="(!lazy || loaded) || active" v-show="active" role="tabpanel" :aria-hidden="!active" :id="`pane-${paneName}`" :aria-labelledby="`tab-${paneName}`" > <slot></slot> </div> </template> <script> export default { name: 'ElTabPane',
componentName: 'ElTabPane',
props: { label: String, labelContent: Function, name: String, closable: Boolean, disabled: Boolean, lazy: Boolean },
data() { return { index: null, loaded: false }; },
computed: { isClosable() { return this.closable || this.$parent.closable; }, active() { const active = this.$parent.currentName === (this.name || this.index); if (active) { this.loaded = true; } return active; }, paneName() { return this.name || this.index; } },
updated() { this.$parent.$emit('tab-nav-update'); } }; </script>
|
定位 bug
所在行数一般无脑采取二分注释法很快就出来了,经过两次尝试,我们只需要把 updated
中的代码注释掉就一切正常了。
根本原因
子组件发送了 tab-nav-update
事件,看一下父组件 el-tabs
接收 tab-nav-update
事件的代码。
1 2 3 4 5 6 7
| created() { if (!this.currentName) { this.setCurrentName('0'); }
this.$on('tab-nav-update', this.calcPaneInstances.bind(null, true)); },
|
这里会执行 calcPaneInstances
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| calcPaneInstances(isForceUpdate = false) { if (this.$slots.default) { const paneSlots = this.$slots.default.filter(vnode => vnode.tag && vnode.componentOptions && vnode.componentOptions.Ctor.options.name === 'ElTabPane'); const panes = paneSlots.map(({ componentInstance }) => componentInstance); const panesChanged = !(panes.length === this.panes.length && panes.every((pane, index) => pane === this.panes[index])); if (isForceUpdate || panesChanged) { this.panes = panes; } } else if (this.panes.length !== 0) { this.panes = []; } },
|
主要是比较前后的 panes
是否一致,如果不一致就直接用新的覆盖旧的 this.panes
。
由于 render
函数中使用了 panes
,当修改 panes
的值的时候就会触发 el-tabs
的 render
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| render(h) { let { type, handleTabClick, handleTabRemove, handleTabAdd, currentName, panes, editable, addable, tabPosition, stretch } = this;
... },
|
打印一下关闭弹窗的时候发生了什么:
当关闭弹窗的时候,触发了 el-tabs
的 render
,但此时除了触发了 el-tabs
的 updated
,同时也触发到了 el-tabs-pane
的 updated
。
在 el-tab-pane
的 updated
中我们发送 tab-nav-update
事件
1 2 3
| updated() { this.$parent.$emit('tab-nav-update'); }
|
tab-nav-update
事件的回调是 calcPaneInstances
,除了改变 this
指向,同时传了一个默认参数 true
。
1
| this.$on('tab-nav-update', this.calcPaneInstances.bind(null, true));
|
对于 calcPaneInstances
第一个参数的含义是 isForceUpdate
。
1 2 3 4 5 6 7 8 9 10
| calcPaneInstances(isForceUpdate = false) { if (this.$slots.default) { ... if (isForceUpdate || panesChanged) { this.panes = panes; } } else if (this.panes.length !== 0) { this.panes = []; } },
|
如果 isForceUpdate
为 true
就会更新 panes
的值,接着又触发 el-tabs
的 render
函数,又一次引发 el-tab-pane
的 updated
,最终造成了 render
的死循环,使得浏览器卡死。
bug 最小说明
一句话总结:某些场景下如果父组件重新 render
,即使子组件没有变化,但子组件传递了 slot
,此时就会触发子组件的 updated
函数。
上边的逻辑确实不符合直觉,我们将代码完全从 Element
中抽离,举一个简单的例子来复现这个问题:
App.vue
代码,依旧用 wrap
包裹。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| <template> <div id="app"> <wrap ref="wrap"> <tabs> <pane>我来自pane的slot</pane> </tabs> <el-button @click="close">关闭</el-button> </wrap> <el-button @click="open">打开弹窗</el-button> </div> </template>
<script> import Wrap from "./Wrap.vue"; import Pane from "./Pane.vue"; import Tabs from "./Tabs.vue";
export default { name: "App", components: { Wrap, Pane, Tabs, }, data() { return { show: false, }; }, methods: { open() { this.$refs.wrap.open(); this.show = true; }, close() { this.$refs.wrap.close(); this.show = false; }, }, }; </script>
<style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
|
Tabs.vue
,提供一个 slot
,并且提供一个方法更新自己包含的 data
属性 i
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| <template> <div> <div>我是 Tabs,第 {{ i }} 次渲染</div> <slot></slot> <el-button @click="change">触发 Tabs 重新渲染</el-button> </div> </template>
<script> export default { data() { return { i: 0, }; }, methods: { change() { this.i++; }, }, updated() { console.log("Tabs:updated"); },
mounted() { console.log("Tabs:mounted"); }, }; </script>
<style scoped></style>
|
Pane.vue
,提供一个 slot
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <template> <div><slot></slot></div> </template>
<script> export default { mounted() { console.log("Pane:mounted"); }, updated() { console.log("Pane:updated"); }, }; </script>
<style scoped></style>
|
操作路径:
打开弹窗 -> 关闭弹窗 -> 再打开弹窗(此时 pane
就会触发 updated
) -> 更新 Tabs
的值,会发现 pane
一直触发 updated
。
如果我们在 Pane
的 updated
中引发 Tabs
的 render
,就会造成死循环了。
解决方案
关于这个问题网上前几年已经讨论过了:
https://segmentfault.com/q/1010000040171066
https://github.com/vuejs/vue/issues/8342
https://stackoverflow.com/questions/57536067/why-vue-need-to-forceupdate-components-when-they-include-static-slot
但是上边网站的例子试了下已经不能复现了,看起来这个问题被修过一次了,但没有完全解决,可能是当做 feature
了。
Vue 2.6+
如果你的版本是 Vue 2.6
以上,当时尤大提过了一个解决方案:
指明 slot
的名字,这里就是 default
。
代码中我们在 Pane
中包裹一层 template
指明 default
。
1 2 3 4 5 6 7 8 9 10 11 12 13
| <template> <div id="app"> <wrap ref="wrap"> <tabs> <pane> <template v-slot:default> 我来自pane的slot </template> </pane> </tabs> <el-button @click="close">关闭</el-button> </wrap> <el-button @click="open">打开弹窗</el-button> </div> </template>
|
再运行一下会发现 pane
的 updated
就不会触发了。
Vue 2.6 以下
仔细想一下,我们第一次渲染的时候并不会出现问题,因此我们干脆在关闭弹窗的时候把 Pane
销毁掉(Pane
添加 v-if
),再打开弹窗的时候现场就和第一次保持一致,就不会引起 Element
的死循环了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| <template> <div id="app"> <wrap ref="wrap"> <tabs> <pane v-if="show"> 我来自pane的slot </pane> </tabs> <el-button @click="close">关闭</el-button> </wrap> <el-button @click="open">打开弹窗</el-button> </div> </template>
<script> import Wrap from "./Wrap.vue"; import Pane from "./Pane.vue"; import Tabs from "./Tabs.vue";
export default { name: "App", components: { Wrap, Pane, Tabs, }, data() { return { show: false, }; }, methods: { open() { this.$refs.wrap.open(); this.show = true; }, close() { this.$refs.wrap.close(); this.show = false; }, }, }; </script>
|
同样的,Pane
的 updated
也不会被触发了。
等 Element 兼容
讲道理,这个问题其实也不能算作是 Element
的,但在 updated
生命周期触发渲染其实 Vue
官方已经给出过警告了。
Element
兼容的话,需要分析一下当时为什么在 updated
更新父组件状态,然后换一种方式了。
等 Vue 修复?
应该不会再修复了,毕竟有方案可以绕过这个问题,强制更新子组件应该是某些场景确实需要更新。
但 slot
为什么会引发这个问题,源代码到时候我会再研究下,最近也一直在看源代码相关的,目前 Vue2
响应式系统和虚拟 dom
两大块原理解析已经完成了,模版编译已经开始写了,关于 slot
应该也快写到了,感兴趣的同学也可以到 vue.windliang.wang 一起学习,文章会将 Vue
的每个点都拆出来并且配有相应的源代码进行调试。
总
在业务开发中,如果业务方能解决的问题,一般就自己解决了,一方面底层包团队更新速度确实慢,另一方面,因为业务代码依赖的包可能和最新版本差很多了,即使底层库修复了,我们也不会去更新库版本,罗老师镇楼。