木灵鱼儿

木灵鱼儿

阅读:362

最后更新:2021/07/21/ 16:35:54

浅析Element的collapse-transition折叠动画

前言

作为一个前端菜鸡,理解上可能会有诸多不对的地方,望大家指正,本篇文章也是本人这几天苦思冥想的结果,也不能保证所说全对,也算是抛砖引玉了,期待大佬们的指正。

简单了解Render

饿了么的折叠动画源码位于:src/transitions目录下,gayhub地址:链接

由于内容不多,我直接贴出来源码:

import { addClass, removeClass } from 'element-ui/src/utils/dom';

class Transition {
  beforeEnter(el) {
    addClass(el, 'collapse-transition');
    if (!el.dataset) el.dataset = {};

    el.dataset.oldPaddingTop = el.style.paddingTop;
    el.dataset.oldPaddingBottom = el.style.paddingBottom;

    el.style.height = '0';
    el.style.paddingTop = 0;
    el.style.paddingBottom = 0;
  }

  enter(el) {
    el.dataset.oldOverflow = el.style.overflow;
    if (el.scrollHeight !== 0) {
      el.style.height = el.scrollHeight + 'px';
      el.style.paddingTop = el.dataset.oldPaddingTop;
      el.style.paddingBottom = el.dataset.oldPaddingBottom;
    } else {
      el.style.height = '';
      el.style.paddingTop = el.dataset.oldPaddingTop;
      el.style.paddingBottom = el.dataset.oldPaddingBottom;
    }

    el.style.overflow = 'hidden';
  }

  afterEnter(el) {
    // for safari: remove class then reset height is necessary
    removeClass(el, 'collapse-transition');
    el.style.height = '';
    el.style.overflow = el.dataset.oldOverflow;
  }

  beforeLeave(el) {
    if (!el.dataset) el.dataset = {};
    el.dataset.oldPaddingTop = el.style.paddingTop;
    el.dataset.oldPaddingBottom = el.style.paddingBottom;
    el.dataset.oldOverflow = el.style.overflow;

    el.style.height = el.scrollHeight + 'px';
    el.style.overflow = 'hidden';
  }

  leave(el) {
    if (el.scrollHeight !== 0) {
      // for safari: add class after set height, or it will jump to zero height suddenly, weired
      addClass(el, 'collapse-transition');
      el.style.height = 0;
      el.style.paddingTop = 0;
      el.style.paddingBottom = 0;
    }
  }

  afterLeave(el) {
    removeClass(el, 'collapse-transition');
    el.style.height = '';
    el.style.overflow = el.dataset.oldOverflow;
    el.style.paddingTop = el.dataset.oldPaddingTop;
    el.style.paddingBottom = el.dataset.oldPaddingBottom;
  }
}

export default {
  name: 'ElCollapseTransition',
  functional: true,
  render(h, { children }) {
    const data = {
      on: new Transition()
    };

    return h('transition', data, children);
  }
};

先不去看Transition 类,这个js文件就是通过render的方式创建了一个transition元素。

render函数有两个参数,第一个是h,也就是用于创建 VNode元素的方法(createElement),h是通用的也是官方推荐的缩略名称。第二个是context上下文对象,通过上下文对象可以获取当前组件的数据,如props、data、slots等等一系列的数据对象。

context上下文必须是声明为函数式组件才会有

