此篇文章主要目的是记录一下这个问题,因为涉及到vue更新过程,所以如果不了解vue更新过程的话,还是挺难找到原因的。
这是一个很简单的弹窗组件,通过默认slot来插入弹窗的主体内容,value属性控制弹窗显示还是隐藏,通过v-model和父组件通信
<template>
<div class="popup" :class="{ visible: value }">
<transition name="fade">
<div class="popup-mask" v-if="value"></div>
</transition>
<transition name="slideup">
<div class="popup-main" v-if="value">
<div class="content-box">
<div class="popup-header">
<span class="title">{{ title }}</span>
<span
v-if="closeable"
class="popup-close iconfont icon-guanbi"
@click.stop="$emit('input', false)"
></span>
</div>
<slot />
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
value: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
closeable: {
default: true,
},
},
mounted() {
document.body.appendChild(this.$el);
},
beforeDestroy() {
document.body.removeChild(this.$el);
},
};
</script>
先解释一下为什么mounted后要挂载到body下,因为弹窗组件的遮罩层是fixed定位,这个fixed定位往往希望参照的父元素是body,这样可以让遮罩铺满这个屏幕。但是fixed定位参照的父元素不一定是body,详细见mdn说明,所以弹窗组件一般都选择挂载到body下。
最开始的时候,这个组件我并没有定义beforeDestroy钩子,因为这里只是移动了dom元素,对于vue来说,vnode的结构并没有改变,并且vnode上的elm属性保持着对这个dom元素的引用,对于patch来说不应该会有问题。事实证明,父元素切换传进来的value值的时候,弹窗组件也是能正常显示隐藏。
但是,当这个弹窗用在beforeRouterLeave的拦截弹窗时,bug就来了。描述下具体现象就是触发beforeRouterLeave,开启弹窗,弹窗里面选择cancel就不离开当前页面,也就是next(false),选择confirm,也就是next(),正常离开页面。如果点cancel,弹窗是可以关闭的。但是如果点来comfrim这时候页面可以正常离开,但是弹窗依然在,控制弹窗显示的属性值已经为false。后面无论是点cancel和comfirm,都无法关闭弹窗。
思路分析:
显然这个bug和路由强相关,首先我回顾了下路由切换做了什么,路由切换的时候会重新匹配新的组件,改变this.route是响应式的,router-view的父组件会收集到相关依赖*(这里的父组件指的是具有$parent关系的,而不是页面层级上的父组件,比如keep-alive包裹的router-view,但是keep-alive不是router-view的父组件)*,最终会触发router-view组件重新render拿到新的vnode,父组件patch时候,旧组件的整颗dom树会被移除。
到这里其实已经有点复杂了,所以我把问题简化一下,我用下面三个组件模拟上面的场景
app组件下面有a,b两个组件 ,初始化显示a组件,2s后切换为b组件
//app.vue
<template>
<div id="app">
<aC v-if="showA" />
<bC v-else />
</template>
created() {
setTimeout(() => {
this.showA = false
}, 2000)
},
a组件里面同样有个这种操作
<template>
<p>
<b ref="b">a组件</b>
</p>
</template>
mounted() {
document.body.appendChild(this.$refs.b)
},
2s后,可以发现p标签没了,body下面的b标签还在。
这里例子简单很多,在vue源码的patch方法打个断点走一遍就能发现,移除的dom实际上已经不包含b标签这块的元素了,所以b标签还在页面上。到这里就解释了为什么弹窗会一直在页面上。
但是弹窗点cancel的时候为什么又会消失,其实上面有提到,这是因为弹窗组件自身的patch从而销毁。
稍微复杂的问题来了,那么点comfirm的时候一样会改变value的值,导致弹窗组件patch,为什么这里就不能生效呢?
这是因为组件的patch是异步的,延迟了一个tick,在一个延迟的tick里面,beforeRouterLeave钩子已经走完了,外面的父组件已经开始patch新旧两个router-view里面的组件了,那么旧组件全部都会走一遍destroy方法里面,会对所有子组件的watcher进行teardown。注意看active已经被置为false
Watcher.prototype.teardown = function teardown () {
if (this.active) {
// remove self from vm's watcher list
// this is a somewhat expensive operation so we skip it
// if the vm is being destroyed.
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this);
}
var i = this.deps.length;
while (i--) {
this.deps[i].removeSub(this);
}
this.active = false;
}
};
等一个tick后,弹窗组件的watcher应该要run了, 被这个actice标记拦截了。所以弹窗组件不会走到patch。
Watcher.prototype.run = function run () {
if (this.active) {
var value = this.get();
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
var oldValue = this.value;
this.value = value;
if (this.user) {
var info = "callback for watcher \"" + (this.expression) + "\"";
invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info);
} else {
this.cb.call(this.vm, value, oldValue);
}
}
}
};
如果过destroy的teardown逻辑注释调,这个弹窗在路由离开后也是可以正常销毁的。
解决方法很简单,在beforeDestroy钩子中手动移除就好。对于这个问题,总结起来就是为了避免我们考虑不到的场景,如果移动了dom,一定要在组件销毁前手动移除掉。