是什么导致 Chrome 中的 Vue.js 组件中的 animation/transition 损坏?

What is causing this broken animation/transition in a Vue.js component in Chrome?

const NotificationBar = {
  name: 'notification-bar',

  template: `
    <div
      :class="{
        'notification-bar': true,
        'notification-bar--error': isError,
        'notification-bar--warning': isWarning,
        'notification-bar--info': isInfo,
        'notification-bar--visible': isVisible,
      }"

      @click="dismiss"
      @transitionend="transitionEnd($event)">
      {{ message }}
    </div>
  `,

  props: {
    message: {
      type: String,
      required: true,
    },

    type: {
      type: String,
      required: true,

      validator(value) {
        const valid = ['error', 'warning', 'info'];
        return valid.includes(value);
      },
    },

    dismissable: {
      type: Boolean,
      default: false,
    },

    timeout: {
      type: Number,
      default: 0,
    },
  },

  data() {
    return {
      isVisible: false,
    };
  },

  computed: {
    isError() {
      return this.type === 'error';
    },

    isWarning() {
      return this.type === 'warning';
    },

    isInfo() {
      return this.type === 'info';
    },
  },

  methods: {
    clear() {
      const event = 'cleared';
      let done;

      if (this.isVisible) {
        this.$once('transitionend', () => {
          done = true;
          this.$emit(event, done);
        });
        this.isVisible = false;
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    dismiss() {
      const event = 'dismissed';
      let done;

      if (this.dismissable) {
        done = true;
        this.$emit(event, done);
        this.clear();
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    show() {
      if (!this.isVisible) {
        this.isVisible = true;
        this.$emit('show', this.clear);

        if (this.timeout) {
          setTimeout(() => {
            this.$emit('timeout');
            this.clear();
          }, this.timeout);
        }
      }
    },

    transitionEnd(event) {
      this.$emit('transitionend', event);
    },
  },

  mounted() {
    window.requestAnimationFrame(this.show);
  },
};

const NotificationCenter = {
  name: 'notification-center',

  components: {
    NotificationBar,
  },

  template: `
    <div>
      <notification-bar
        v-for="notification in active"

        :message="notification.message"
        :type="notification.type"
        :dismissable="notification.dismissable"
        :timeout="notification.timeout"

        @cleared="clear">
      </notification-bar>
    </div>
  `,

  props: {
    queue: {
      type: Array,
      required: true,
    },
  },

  data() {
    return {
      active: [],
    };
  },

  computed: {
    hasActiveNotification() {
      return this.active.length > 0;
    },

    hasQueuedNotification() {
      return this.queue.length > 0;
    },
  },

  watch: {
    queue() {
      if (this.hasQueuedNotification && !this.hasActiveNotification) {
        this.setNextActive();
      }
    },
  },

  methods: {
    setNextActive() {
      this.setActive(this.queue.shift());
    },

    setActive(notification) {
      this.active.push(notification);
    },

    removeActive() {
      this.active.pop();
    },

    clear() {
      this.active.pop();

      if (this.hasQueuedNotification) {
        this.$nextTick(this.setNextActive);
      }
    },
  },
};

window.vm = new Vue({
  components: {
    NotificationCenter,
  },

  el: '#app',

  template: `
    <div>
      <notification-center
        :queue="notifications">
      </notification-center>

      <label>
        <strong>Type</strong> <br>
        Error <input v-model="type" type="radio" name="type" value="error"> <br>
        Warning <input v-model="type" type="radio" name="type" value="warning"> <br>
        Info <input v-model="type" type="radio" name="type" value="info"> <br>
      </label>

      <label>
        <strong>Message</strong>
        <input v-model="message" type="text">
      </label>

      <label>
        <strong>Dismissable</strong>
        <input v-model="dismissable" type="checkbox">
      </label>

      <label>
        <strong>Timeout</strong>
        <input v-model="timeout" type="number" step="100" min="0">
      </label>

      <button @click="generateNotification">Generate notification</button>
    </div>
  `,

  data: {
    notifications: [],
    
    type: null,
    message: null,
    dismissable: null,
    timeout: null,
  },
  
  methods: {
    generateNotification() {
      const {
        type,
        message,
        dismissable,
        timeout,
      } = this;
      
      this.notifications.push({
        type,
        message,
        dismissable,
        timeout,
      });
      
      this.type = this.message = this.dismissable = this.timeout = null;
    },
  },
});
.notification-bar {
  box-sizing: border-box;
  position: absolute;
  top: -3.2rem;
  right: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3.2rem;
  color: #fff;
  font-family: 'Avenir Next', sans-serif;
  font-size: 1.2em;
  line-height: 3.2rem;
  text-align: center;
  transition: top 266ms ease;
}
.notification-bar--error {
  background-color: #f02a4d;
}
.notification-bar--warning {
  background-color: #ffc107;
}
.notification-bar--info {
  background-color: #2196f3;
}
.notification-bar--visible {
  top: 0;
}

label {
  display: block;
  margin: 2rem 0;
  font-size: 1.4rem;
}
label:first-of-type {
  margin-top: 5rem;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
    <style>
      html {
        font-size: 62.5%;
      }
      
      body {
        font-family: sans-serif;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.3/vue.js"></script>
  </body>
</html>

重现问题的步骤

  1. 运行 以上 Chrome 中的片段。
  2. 生成通知。 (设置 type 为任何值,设置 message 为任何值,设置 dismissable,点击 生成通知)
  3. 观察通知栏

预期行为

通知栏以动画形式平滑地进入可见视口。您可以通过在 Firefox 中执行上述步骤来观察这一点。

这是演示 Chrome 中正确行为的 GIF。 单击 生成通知 后,您可以顺利看到该栏正在过渡。

这是 Chrome 正常运行时的时间线截图:

这是 Chrome 正常运行时的调用树:

实际行为

大多数时候,通知栏无法顺利地动画化到可见视口中。在 Chrome Devtools 中捕获时间线显示通知栏时没有动画 运行ning。当栏在屏幕外动画时,动画总是 运行 正确。动画在 Firefox 中总是 运行s 正确。

这是演示 Chrome 中错误行为的 GIF。 单击 生成通知 后,您会突然看到该栏出现。

这是 Chrome 运行不正常时的时间线截图:

这是 Chrome 行为不正确时的调用树:

附加信息

代码的作用概述:

  1. NotificationCenter 接受一个 queue 道具。这是一个对象数组,该数组表示通知队列,一个对象表示单个通知。

  2. 一旦 queue 发生变化,观察者 运行 就会检查队列中是否有通知以及是否没有活动通知。如果是这种情况,下一个通知将设置为活动通知。

  3. NotificationCenter 的模板有一个指令循环遍历 active 中的项目并呈现 NotificationBar。在上一步中,设置了一个新的活动通知,因此将创建一个新的通知栏并将其安装到 DOM.

  4. 一旦NotificationBar挂载在DOM上,它的show方法就是window.requestAnimationFrame里面的运行。

这一行有问题:

this.type = this.message = this.dismissable = this.timeout = null;

如果删除它,它会正常工作。因为当它被执行时 props 变成 NULL 并且你已经验证了 props 不应该为 null。

const NotificationBar = {
  name: 'notification-bar',

  template: `
    <div
      :class="{
        'notification-bar': true,
        'notification-bar--error': isError,
        'notification-bar--warning': isWarning,
        'notification-bar--info': isInfo,
        'notification-bar--visible': isVisible,
      }"

      @click="dismiss"
      @transitionend="transitionEnd($event)">
      {{ message }}
    </div>
  `,

  props: {
    message: {
      type: String,
      required: true,
    },

    type: {
      type: String,
      required: true,

      validator(value) {
        const valid = ['error', 'warning', 'info'];
        return valid.includes(value);
      },
    },

    dismissable: {
      type: Boolean,
      default: false,
    },

    timeout: {
      type: Number,
      default: 0,
    },
  },

  data() {
    return {
      isVisible: false,
    };
  },

  computed: {
    isError() {
      return this.type === 'error';
    },

    isWarning() {
      return this.type === 'warning';
    },

    isInfo() {
      return this.type === 'info';
    },
  },

  methods: {
    clear() {
      const event = 'cleared';
      let done;

      if (this.isVisible) {
        this.$once('transitionend', () => {
          done = true;
          this.$emit(event, done);
        });
        this.isVisible = false;
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    dismiss() {
      const event = 'dismissed';
      let done;

      if (this.dismissable) {
        done = true;
        this.$emit(event, done);
        this.clear();
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    show() {
      if (!this.isVisible) {
        this.isVisible = true;
        this.$emit('show', this.clear);

        if (this.timeout) {
          setTimeout(() => {
            this.$emit('timeout');
            this.clear();
          }, this.timeout);
        }
      }
    },

    transitionEnd(event) {
      this.$emit('transitionend', event);
    },
  },

  mounted() {
    window.requestAnimationFrame(this.show);
  },
};

const NotificationCenter = {
  name: 'notification-center',

  components: {
    NotificationBar,
  },

  template: `
    <div>
      <notification-bar
        v-for="notification in active"

        :message="notification.message"
        :type="notification.type"
        :dismissable="notification.dismissable"
        :timeout="notification.timeout"

        @cleared="clear">
      </notification-bar>
    </div>
  `,

  props: {
    queue: {
      type: Array,
      required: true,
    },
  },

  data() {
    return {
      active: [],
    };
  },

  computed: {
    hasActiveNotification() {
      return this.active.length > 0;
    },

    hasQueuedNotification() {
      return this.queue.length > 0;
    },
  },

  watch: {
    queue() {
      if (this.hasQueuedNotification && !this.hasActiveNotification) {
        this.setNextActive();
      }
    },
  },

  methods: {
    setNextActive() {
      this.setActive(this.queue.shift());
    },

    setActive(notification) {
      this.active.push(notification);
    },

    removeActive() {
      this.active.pop();
    },

    clear() {
      this.active.pop();

      if (this.hasQueuedNotification) {
        this.$nextTick(this.setNextActive);
      }
    },
  },
};

window.vm = new Vue({
  components: {
    NotificationCenter,
  },

  el: '#app',

  template: `
    <div>
      <notification-center
        :queue="notifications">
      </notification-center>

      <label>
        <strong>Type</strong> <br>
        Error <input v-model="type" type="radio" name="type" value="error"> <br>
        Warning <input v-model="type" type="radio" name="type" value="warning"> <br>
        Info <input v-model="type" type="radio" name="type" value="info"> <br>
      </label>

      <label>
        <strong>Message</strong>
        <input v-model="message" type="text">
      </label>

      <label>
        <strong>Dismissable</strong>
        <input v-model="dismissable" type="checkbox">
      </label>

      <label>
        <strong>Timeout</strong>
        <input v-model="timeout" type="number" step="100" min="0">
      </label>

      <button @click="generateNotification">Generate notification</button>
    </div>
  `,

  data: {
    notifications: [],
    
    type: null,
    message: null,
    dismissable: null,
    timeout: null,
  },
  
  methods: {
    generateNotification() {
      const {
        type,
        message,
        dismissable,
        timeout,
      } = this;
      
      this.notifications.push({
        type,
        message,
        dismissable,
        timeout,
      });
      
      //this.type = this.message = this.dismissable = this.timeout = null;
    },
  },
});
.notification-bar {
  box-sizing: border-box;
  position: absolute;
  top: -3.2rem;
  right: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3.2rem;
  color: #fff;
  font-family: 'Avenir Next', sans-serif;
  font-size: 1.2em;
  line-height: 3.2rem;
  text-align: center;
  transition: top 266ms ease;
}
.notification-bar--error {
  background-color: #f02a4d;
}
.notification-bar--warning {
  background-color: #ffc107;
}
.notification-bar--info {
  background-color: #2196f3;
}
.notification-bar--visible {
  top: 0;
}

label {
  display: block;
  margin: 2rem 0;
  font-size: 1.4rem;
}
label:first-of-type {
  margin-top: 5rem;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
    <style>
      html {
        font-size: 62.5%;
      }
      
      body {
        font-family: sans-serif;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.3/vue.js"></script>
  </body>
</html>

编辑

您必须在 setActive 函数中进行一些验证,就像您将空项目推入活动状态一样,您的一些验证失败了。

  setActive(notification) {
    if(notification.message){
    this.active.push(notification);
    }
  },

检查此 fiddle

经过与 LinusBorg, a contributor to Vue, on the Vue forum 的讨论,我们得出了导致此问题的可能原因:

[...] the issue is likely that Vue patches the DOM asynchronously, so when mounted() is called, the elements of the components exist, but they are not guranteed to be in the DOM.

And so now, depending on how different browsers handle the priorities of normal tasks, microtasks and animationFrames, it may simply be the case that in Chrome, the element is not in the DOM yet, when you change the class through show()

In that case, the animation effect would not appear, naturally.

I suggest to try this.$nextTick() instead (which guarantees that the element is already in the DOM), or simply use the tools Vue gives you for this, namely the <transition> component.

– 林纳斯博格,https://forum.vuejs.org/t/what-is-causing-this-broken-animation-transition-in-a-vue-js-component-in-chrome/7742/7

最初尝试使用 this.$nextTick,但在 Firefox 和 Chrome 中都失败了。

最终我能够使用 <transition> 组件实现这整个事情。

const NotificationBar = {
  name: 'notification-bar',
  
  template: `
    <transition
      name="visible"
      mode="out-in"

      @after-enter="show">
      <div
        :class="{
          'notification-bar': true,
          'notification-bar--error': isError,
          'notification-bar--warning': isWarning,
          'notification-bar--info': isInfo,
          'notification-bar--visible': isVisible,
        }"

        :key="id"

        @click="dismiss">
        {{ message }}
      </div>
    </transition>
  `,

  props: {
    message: {
      type: String,
      required: true,
    },

    type: {
      type: String,
      required: true,

      validator(value) {
        const valid = ['error', 'warning', 'info'];
        return valid.includes(value);
      },
    },

    id: {
      type: [Number, String],
      required: true,
    },

    dismissable: {
      type: Boolean,
      default: false,
    },

    timeout: {
      type: Number,
      default: 0,
    },
  },

  data() {
    return {
      isVisible: false,
    };
  },

  computed: {
    isError() {
      return this.type === 'error';
    },

    isWarning() {
      return this.type === 'warning';
    },

    isInfo() {
      return this.type === 'info';
    },
  },

  methods: {
    clear() {
      const event = 'clear';
      let done;

      if (this.isVisible) {
        done = true;
        this.$emit(event, done);
        this.isVisible = false;
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    dismiss() {
      const event = 'dismissed';
      let done;

      if (this.dismissable) {
        done = true;
        this.$emit(event, done);
        this.clear();
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    show() {
      if (!this.isVisible) {
        this.isVisible = true;
        this.$emit('show', this.clear);

        if (this.timeout) {
          setTimeout(() => {
            this.$emit('timeout');
            this.clear();
          }, this.timeout);
        }
      }
    },
  },
};

const NotificationCenter = {
  name: 'notification-center',
  
  template: `
    <div>
      <notification-bar
        v-if="hasQueuedNotification"

        :message="activeNotification.message"
        :type="activeNotification.type"
        :dismissable="activeNotification.dismissable"
        :timeout="activeNotification.timeout"
        :id="activeNotification.id"

        @clear="clear">
      </notification-bar>
    </div>
  `,

  components: {
    NotificationBar,
  },

  props: {
    queue: {
      type: Array,
      required: true,
    },
  },

  computed: {
    hasQueuedNotification() {
      return this.queue.length > 0;
    },

    activeNotification() {
      return this.queue[0];
    },
  },

  methods: {
    clear() {
      this.queue.shift();
    },
  },
};

window.vm = new Vue({
  components: {
    NotificationCenter,
  },

  el: '#app',

  template: `
    <div>
      <notification-center
        :queue="notifications">
      </notification-center>

      <label>
        <strong>Type</strong> <br>
        Error <input v-model="type" type="radio" name="type" value="error"> <br>
        Warning <input v-model="type" type="radio" name="type" value="warning"> <br>
        Info <input v-model="type" type="radio" name="type" value="info"> <br>
      </label>

      <label>
        <strong>Message</strong>
        <input v-model="message" type="text">
      </label>

      <label>
        <strong>Dismissable</strong>
        <input v-model="dismissable" type="checkbox">
      </label>

      <label>
        <strong>Timeout</strong>
        <input v-model="timeout" type="number" step="100" min="0">
      </label>

      <button @click="generateNotification">Generate notification</button>
    </div>
  `,

  data: {
    notifications: [],

    type: null,
    message: null,
    dismissable: null,
    timeout: null,

    dismissIndex: null,
    dismissMessage: null,
  },

  methods: {
    generateNotification() {
      const {
        type,
        message,
        dismissable,
        timeout,
      } = this;

      const id = Date.now();

      this.notifications.push({
        type,
        message,
        dismissable,
        timeout,
        id,
      });

      this.type = this.message = this.dismissable = this.timeout = null;
    },
  },
});
.notification-bar {
  box-sizing: border-box;
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3.2rem;
  color: #fff;
  font-family: 'Avenir Next', sans-serif;
  font-size: 1.2em;
  line-height: 3.2rem;
  text-align: center;
}
.notification-bar--error {
  background-color: #f02a4d;
}
.notification-bar--warning {
  background-color: #ffc107;
}
.notification-bar--info {
  background-color: #2196f3;
}
.notification-bar.visible-enter, .notification-bar.visible-leave-to {
  top: -3.2rem;
}
.notification-bar.visible-enter-to, .notification-bar.visible-leave {
  top: 0;
}
.notification-bar.visible-enter-active, .notification-bar.visible-leave-active {
  transition: top 266ms ease;
}

/* ================================================================== */
/*                                                                    */
/* ================================================================== */
html {
  font-size: 62.5%;
}

body {
  margin: 0;
  border: 1px solid black;
  font-family: sans-serif;
}

label {
  display: block;
  margin: 2rem 0;
  font-size: 1.4rem;
}

label:first-of-type {
  margin-top: 5rem;
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title></title>
</head>
<body>
  <div id="app"></div>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.1.10/vue.js"></script>
</body>
</html>