前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >elementUI中el-tabs或者说Vue现存的一个bug排查

elementUI中el-tabs或者说Vue现存的一个bug排查

作者头像
windliang
发布2022-09-23 13:21:23
1.2K0
发布2022-09-23 13:21:23
举报
文章被收录于专栏:windliang的博客windliang的博客

现象

element-ui 版本是 2.15.9vue 版本是 2.7.8

el-dialog 中使用 el-tabs ,并且 el-dialog 添加 destroy-on-close 属性,当关闭弹窗的时候页面就直接无响应了。

代码语言:javascript
复制
<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 就直接抛错了:

image-20220814073751320

操作过程中控制台也没有任何报错,去 githubissues 看一眼发现已经有 3 个人遇到过这个问题了:

[[bug report] El dialog [destroy on close] El tabs page crashes #21114](https://github.com/ElemeFE/element/issues/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](https://github.com/ElemeFE/element/issues/20974)

[[Bug Report] el-tabs in el-dialog with destroy-on-close=‘true’ ,dialog can't be closed](https://github.com/ElemeFE/element/issues/20947)

看表现应该是哪里陷入了死循环,猜测是 el-tabsrender 函数在无限执行。

为了证实这个猜测,我们直接在 node_modulesel-tabsrender 函数添加 console

image-20220814080300663

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

Kapture 2022-08-14 at 08.05.56

直接原因找到了,下边需要排查一下 render 进入死循环的原因。

问题排查

可能出现问题的点,el-dialogel-tabsel-tab-pane,当然如果上述都没问题的话,也不排除 Vue 的问题,虽然可能性很低。

el-dialog

如果我们把 destroy-on-close 属性去掉,然后一切就恢复正常了。所以我们先看一下 destroy-on-close 做了什么。

代码语言:javascript
复制
<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

代码语言:javascript
复制
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++;
        });
      }
    }
  }
},

当我们把 dialogvisible 置为 false 的时候,会判断 this.destroyOnClose 的值,然后修改 key 的值。

key 值修改以后,div 中的元素就会整个重新渲染了,这就是官网中所说明 this.destroyOnClose 的作用。

image-20220814095416654

为了排除 el-dialog 的问题,我们写一个自定义组件来替代 el-dialog

代码语言:javascript
复制
<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 换为上边的组件。

代码语言:javascript
复制
<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-paneel-tabs 去掉再来看一下还有没有问题。

代码语言:javascript
复制
<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>

运行一下发现一切正常了:

Kapture 2022-08-14 at 10.07.33

至此,可以基本确认是 el-tab-pane 问题了。

直接原因

我们来定位是哪行代码出现了问题,看一下 el-tab-pane 的整个代码。

代码语言:javascript
复制
<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 中的代码注释掉就一切正常了。

代码语言:javascript
复制
updated() {
  // this.$parent.$emit('tab-nav-update');
}

根本原因

子组件发送了 tab-nav-update 事件,看一下父组件 el-tabs 接收 tab-nav-update 事件的代码。

代码语言:javascript
复制
created() {
  if (!this.currentName) {
    this.setCurrentName('0');
  }

  this.$on('tab-nav-update', this.calcPaneInstances.bind(null, true));
},

这里会执行 calcPaneInstances 方法:

代码语言:javascript
复制
calcPaneInstances(isForceUpdate = false) {
  if (this.$slots.default) {
    const paneSlots = this.$slots.default.filter(vnode => vnode.tag &&
                                                 vnode.componentOptions && vnode.componentOptions.Ctor.options.name === 'ElTabPane');
    // update indeed
    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-tabsrender

代码语言:javascript
复制
render(h) {
      let {
        type,
        handleTabClick,
        handleTabRemove,
        handleTabAdd,
        currentName,
        panes, // 这里用到了
        editable,
        addable,
        tabPosition,
        stretch
      } = this;

      ...
    },

打印一下关闭弹窗的时候发生了什么:

image-20220816063309490

当关闭弹窗的时候,触发了 el-tabsrender ,但此时除了触发了 el-tabsupdated ,同时也触发到了 el-tabs-paneupdated

el-tab-paneupdated 中我们发送 tab-nav-update 事件

代码语言:javascript
复制
updated() {
  this.$parent.$emit('tab-nav-update');
}

tab-nav-update 事件的回调是 calcPaneInstances ,除了改变 this 指向,同时传了一个默认参数 true

代码语言:javascript
复制
 this.$on('tab-nav-update', this.calcPaneInstances.bind(null, true));

对于 calcPaneInstances 第一个参数的含义是 isForceUpdate

代码语言:javascript
复制
calcPaneInstances(isForceUpdate = false) {
  if (this.$slots.default) {
    ...
    if (isForceUpdate || panesChanged) {
      this.panes = panes;
    }
  } else if (this.panes.length !== 0) {
    this.panes = [];
  }
},

如果 isForceUpdatetrue 就会更新 panes 的值,接着又触发 el-tabsrender 函数,又一次引发 el-tab-paneupdated ,最终造成了 render 的死循环,使得浏览器卡死。

bug 最小说明

一句话总结:某些场景下如果父组件重新 render,即使子组件没有变化,但子组件传递了 slot ,此时就会触发子组件的 updated 函数。

上边的逻辑确实不符合直觉,我们将代码完全从 Element 中抽离,举一个简单的例子来复现这个问题:

App.vue 代码,依旧用 wrap 包裹。

代码语言:javascript
复制
<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

代码语言:javascript
复制
<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

代码语言:javascript
复制
<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

Kapture 2022-08-16 at 08.04.05

如果我们在 Paneupdated 中引发 Tabsrender ,就会造成死循环了。

解决方案

关于这个问题网上前几年已经讨论过了:

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 以上,当时尤大提过了一个解决方案:

image-20220816085852972

指明 slot 的名字,这里就是 default

代码中我们在 Pane 中包裹一层 template 指明 default

代码语言:javascript
复制
<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>

再运行一下会发现 paneupdated 就不会触发了。

image-20220816082327739

Vue 2.6 以下

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

代码语言:javascript
复制
<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>

同样的,Paneupdated 也不会被触发了。

image-20220816083018335

等 Element 兼容

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

image-20220816083227156

Element 兼容的话,需要分析一下当时为什么在 updated 更新父组件状态,然后换一种方式了。

等 Vue 修复?

应该不会再修复了,毕竟有方案可以绕过这个问题,强制更新子组件应该是某些场景确实需要更新。

slot 为什么会引发这个问题,源代码到时候我会再研究下,最近也一直在看源代码相关的,目前 Vue2 响应式系统和虚拟 dom 两大块原理解析已经完成了,模版编译已经开始写了,关于 slot 应该也快写到了,感兴趣的同学也可以到 vue.windliang.wang 一起学习,文章会将 Vue 的每个点都拆出来并且配有相应的源代码进行调试。

在业务开发中,如果业务方能解决的问题,一般就自己解决了,一方面底层包团队更新速度确实慢,另一方面,因为业务代码依赖的包可能和最新版本差很多了,即使底层库修复了,我们也不会去更新库版本,罗老师镇楼。

image-20220816084114944

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-08-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 windliang 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 现象
  • 问题排查
    • el-dialog
      • el-tabs el-tab-pane
      • 直接原因
      • 根本原因
      • bug 最小说明
      • 解决方案
        • Vue 2.6+
          • Vue 2.6 以下
            • 等 Element 兼容
              • 等 Vue 修复?
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档