组件mounted后通过document.body.appendChild挂载到body下的问题

3,590 阅读1分钟

此篇文章主要目的是记录一下这个问题,因为涉及到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属性值,this.route属性值,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标签还在。

image.png

这里例子简单很多,在vue源码的patch方法打个断点走一遍就能发现,移除的dom实际上已经不包含b标签这块的元素了,所以b标签还在页面上。到这里就解释了为什么弹窗会一直在页面上。

但是弹窗点cancel的时候为什么又会消失,其实上面有提到,这是因为弹窗组件自身的patch从而销毁。

稍微复杂的问题来了,那么点comfirm的时候一样会改变value的值,导致弹窗组件patch,为什么这里就不能生效呢?

这是因为组件的patch是异步的,延迟了一个tick,在一个延迟的tick里面,beforeRouterLeave钩子已经走完了,外面的父组件已经开始patch新旧两个router-view里面的组件了,那么旧组件全部都会走一遍destroy方法,destroy方法,在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,一定要在组件销毁前手动移除掉。