context:

  • props:提供所有 prop 的对象
  • children:VNode 子节点的数组
  • slots:一个函数,返回了包含所有插槽的对象
  • scopedSlots:(2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。
  • data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件
  • parent:对父组件的引用
  • listeners:(2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。
  • injections:(2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的 property。

在饿了么这个transtion中, children就是你使用它时,在其内部书写的元素。

例:

<template>
  <div>
    <el-collapse-transition>
      <!-- 我是children -->
      <div v-if="show">xxxx</div>  
    </el-collapse-transition>
  </div>
<template>
<script>
import ElCollapseTransition from 'element-ui/src/transitions/collapse-transition';
export default {
  data(){
    return {
      show: false,
    }
  },
  components: { 
    ElCollapseTransition 
  },
}
</script>

使用children 就可以省去传统vue文件形式使用插槽来接收内容(不用写vue文件,也就不用写template)。

h函数的参数

  1. 一个 HTML 标签名、组件选项对象,或者resolve 了上述任何一种的一个 async 函数。必填项。 {String | Object | Function}
  2. 一个与模板中 attribute 对应的数据对象。可选。 {String | Array}
  3. 子级虚拟节点 (VNodes),由 createElement() 构建而成,也可以使用字符串来生成“文本虚拟节点”。可选。

具体官方解释:createElement 参数

第一个参数就不多解释了,就是一个标签名,而第二个参数,则是attribute属性,如:click事件绑定,style、class、props这些设置,都是在第二个参数中,第三个参数就是他的子集节点,也就是children

事件的绑定,在render中,是采用on对象的形式,具体参考官方文档: 事件 & 按键修饰符

on: {
  'click': this.doThisInCapturingMode,
  'keyup': this.doThisOnce,
  'mouseover': this.doThisOnceInCapturingMode
}

由于饿了么的折叠动画,使用的是js动画钩子,属于自定义的事件,所以我们的on对象最终应该是这样的:

on: {
  'beforeEnter': function(){},
  'enter': function(){},
  'afterEnter': function(){},
  'beforeLeave': function(){},
  'leave': function(){},
  'afterLeave': function(){},
}

合并一下

export default {
  name: 'ElCollapseTransition',
  functional: true,
  render(h, { children }) {
    const data = {
      on: {
        'beforeEnter': function(){},
        'enter': function(){},
        'afterEnter': function(){},
        'beforeLeave': function(){},
        'leave': function(){},
        'afterLeave': function(){},
      }
    };

    return h('transition', data, children);
  }
};

函数式组件声明的作用

官方推荐在没有管理任何状态,也没有监听任何传递给它的状态,也没有生命周期方法,只是一个接受一些 prop 的函数,推荐使用functional函数式组件!

我们的transition本身也不需要处理任何状态,也没有生命周期,只是prop接受一些自定义事件函数而已。

更重要的一点是,声明functional,h函数才能有第二个上下文件对象参数。才能拿到children。

当然,你使用插槽也可以拿到,但是children是拿到所有,而slots插槽是有具名属性,如果使用了具名,你拿到的可能就是不完整的子节点。

使用插槽

不声明函数式组件

export default {
  name: 'ElCollapseTransition',
  render(h) {
    const data = {
      on: {
        'beforeEnter': function(){},
        'enter': function(){},
        'afterEnter': function(){},
        'beforeLeave': function(){},
        'leave': function(){},
        'afterLeave': function(){},
      }
    };

    return h('transition', data, this.$slots.default);
  }
};

其实这样也是可以的,但是个人更推荐函数式,因为它更快(某种意义上)。


到这,render的了解就差不多了,如何创建transtion,以及子节点处理,数据绑定,为什么要声明函数式组件,想必应该都有了简单了解。

使用class类产生的bug

on监听的事件,在render中使用一个对象存储,那么多事件,绑定的时候必然是for循环,拿到每一个键值对进行绑定,但是使用class,会有一个问题:for循环无法拿到class中的属性方法

为什么?

因为class中,会将beforeEnter这些方法声明为prototype上的属性,并且设置为不可枚举。防止我们设置的方法被for循环时获取到,影响使用。

所以,饿了么这种做法应该是有问题的:

 const data = {
     on: new Transition()
 };

虽然它返回了一个对象,但是这个对象上的属性方法无法被for循环出来,on就无法正确绑定。

为了印证我的猜测,我手动操作了几个属性后,发现on绑定就生效了,代码如下:

export default {
  name: 'ElCollapseTransition',
  functional: true,
  render(h, { children }) {
    const t = new Transition();
    
    console.log(Object.getOwnPropertyDescriptor(t.__proto__, "beforeEnter"));  
    //{writable: true, enumerable: false, configurable: true, value: ƒ}

    //手动设置几个属性可枚举
    Object.defineProperties(t.__proto__, {
      beforeEnter: {
        enumerable: true
      },
      enter: {
        enumerable: true
      },
      afterEnter: {
        enumerable: true
      },
    })

    const data = {
      on: t
    };

    return h('transition', data, children);
  }
};

处理这个bug

我们可以把class改成普通对象即可,或者自己手动设置每个属性的说明对象,将enumerable设为真。

动画-js钩子了解

js动画有8个钩子,也可以理解为动画的生命周期:

  1. beforeEnter 进入-动画开始之前
  2. enter 进入-动画开始
  3. afterEnter 进入-动画结束
  4. enterCancelled 进入-动画被取消
  5. beforeLeave 离开-动画开始之前
  6. leave 离开-动画开始
  7. afterLeave 离开-动画结束
  8. leaveCancelled 离开动画被取消

leaveCancelled钩子只有在使用v-show的时候会触发。

enterCancelled钩子会在动画切换速度很快时,开始动画还未结束就触发离开动画时。

常用的钩子应该就是去除了leaveCancelledenterCancelled,剩下的钩子都能使用。

需要注意一点的是,动画切换过快,动画结束的生命周期是不会触发的:afterEnterafterLeave

所以,一定要在动画开始之前,做好对样式的初始化,比如如果我想让元素的height发生动画,从0到元素实际高度,一定是在beforeEnter 时,先将元素的宽度设为0,然后开始时设为实际高度,或者取消0的设置,这样元素就会有从0到有的一个过渡。

钩子的参数

所有的钩子,第一个参数都是触发动画的元素dom,统称el

enterleave,拥有第二个参数done,done是一个回调函数,如果你在函数上写了这个形参,那么vue就会放弃默认的动画事件监听,只有当你运行done()时,动画才会表示结束,进入到下一个生命钩子。

done可以让你发挥更多的空间,有点像promise的回调,只有触发了才会结束,否则外面将继续等待。

const Transition = {
  beforeEnter(el) {},

  enter(el, done) {},

  afterEnter(el) {},

  beforeLeave(el) {},

  leave(el, done) {},

  afterLeave(el) {},
}

如果不使用done,vue会嗅探你的css中是否存在transition或者animation,然后分别监听对应的动画结束事件:

  1. transitionend
  2. animationend

在事件回调里运行done,进入到下一个动画钩子。

但是,在一些场景中,你需要给同一个元素同时设置两种过渡动效,比如 animation 很快的被触发并完成了,而 transition 效果还没结束。在这种情况中,你就需要使用 type attribute 并设置 animationtransition 来明确声明你需要 Vue 监听的类型。

显然,饿了么的折叠动画没有使用done,我这里说到done,是为了方便我们待会印证一些猜测。

小试牛刀

既然已经明白了钩子的含义,那么我们就可以开始动手了,手动写一个自己的动画,比如宽度动画。

显示组件

<template>
  <div>
    <button @click="show=!show">显隐</button>
    <MuWidthTransition>
      <div v-show="show" class="box">xxx</div>
    </MuWidthTransition>
  <div>
</template>
<script>
import MuWidthTransition from "@/components/default/transitions/width-transition";
export default {
  data() {
    return {
      show: false
    }
  },
  components: {
    MuWidthTransition 
  }
}
</script>
<style lang="scss" scoped>
.box {
  width: 200px;
  height: 200px;
  background-color: red;
}

.mu-width-transition {
  transition: width 0.25s;
}
</style>

动画组件

const Transition = {
  beforeEnter(el) {
    el.classList.add("mu-width-transition");
    el.style.width = "0";
    el.style.overflow = 'hidden';
  },

  enter(el) {
    el.style.width = "";
  },

  afterEnter(el) {
    el.classList.remove('mu-width-transition');

    el.style.width = "";
    el.style.overflow = "";
  },

  beforeLeave(el) {
    el.classList.add("mu-width-transition");
    el.style.width = "";
    el.style.overflow = "hidden";
  },

  leave(el) {
    el.style.width = "0";
  },

  afterLeave(el) {
    el.style.width = "";
    el.style.overflow = '';
    el.classList.remove('mu-width-transition');
  },
}

export default {
  name: 'MuWidthTransition',
  functional: true,
  render(h, { children }) {
    const data = {
      on: Transition
    };

    return h('transition', data, children);
  }
};

我们的逻辑很简单,就是:

  • 动画开始前让元素宽度为0,overflow : "hidden";添加动画过渡的class:mu-width-transition
  • 动画开始时宽度设置取消,元素宽度开始还原
  • 动画结束时删除过渡class,overflow 清空
  • 离开之前添加过渡class,宽度清空,overflow : "hidden";
  • 离开时宽度为0,元素开始收缩
  • 离开结束,元素宽度清空,overflow 清空,删除过渡class

理论上,我们这路子是没错。

但是,你会得到这么一个结果:进入时无动画,离开时有动画,在离开时再点击进入,有动画

Way???

小试牛刀-失败的原因

仔细回想下以前写css动画时,是不是有那么一个经验,在元素display:none转为block时,先设置display再延迟一会,设置css样式或者添加class。元素才会有动画,会不会是这个问题。

我们改改:

enter(el, done) {
    setTimeout(() => {
        el.style.width = "";
        setTimeout(() => {
            done();
        }, 250)
    }, 20)
},

利用setTimeout我们延迟20ms触发width设置,然后等待动画完成触发done。

效果确实可以:

是不是觉得,果然如此,但是,“向来如此”便是对的吗?

为什么我们需要延迟,而饿了么的不需要,它有什么神奇之处?

为什么我们需要延迟?

我们要知道,css的动画,是一帧一帧进行的,每帧的间隔大约在17ms,但是不同浏览器这个时间也会有所不同,可能会更短。

而js代码运行是很快的,多行代码的运行完成可能都不需要1ms,甚至是0.xxxms。

然而就是因为太快,导致元素被瞬间加上css样式,动画的帧率都来不及反应,导致我们的动画失效了,理论知识没错,但是就是太快。

打个比方就是:这个小家伙一出生就死了,都没来得及哭出声!

更深层的理解,我个人想法:

当元素被渲染时,在这一帧还未触发时,宽度变成0,然后瞬间又清空宽度设置,而这一帧的渲染会按照最终的样式结果渲染,此时元素的宽度设置和未设置之前一样,所以不会有动画产生,一毛一样还怎么动画,直接显示就完了。

收缩时有动画?

宽度已经有了,我们只是让他收缩,并不对动画帧产生影响,正常渲染。

为什么饿了么动画不需要延时?

不知道饿了么有意还是无意,下了一记神仙手,这一招让整盘棋都活了过来。不过在了解之前,我们先了解下:浏览器的回流与重绘

参考掘金的这篇文章:《浏览器的回流与重绘 (Reflow & Repaint)》

其中js也能让浏览器产生回流:

  • clientWidthclientHeightclientTopclientLeft
  • offsetWidthoffsetHeightoffsetTopoffsetLeft
  • scrollWidthscrollHeightscrollTopscrollLeft
  • scrollIntoView()scrollIntoViewIfNeeded()
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

回流会触发重绘,会使得浏览器清空当前元素的计算队列,重新计算。这样我们js才能得到准确的结果。

我们再回过头看下饿了么的代码,其中就有el.scrollHeight这个字眼,很明显,scrollHeight会触发浏览器的回流。

那么回流在这里做了什么?

回流会重新计算元素的样式,其中就包括你在动画开始前添加的样式,height="0";而且因为重新计算,会在同步线程上产生一些延迟,而这个元素的上一帧最终高度将为0,下一帧时,我们js设置高度为指定高度或者清空,此时会产生两个不同的结果,他们之前产生了差别,动画也就产生了。

大试牛刀

既然我们搞明白了原因,是时候出手了

动画组件

const Transition = {
  beforeEnter(el) {
    el.classList.add("mu-width-transition");
    el.style.width = "0";
    el.style.overflow = 'hidden';
  },

  enter(el) {
    el.offsetWidth;
    el.style.width = "";
  },

  afterEnter(el) {
    el.classList.remove('mu-width-transition');

    el.style.width = "";
    el.style.overflow = "";
  },

  beforeLeave(el) {
    el.classList.add("mu-width-transition");
    el.style.width = "";
    el.style.overflow = "hidden";
  },

  leave(el) {
    el.style.width = "0";
  },

  afterLeave(el) {
    el.style.width = "";
    el.style.overflow = '';
    el.classList.remove('mu-width-transition');
  },
}

export default {
  name: 'MuWidthTransition',
  functional: true,
  render(h, { children }) {
    const data = {
      on: Transition
    };

    return h('transition', data, children);
  }
};

效果很满意,完全达到我们的预期。

我们也充分利用了危险,毕竟,回流和重绘,一直是前端书写时需要避免的一个巨坑,但是知己知彼,敌人也能成为某种意义上的朋友。帮我们一把。

小机灵鬼(等待大佬解答)

这里就会有人想了,既然回流会让帧的最终结果改变,那么我们在动画之前,所有属性配置完毕再回流一下岂不美哉。

 beforeEnter(el) {
     el.classList.add("mu-width-transition");
     el.style.width = "0";
     el.style.overflow = 'hidden';
     el.offsetWidth;
 },

 enter(el) {
     el.style.width = "";
 },

事实上这么干反倒没有了动画效果,beforeEnter和enter显然不是同一个任务,这就应该会涉及到js的运行方面的机制,个人是个菜鸡,也不是很懂,盲目分析了一下:

offsetWidth触发回流重绘后,浏览器会触发元素重新计算,但是beforeEnter和enter不是同一个任务,虽然offsetWidth会延迟,但是和js运行不是同一个管道,加上js很快,唰的一下就给改了,导致offsetWidth重新计算时width又成了"",动画效果也就没了。

而enter钩子中,offsetWidth和el.style.width = ""一起写时,因为是同一个任务,所以el.style.width = ""会等待前面的搞定我再运行。

还有一个原因是,都在beforeEnter处理,太快了,offsetWidth这些计算就算重绘完了,帧也没开始。

毕竟按道理,我们在beforeEnter也可以触发动画,只要在帧数之前得到初始化好的数据,然后再后面的帧数再得到一个不同的结果。

 beforeEnter(el) {
     el.classList.add("mu-width-transition");
     el.style.width = "0";
     el.style.overflow = 'hidden';
     el.offsetWidth;
     el.style.width = "";
 },

 enter(el) {
     
 },

这样写,也不能产生动画,因为忽略了另一个因素,动画帧率时间。你就算重新生成了新的结果,我帧率来不及反应,也没用。

为了判断这个想法,我输出了时间戳:

 beforeEnter(el) {
     el.classList.add("mu-width-transition");
     el.style.width = "0";
     el.style.overflow = 'hidden';
     
     console.time("off")
     el.offsetWidth;
     console.timeEnd("off")

     el.style.width = "";
 },

 enter(el) {
     
 },

offsetWidth的用时:0.136962890625 ms;无动画

我改用promise+setTimeout+asyns来做延迟处理

 async beforeEnter(el) {
     el.classList.add("mu-width-transition");
     el.style.width = "0";
     el.style.overflow = 'hidden';
     
     console.time("off")
     await new Promise((resolve, reject) => {
      setTimeout(() => {
        el.style.width = "200px";
        resolve();
      }, 1)
    })
     console.timeEnd("off")

     el.style.width = "";
 },

await 用时:1.535888671875 ms;有动画

那么我们再计算下之前动画的用时

beforeEnter(el) {
    el.classList.add("mu-width-transition");
    el.style.width = "0";
    el.style.overflow = 'hidden';

    console.time("off")
  },

  enter(el) {
    el.offsetWidth;
    console.timeEnd("off")
    
    el.style.width = "";

    
  },

用时:0.3779296875 ms

很明显,在beforeEnter处进行回流,速度是非常快的,太快了,导致帧率没跟上,后面的写法,时间上都稍微慢了一些,动画就出现了。

总结

动画的产生有两个条件:

  1. 前后结果要不同
  2. 要在帧率合理的反应时间内,不能太快

那么我们也能解释的通,为啥元素display由none变为block时,添加的css变化不会有动画产生,因为太快了,前一帧的结果与后面的帧数结果相同,无法产生动画,none时元素也不会渲染,计算都在显示的那一瞬间完成。

估计这也是为啥,元素显示状态时,动画随便加都有效果的原因吧!

版权申明

本文系作者 @木灵鱼儿 原创发布在木灵鱼儿 - 有梦就能远航站点。未经许可,禁止转载。

关于作者

站点职位 博主
获得点赞 1
文章被阅读 362

相关文章