手摸手实现Transition

xdm好,我是剑大瑞。

本篇内容旨在通过本人实现Transition组件,从而理解其外部原理。

如果你还没有应用过Transition组件或者对其不相熟,那么我倡议你能够先学习官网文档,写一些demo,当相熟了Transition组件之后,然而又对其原理有所好奇,就能够再回来学习这篇文章。官网文档传送门。

前言

通过官网文档能够晓得,当应用Transition组件的时候,咱们能够通过配置Transition组件的props管制组件的进场过渡、离场过渡状态、动画成果。

配置props的过程中,重要的是指定nameVue会将name字段与不同的过渡阶段名称进行组合,在不同的阶段为咱们的dom增加类名或者移除类名。

这里借用官网的示意图:

这张图片对于Transition组件的过渡成果形容十分确切了:

  • 当组件挂载的时候,classv-enter-from过渡为v-enter-to。切换的两头过程咱们称它为v-enter-active
  • 当组件卸载的时候,classv-leave-from过渡为v-leave-to。切换的过程咱们称它为v-leave-active
  • 在由enter-from⇒enter-to或者leave-from⇒leave-to的阶段,咱们能够指定组件的初始和最终款式。在enter-active & leave-active阶段咱们能够指定组件的过渡或者动画成果。

首先咱们须要调用defineComponent API来定义一个MyTransition组件,通过setup获取插槽中的内容。

这外面有两点须要思考:

  1. MyTransition只会把过渡成果利用到其包裹的内容上,而不会额定渲染 DOM 元素,也不会呈现在可被查看的组件层级中。

    就是说组件并不需要有本人的template,只做插槽的搬用工。

  2. MyTransition组件并不需要有本人的状态,只需将用户传入的props解决后,再将解决后的newProps传给子组件即可。

    就是说MyTransition组件并不需要有本人的状态,只做状态的搬运工。

Props设计

然而咱们怎么设计props呢?

思考这个问题,还须要回到Transition组件的外围逻辑在于:

  • 在组件的挂载阶段,咱们须要将enter-fromenter-to阶段的过渡或者动画成果class附加到DOM元素上。
  • 在组件的卸载阶段,咱们须要将leave-fromleave-to阶段的过渡或者动画成果class附加到DOM元素上。

那咱们是否须要通过mountedunmounted API钩子中实现class的移除和增加呢?

答案:其实不须要。在Vue 中的Transition组件是与渲染器的patch逻辑高度依赖的。

渲染器解决形式

在渲染器中,能够在mountElement函数中,解决Enter阶段的过渡或者动画成果。在remove函数中解决Leave阶段的过渡或者动画成果。

这里咱们在此简略看下这两个函数的代码:

  • mountElement函数简略版,mountElement函数负责挂载元素。
// 挂载元素节点const mountElement = (vnode,...args) => {  let el;  let vnodeHook;  const { type, props, shapeFlag, transition, patchFlag, dirs } = vnode;  // 省略局部代码...  *if (needCallTransitionHooks*) {            // 执行过渡钩子      transition.beforeEnter(el);  }  // 省略局部代码...  if ((vnodeHook = props && props.onVnodeMounted) ||      needCallTransitionHooks ||      dirs) {      // post 各种钩子 至后置执行工作池      queuePostRenderEffect(() => {         // 执行过渡动画钩子        needCallTransitionHooks && transition.enter(el);       }, parentSuspense);  }};
  • remove函数简略版,remove函数次要负责从父元素中移除元素。
// 移除Vnodeconst remove = vnode => {  const { type, el, anchor, transition } = vnode;  // 省略局部代码...  const performRemove = () => {      hostRemove(el);      if (transition && !transition.persisted && transition.afterLeave) {          // 执行transition钩子          transition.afterLeave();      }  };  if (vnode.shapeFlag & 1 /* ELEMENT */ &&      transition &&      !transition.persisted) {      const { leave, delayLeave } = transition;            // 执行lea      const performLeave = () => leave(el, performRemove);      if (delayLeave) {          delayLeave(vnode.el, performRemove, performLeave);      }      else {          performLeave();      }  }};
  • move函数简略版,move函数次要负责元素的挪动,插入父元素。
const move = (vnode, container, anchor, moveType, parentSuspense = null) => {  const { el, type, transition, children, shapeFlag } = vnode;  // 省略局部代码...    if (needTransition) {      if (moveType === 0 /* ENTER */) {                    // 执行过渡钩子          transition.beforeEnter(el);          hostInsert(el, container, anchor);          queuePostRenderEffect(() => transition.enter(el), parentSuspense);      } else {          const { leave, delayLeave, afterLeave } = transition;          const remove = () => hostInsert(el, container, anchor);          const performLeave = () => {              leave(el, () => {                  remove();                  afterLeave && afterLeave();              });          };          if (delayLeave) {              delayLeave(el, remove, performLeave);          }          else {              performLeave();          }      }  }  // 省略局部代码...};

通过下面的代码,能够晓得,Vue3是通过渲染器执行Transition组件自定义的钩子函数,来实现过渡成果的管制的。

所以咱们能够通过为props定义钩子函数,并绑定到transition组件,在元素的patch阶段,执行钩子函数,从而实现对动效的管制。

Javascript钩子解决props

为此咱们能够参考官网文档中的JavaScript钩子局部,为props定义Enter & Appear & Leave阶段的钩子。

在钩子函数中操作动效class的移除或增加操作。

const MyTransition = defineComponent({  name: 'MyTransition',  props: {    name: {      type: String,      default: 'v'    },    type: String,    css: {      type: Boolean,      default: true    },    duration: [String, Number, Object],    enterFromClass: String,    enterActiveClass: String,    enterToClass: String,    appearFromClass: String,    appearActiveClass: String,    appearToClass: String,    leaveFromClass: String,    leaveActiveClass: String,    leaveToClass: String  },  setup(props, { slots }) {    const children = slots.default()    const newProps = {}        for (const key in props) {      newProps[key] = props[key]      }        const {      name = 'v',      type,      duration,      enterFromClass = `${name}-enter-from`,      enterActiveClass = `${name}-enter-active`,      enterToClass = `${name}-enter-to`,      appearFromClass = enterFromClass,      appearActiveClass = enterActiveClass,      appearToClass = enterToClass,      leaveFromClass = `${name}-leave-from`,      leaveActiveClass = `${name}-leave-active`,      leaveToClass = `${name}-leave-to`    } = props            // 为newProps绑定够子函数    Object.assign(newProps, {      // Enter阶段      onBeforeEnter(el) {      },      onEnter(el) {      },      onAfterEnter(el) {      },      onEnterCancelled(el) {        },            // Apear阶段            onBeforeAppear(el) {            },            onAppear(el) {            },            onAppearCancelled(el) {            },      // Leave阶段      onLeave(el) {      },      onLeaveCancelled(el) {      },    })            // 为子元素绑定通过解决的newProps    return h(children, newProps, null)  }})

通过下面的代码,能够晓得,通过解构props,组合成各动效阶段的class

钩子函数都会承受一个el参数,它代表以后须要进行增加过渡动效的DOM,由渲染器在patch阶段传入。

接下来的工作就是在JavaScript钩子函数中,操作class

欠缺钩子函数

Javascript钩子函数的主要职责是为el增加或者移除动效class

然而咱们须要先明确每个类应该在何时增加?何时移除?

在进入/来到的过渡中,会有 6 个 class 切换。

  1. v-enter-from:定义进入过渡的开始状态。在元素被插入之前失效,在元素被插入之后的下一帧移除
  2. v-enter-active:定义进入过渡失效时的状态。在整个进入过渡的阶段中利用,在元素被插入之前失效,在过渡/动画实现之后移除。这个类能够被用来定义进入过渡的过程工夫,提早和曲线函数。
  3. v-enter-to:定义进入过渡的完结状态。在元素被插入之后下一帧失效 (与此同时 v-enter-from 被移除),在过渡/动画实现之后移除
  4. v-leave-from:定义来到过渡的开始状态。在来到过渡被触发时立即失效,下一帧被移除
  5. v-leave-active:定义来到过渡失效时的状态。在整个来到过渡的阶段中利用,在来到过渡被触发时立即失效,在过渡/动画实现之后移除。这个类能够被用来定义来到过渡的过程工夫,提早和曲线函数。
  6. v-leave-to:来到过渡的完结状态。在来到过渡被触发之后下一帧失效 (与此同时 v-leave-from 被移除),在过渡/动画实现之后移除


由此可知,咱们须要:

  • onBeforeEnter函数中实现enterFromClass & enterActiveClass增加工作。
  • onEnter函数中实现下一帧绘制的间隙,实现enterFromClass的移除,enterToClass的增加工作。
  • Enter阶段的动画完结之后须要实现enterActiveClass & enterToClass移除工作。

为了不便class的增加 || 移除操作咱们能够先定义两个用于操作class的函数,不便在多个钩子中应用。

// 增加类function addTransitionClass(el, cls) {  cls.split(/\s+/).forEach(c => c && el.classList.add(c))}// 移除类function removeTransitionClass(el, cls) {  cls.split(/\s+/).forEach(c => c && el.classList.remove(c))}

通过下面两个函数,能够实现onBeforeEnter & onEnter钩子:

setup() {    // 省略局部代码...    Object.assign(baseProps, {      // 传入通过解决后的 props      // Enter      onBeforeEnter(el) {                // 增加class...        addTransitionClass(el, enterFromClass)        addTransitionClass(el, enterActiveClass)      },      onEnter(el) {        // 在下一帧执行的时候移除class         requestAnimationFrame(() => {                    // 移除enterFromClass                    removeTransitionClass(el, enterFromClass)          // 而后增加新的enterToClass          addTransitionClass(el, enterToClass)        })      },      // 省略局部代码...    })}

两个问题

下面的代码会有两个问题:

  1. requestAnimationFrame中的回调函数真的能如咱们所冀望的那样在下一帧中执行吗?
  2. 如何实现动效完结之后,对class的移除?

先说第一个问题,答案是否定的。requestAnimationFrame中的回调,会在以后帧就实现执行。那是为什么呢?

通过查阅MDN,能够晓得。通过requestAnimationFrame注册的回调函数通常会在浏览器下一次重绘之前执行,而不是在下一帧中执行。

如果想在浏览器下次重绘之前持续更新下一帧动画,那么回调函数本身必须再次调用window.requestAnimationFrame()

为了实现在下一帧中对class的移除 && 增加。须要将onEnter中的代码改写为:

setup() {    // 省略局部代码...    Object.assign(baseProps, {      onEnter(el) {        // 在下一帧执行的时候移除class         requestAnimationFrame(() => {                    requestAnimationFrame(() => {                        // 移除enterFromClass                        removeTransitionClass(el, enterFromClass)              // 而后增加新的enterToClass              addTransitionClass(el, enterToClass)            })        })      },      // 省略局部代码...    })}

第二个问题:移除动效class当为DOM增加class之后,就会触动员效。触发之后咱们能够通过监听

transitionend事件或者animationend事件,而后移除动效class

持续改写onEnter函数:

onEnter(el) {  // 定义一个供addEventListener执行的回调函数  const resolve = () => {    removeTransitionClass(el, enterToClass)    removeTransitionClass(el, enterActiveClass)  }  // 在下一帧执行的时候移除class   requestAnimationFrame(() => {    requestAnimationFrame(() => {      removeTransitionClass(el, enterFromClass)       addTransitionClass(el, enterToClass)      // 监听动效完结事件,type由props传入      el.addEventListener(`${type}end`, resolve)    })  })},// 省略局部代码...

至此就实现Enter阶段的两个钩子函数。

同样的逻辑,咱们能够实现Leave阶段的钩子函数。

onLeave(el) {  // 定义resolve回调  const resolve = () => {        removeTransitionClass(el, leaveToClass)    removeTransitionClass(el, leaveActiveClass)    }    // 间接增加leaveFromClass  addTransitionClass(el, leaveFromClass)  addTransitionClass(el, leaveActiveClass)    // 来到阶段的下一帧中移除class  requestAnimationFrame(() => {    requestAnimationFrame(() => {      removeTransitionClass(el, leaveFromClass)      addTransitionClass(el, leaveToClass)      el.addEventListener(`${type}end`, resolve)    })  })}

Enter阶段不同的是Leave阶段的fromClass & activeClass并没有在beforeOnLeave阶段进行,而是间接在onLeave阶段开始。

这就有一个问题,咱们间接增加的leaveFromClass并不能让动效立刻失效,这波及到一个issue

相干链接

  • issue: https://github.com/vuejs/core...
  • 复现链接:https://codesandbox.io/s/comp...

其粗心是:当在初始阶段通过state管制元素的style做暗藏或者显示时,Transition组件Leave阶段动效并没有按合乎预期的成果进行转换。

为此咱们须要在增加了leaveFromClass后,通过强制触发一次强制reflow,使 -leave-from classes能够立刻失效。

onLeave(el, done) {  const resolve = () => {        removeTransitionClass(el, leaveToClass)    removeTransitionClass(el, leaveActiveClass)    }    // 间接增加leaveFromClass  addTransitionClass(el, leaveFromClass)    // 通过读取offsetHeight实现强制reflow  document.body.offsetHeight  addTransitionClass(el, leaveActiveClass)  requestAnimationFrame(() => {    requestAnimationFrame(() => {      removeTransitionClass(el, leaveFromClass)      addTransitionClass(el, leaveToClass)      el.addEventListener(`${type}end`, resolve)    })  })}

onLeaveCancelled钩子仅用于v-show中,会勾销leaveActive & leaveTo的动效。这个实现并不简单。

onLeaveCancelled(el) {    removeTransitionClass(el, leaveToClass)  removeTransitionClass(el, leaveActiveClass)}

至此,咱们曾经实现了Enter & Leave阶段的动效钩子实现。

接下来还须要实现Appear阶段的钩子函数。Appear钩子函数的调用逻辑为当用户为props配置了appear = true时,则会在初始渲染阶段就出动员效。

其实现逻辑与Enter阶段根本一样:

onBeforeAppear(el) {    addTransitionClass(el, appearFromClass)    addTransitionClass(el, appearActiveClass)},onAppear(el: Element) {    // 定义resolve函数    const resolve = () => {        removeTransitionClass(el, appearToClass)        removeTransitionClass(el, appearActiveClass)      }    // 在下一帧执行的时候移除class    // 如果isApper为true移除from否则移除enter        requestAnimationFrame(() => {        requestAnimationFrame(() => {                removeTransitionClass(el, appearFromClas)                addTransitionClass(el, appearToClass )          el.addEventListener(`${type}end`, resolve)    })  })}onAppearCancelled(el) {   removeTransitionClass(el, appearToClass)     removeTransitionClass(el, appearActiveClass)},

至此咱们曾经实现了Enter Appear Leave阶段的钩子定义。然而会发现代码中会有很多冗余。代码逻辑有很多反复之处。为此咱们能够将代码进行优化。

重构

  1. 将过渡开始须要增加class的局部抽离为startBefore,将过渡完结后须要移除class的局部抽离为finishEnterfinishLeave函数,通过参数isAppear来判断增加或者移除哪些class
const startBefore = (el, isAppear) => {  addTransitionClass(el, isAppear ? appearFromClass : enterFromClass);  addTransitionClass(el, isAppear ? appearActiveClass : enterActiveClass);};const finishEnter = (el, isAppear) => {  removeTransitionClass(el, isAppear ? appearToClass : enterToClass);  removeTransitionClass(el, isAppear ? appearActiveClass : enterActiveClass);};const finishLeave = (el) => {  removeTransitionClass(el, leaveToClass);  removeTransitionClass(el, leaveActiveClass);};
  1. 将嵌套的requestAnimationFrame抽离为nextFrame函数。
function nextFrame(cb) {  requestAnimationFrame(() => {      requestAnimationFrame(cb);  });}
  1. 将监听transitionend & animationend事件的逻辑抽离为whenTransitionEnds函数
function whenTransitionEnds(el, type, resolve) {  const endEvent = type + ‘end’    const end = () => {        // 每次监听时,先移除原有的监听事件      el.removeEventListener(endEvent, onEnd);      resolve();    };    const onEnd = (e) => {      if (e.target === el) {      end();      }    };  el.addEventListener(endEvent, onEnd);}
  1. onEnteronAppear函数逻辑存在反复之处,咱们能够定义一个高阶函数,用于返回钩子函数。
const makeEnterHook = (isAppear) => {  return (el) => {      const hook = isAppear ? onAppear : onEnter;      const resolve = () => finishEnter(el, isAppear, done);      nextFrame(() => {          removeTransitionClass(el, isAppear ? appearFromClass : enterFromClass;          addTransitionClass(el, isAppear ? appearToClass : enterToClass);                    whenTransitionEnds(el, type, resolve);      });}

调用函数重构MyTransition

function whenTransitionEnds(el, type, resolve) {  const endEvent = type + ‘end’    const end = () => {        // 每次监听时,先移除原有的监听事件      el.removeEventListener(endEvent, onEnd);      resolve();    };    const onEnd = (e) => {      if (e.target === el) {      end();      }    };  el.addEventListener(endEvent, onEnd);}function nextFrame(cb) {  requestAnimationFrame(() => {      requestAnimationFrame(cb);  });}const MyTransition = defineComponent({  name: 'MyTransition',  props: {    name: {      type: String,      default: 'v'    },    type: String,    css: {      type: Boolean,      default: true    },    duration: [String, Number, Object],    enterFromClass: String,    enterActiveClass: String,    enterToClass: String,    appearFromClass: String,    appearActiveClass: String,    appearToClass: String,    leaveFromClass: String,    leaveActiveClass: String,    leaveToClass: String  },  setup(props, { slots }) {    const children = slots.default()    const newProps = {}    const {      name = 'v',      type,      duration,      enterFromClass = `${name}-enter-from`,      enterActiveClass = `${name}-enter-active`,      enterToClass = `${name}-enter-to`,      appearFromClass = enterFromClass,      appearActiveClass = enterActiveClass,      appearToClass = enterToClass,      leaveFromClass = `${name}-leave-from`,      leaveActiveClass = `${name}-leave-active`,      leaveToClass = `${name}-leave-to`    } = props    const startBefore = (el, isAppear) => {      addTransitionClass(el, isAppear ? appearFromClass : enterFromClass);      addTransitionClass(el, isAppear ? appearActiveClass : enterActiveClass);    };        const finishEnter = (el, isAppear) => {      removeTransitionClass(el, isAppear ? appearToClass : enterToClass);      removeTransitionClass(el, isAppear ? appearActiveClass : enterActiveClass);    };    const finishLeave = (el) => {      removeTransitionClass(el, leaveToClass);      removeTransitionClass(el, leaveActiveClass);    };    const makeEnterHook = (isAppear) => {      return (el) => {        const hook = isAppear ? onAppear : onEnter;        const resolve = () => finishEnter(el, isAppear, done);        nextFrame(() => {            removeTransitionClass(el, isAppear ? appearFromClass : enterFromClass;            addTransitionClass(el, isAppear ? appearToClass : enterToClass);            whenTransitionEnds(el, type, resolve)        });    }    Object.assign(newProps, {      onBeforeEnter(el) {         startBefore(el, false)      },      onBeforeAppear(el) {           startBefore(el, true)      },      onEnter: makeEnterHook(false),      onAppear: makeEnterHook(true),      onLeave(el) {        const resolve = () => finishLeave(el);        addTransitionClass(el, leaveFromClass);                document.body.offsetHeight;            addTransitionClass(el, leaveActiveClass);        nextFrame(() => {            removeTransitionClass(el, leaveFromClass);            addTransitionClass(el, leaveToClass);                    whenTransitionEnds(el, type, resolve);        });      },      onEnterCancelled(el) {          finishEnter(el, false);      },      onAppearCancelled(el) {          finishEnter(el, true);      },      onLeaveCancelled(el) {          finishLeave(el);      }    })    return h(children, newProps, null)  }})

通过重构后,代码简洁了很多。在来张图片总结下上述过程。

持续时间实现

这里还有一个小性能须要实现,就是设置显性的过渡持续时间。

当用户设置duration属性的时候,能够使其中一些嵌套的外部元素相比于过渡成果的根元素具备提早的或更长的过渡成果。

应用的时候,你能够用 <transition> 组件上的 duration prop 显式指定过渡持续时间 (以毫秒计):

<transition :duration="1000">...</transition>

你也能够别离指定进入和来到的持续时间:

<transition :duration="{ enter: 500, leave: 800 }">...</transition>

这意味着,用户能够传单个工夫或者以对象的模式,执行Enter阶段 & Leave阶段的过渡工夫。

那如何实现一个继续的成果呢?

让咱们回顾先原来的逻辑。

在通常状况下,咱们会通过监听transitionend || animationend事件。来移除动效class。当初咱们须要期待durationTime之后能力移除。

那咱们能够期待duration之后,再移除动效class。能够应用setTimeout来实现这个继续成果,只需将durationTime传入whenTransitionEnds函数。whenTransitionEnds函数通过调用setTimeout来开启一个延时工作,期待duration之后,执行移除class的回调。接下来略微调整一下代码逻辑即可。

// 定义normalizeDuration函数function normalizeDuration(duration) {  if (duration == null) {      return null;  } else if (isObject(duration)) {      return [NumberOf(duration.enter), NumberOf(duration.leave)];  } else {      const n = NumberOf(duration);      return [n, n];  }}// 在setup函数中,对duration进行标准解决const durations = normalizeDuration(duration)const enterDuration = durations && durations[0]const leaveDuration = durations && durations[1]

改写makeEnterHook && onLeave && whenTransitionEnds函数:

const makeEnterHook = (isAppear) => {   return (el) => {      // 省略局部代码...      whenTransitionEnds(el, type, enterDuration, resolve)   }}onLeave(el) {     // 省略局部代码...   whenTransitionEnds(el, type, leaveDuration, resolve)}function whenTransitionEnds(el, type, explicitTimeout,resolve) {    // 省略局部代码...    const resolveIfNotStale = () => {       resolve()  }    // 如果存在继续过渡工夫,间接通过setTimeout来判断    if (explicitTimeout) {    return setTimeout(resolveIfNotStale, explicitTimeout)  }    // 省略局部代码...    const end = () => {        // 每次监听时,先移除原有的监听事件      el.removeEventListener(endEvent, onEnd);      resolveIfNotStale();    };  const onEnd = (e) => {      if (e.target === el) {      end();      }    };  el.addEventListener(endEvent, onEndd)}

通过改写whenTransitionEnds函数能够晓得,当设置duration时,先判断explicitTimeout是否存在,如果存在,间接通过setTimeout来实现提早移除class

JavaScript钩子实现

VueTransition组件除了能够应用css来管制组件的动效,还能够通过JavaScript来管制。

当动效须要应用JavaScript管制时,须要在methods中配置相应的钩子函数。

如果须要通过JavaScript管制整个动效过程,须要在props中设置,css = false

然而再开始JavaScript钩子之前,咱们做一些调整。

通过后面的代码,能够发现,咱们的MyTransition的大部分逻辑其实是在解决props,定义钩子函数。

拆散

接下来为了让代码不那么臃肿,咱们能够在设计一个MyTransitionBase组件,该组件次要负责:

  • 将钩子函数挂载至DOM
  • 实现动效过渡模式

开始吧。

// 定义钩子类型校验const TransitionHookValidator = [Function, Array];const MyTransitionBase = defineComponent({  name: `MyTransitionBase`,  props: {      mode: String,      appear: Boolean,      // enter      onBeforeEnter: TransitionHookValidator,      onEnter: TransitionHookValidator,      onAfterEnter: TransitionHookValidator,      onEnterCancelled: TransitionHookValidator,      // leave      onBeforeLeave: TransitionHookValidator,      onLeave: TransitionHookValidator,      onAfterLeave: TransitionHookValidator,      onLeaveCancelled: TransitionHookValidator,      // appear      onBeforeAppear: TransitionHookValidator,      onAppear: TransitionHookValidator,      onAfterAppear: TransitionHookValidator,      onAppearCancelled: TransitionHookValidator  },  setup(props, { slots }) {            // 返回一个渲染函数      return () => {                    // 获取子节点          const children = slots.default          if (!children || !children.length) {              return;          }                                    // 只为单个元素/组件绑定过渡成果          const child = children[0];                                        // 接下来在这里实现子节点钩子函数挂载和设置过渡模式的实现                    return child;      };  }};)

咱们须要再解决下MyTransition组件。MyTransition组件仅负责props的解决,在MyTransition组件中,会将class动效转为JavaScript动效钩子,如果用户告诉绑定JavaScript钩子,只需在Javascript钩子函数中调用配置的钩子即可。

import { h } from 'vue'// 将MyTransition转为函数式组件const MyTransition = (props, { slots }) => h(MyTransitionBase, resolveMyTransitionProps(props), slots);// 定义一个callHook函数用于执行JavaScript钩子const callHook = (hook, args = []) => {    if (isArray(hook)) {        hook.forEach(h => h(...args));    } else if (hook) {        hook(...args);    }};// 定义resolveMyTransitionProps,负责props解决function resolveMyTransitionProps(rawProps) {  const newProps = {};    // 将rawProps上的属性全副从新绑定至newProps  for (const key in rawProps) {      newProps[key] = rawProps[key];  }    // 如果仅应用javascript钩子管制动效,那么间接返回newProps  if (rawProps.css === false) {      return newProps;  }  // 省略局部代码...    // 解构出JavaScript钩子  const { onBeforeEnter, onEnter, onEnterCancelled, onLeave, onLeaveCancelled, onBeforeAppear = onBeforeEnter, onAppear = onEnter, onAppearCancelled = onEnterCancelled } = newProps;        const makeEnterHook = (isAppear) => {    return (el, done) => {            // 省略局部代码...        callHook(hook, [el, resolve])      };  };  return extend(newProps, {      onBeforeEnter(el) {                    // 省略局部代码...          callHook(onBeforeEnter, [el]);      },      onBeforeAppear(el) {                    // 省略局部代码...          callHook(onBeforeAppear, [el]);      },      onEnter: makeEnterHook(false),      onAppear: makeEnterHook(true),      onLeave(el, done) {                    // 省略局部代码...          callHook(onLeave, [el, resolve]);      },      onEnterCancelled(el) {                    // 省略局部代码...          callHook(onEnterCancelled, [el]);      },      onAppearCancelled(el) {                // 省略局部代码...                          callHook(onAppearCancelled, [el]);      },      onLeaveCancelled(el) {                    // 省略局部代码...          callHook(onLeaveCancelled, [el]);      }  });}
下面代码省略的局部为原来就有的,调整的只是新增的局部。

从下面的代码,能够发现:

  1. 因为在后面咱们说过MyTransition组件没有本人的状态,所以咱们能够通过渲染函数将其定义为一个函数式组件。
  2. 定义了一个resolveMyTransitionProps函数,用于做props的解决。
  3. 如果用于配置的css = false,能够间接返回newProps
  4. 用户同时应用css & JavaScript钩子实现动效时,须要callHook函数调用解构进去的钩子函数。

解决MyTransitionBase

拆分后MyTransitionBase组件次要负责JavaScript钩子的调用。

MyTransition组件为class动效与JavaScript钩子做了层兼容合并解决,最终都以JavaScript钩子的模式传递给MyTransitionBase组件。

接下来咱们在MyTransitionBase组件中实现Javascipt钩子与子节点的绑定。

然而在绑定之前,咱们须要在剖析下Enter 阶段 & Appear阶段动效的区别和分割。

Appear阶段的钩子调用次要通过用户是否为props配置appear属性判断。

appear属性用于判断是否在初始渲染时应用动效。

在通常状况下,appear = false

当用户为appear = true时,会在初始阶段就利用动效。

那么咱们如何判断是初始阶段呢?

这里也不再绕弯子了。咱们能够在MyTransitionBase组件beforeEnter & enter阶段 钩子中,通过判断是否MyTransitionBase曾经mounted,来判断是否是初始渲染状态。

如果没有挂载,则咱们在beforeEnter钩子中执行props中传递的onBeforeEnter钩子即可。

如果曾经实现挂载,并且用户传递的appear = true,则执行onBeforeAppear || onBeforeEnter

同样的逻辑实用于enter阶段:

  • MyTransitionBase组件挂载执行onEnter钩子
  • 否则执行onAppear钩子
import { onMounted, onBeforeUnmount } from 'vue'const MyTransitionBase = defineComponent({  // 省略局部代码...  setup(props, { slots }) {            // 定义一个state用于记录MyTransitionBase是否实现挂载 | 卸载      const state = {        isMounted: false,        isUnmounting: false,      }      onMounted(() => {        state.isMounted = true      })      onBeforeUnmount(() => {        state.isUnmounting = true      })            // 返回一个渲染函数      return () => {        // 获取子节点        const children = slots.default        if (!children || !children.length) {          return;        }        // 只为单个元素/组件绑定过渡成果        const child = children[0];                // 获取Enter阶段钩子        const hooks = resolveTransitionHooks(          child,          props,          state        )        // 将钩子绑定至子节点的 transition 属性                // 当渲染器渲染的时候会调用Hooks        setTransitionHooks(child, hooks)        return child;      };  }})

定义resolveTransitionHooks函数,负责解析动效hooks

// 负责解析Hooksfunction resolveTransitionHooks(vnode, props, state) {  const {     appear,     mode,     persisted = false,     onBeforeEnter,     onEnter,     onAfterEnter,     onEnterCancelled,     onBeforeLeave,     onLeave,     onAfterLeave,     onLeaveCancelled,     onBeforeAppear,     onAppear,     onAfterAppear,     onAppearCancelled   } = props;   const hooks = {      mode,      persisted,      beforeEnter(el) {        let hook = onBeforeEnter;        if (!state.isMounted) {                    // 依据用户属性判断是否应用onBeforeAppear                    // 如果用户没有传onBeforeAppear则应用onBeforeEnter          if (appear) {            hook = onBeforeAppear || onBeforeEnter;          } else {            return;          }        }        callHook(hook, [el]);      },      enter(el) {        let hook = onEnter;        if (!state.isMounted) {          if (appear) {            hook = onAppear || onEnter;          } else {            return;          }        }         if (hook) {          hook(el);        }      },      leave(el) {        callHook(onBeforeLeave, [el]);        if (onLeave) {          onLeave(el);        }      }  };  return hooks;}

定义函数,用于将hooks绑定至Vnode

// 用于给虚构节点绑定hooks, 如果是组件类型,则递归绑定hooksfunction setTransitionHooks(vnode, hooks) {  if (vnode.component) {    setTransitionHooks(vnode.component.subTree, hooks);  } else {    vnode.transition = hooks;  }}

通过下面的代码能够晓得,JavaScript钩子函数,次要是在beforeEnterenterleave阶段进行调用的。

接下来,实现过渡模式的实现。

过渡模式

过渡模式次要是为了解决多个元素之间的过渡成果,在不应用过渡模式的时候,元素之间过渡时,会被同时绘制。

这里是因为transition组件默认进入和来到同时产生。

然而有时,咱们须要解决更简单的动作,比方须要使以后元素提前来到,实现之后再让新的元素进入等状况。

这就波及到元素组件间过渡状态的协调。

transition组件为用于提供了两种模式:

  • out-in: 以后元素先进行来到过渡,实现之后新元素过渡进入。
  • in-out: 新元素先进行进入过渡,实现之后以后元素过渡来到。

接下来就是获取以后元素与新元素,并在适合的机会执行对应的钩子就能够。

out-in为例,咱们心愿达到的成果是以后元素来到之后,在开始新元素的过渡。

咱们能够定义一个以后元素的来到钩子,在渲染其中,当须要移除 || 挪动以后元素的时候,咱们能够先执行以后元素的来到钩子,之后再调用新元素的进入钩子。

这就实现了out-in的成果。

渲染器解决逻辑

咱们能够看下渲染器中是在哪个阶段解决的。

patch阶段,通过move函数来实现节点的插入。

// move & remove函数位于baseCreateRenderer函数中// 挪动节点const move = (vnode, container, anchor, moveType, parentSuspense = null) => {  const {    el,    type,    transition,    children,    shapeFlag  } = vnode;  // 省略局部代码...  // single nodes  const needTransition = transition;  if (needTransition) {    // 省略局部代码...        const {      leave,      delayLeave,      afterLeave    } = transition;        // hostInsert函数负责将el插入container    const remove = () => hostInsert(el, container, anchor);                    // 由performLeave函数执行leave钩子      // leave 钩子会取负责元素的插入与afterLeave钩子的执行    const performLeave = () => {      leave(el, () => {        remove();        afterLeave && afterLeave(); // out-in模式下      });    };    if (delayLeave) {            // 要害:delayLeave函数负责实现以后元素的插入和leave钩子的调用      // in-out模式下,执行delayLeave      delayLeave(el, remove, performLeave);    } else {         // 要害:performLeave函数负责leave钩子的调用,最终通过leave函数实现以后元素的插入和afterLeave钩子的调用      performLeave();    }  }};

unmount阶段会执行remove函数。remove函数会将元素从父节点移除。

// 移除Vnodeconst remove = vnode => {  const {    type,    el,    anchor,    transition  } = vnode;  // 省略局部代码...    // hostRemove函数会将el从其父元素中移除 & afterLeave函数的调用  const performRemove = () => {    hostRemove(el);    if (transition && !transition.persisted && transition.afterLeave) {      // out-in模式下      transition.afterLeave();    }  };      if (vnode.shapeFlag & 1 &&    transition &&    !transition.persisted) {    const {      leave,      delayLeave    } = transition;                // 对leave函数做层包裹,afterLeave钩子最终交给leave钩子调用    const performLeave = () => leave(el, performRemove);    if (delayLeave) {            // 要害:delayLeave函数负责实现以后元素的移除和leave & afterLeave钩子的调用      // in-out模式下执行in-out      delayLeave(vnode.el, performRemove, performLeave);    } else {            // 要害:performLeave函数实现leave钩子的调用      performLeave();    }  } else {        // 要害:performRemove函数负责元素的移除和afterLeave钩子的执行    performRemove();  }};

下面的代码咱们只需关注标注的要害局部即可。

如果对于渲染器不是很理解,想全面了解下面的代码并不事实。

这里只需简略晓得:

transition组件高度依赖于渲染器。对于增加过渡模式的元素,在动效钩子中会存在afterLeave或者delayLeave钩子,由afterLeave钩子负责以后元素先来到的成果。delayLeave钩子负责以后元素推延来到的成果。

新增过渡模式钩子

开始实现过渡模式:

const MyTransitionBase = {  setup(props, { slots }) {            // 获取以后组件实例      const instance = getCurrentInstance();      const state = useTransitionState();      return () => {          const children = slots.default()            // 获取用户配置的过渡模式          const { mode } = props;          // 获取新元素          const child = children[0];                                        // 解析新元素的hooks          const enterHooks = resolveTransitionHooks(child, rawProps, state, instance)          setTransitionHooks(child, enterHooks);                    // 获取以后元素          const oldChild = instance.subTree;          // 解决过渡模式          if (oldChild && (!isSameVNodeType(child, oldChild))) {                            // 从以后元素解析动效钩子              const leavingHooks = resolveTransitionHooks(oldChild, rawProps, state, instance);                                       // 为以后(旧)元素更新动效钩子              setTransitionHooks(oldChild, leavingHooks);                            if (mode === 'out-in') {                  // 为以后(旧)元素新增afterLeave钩子,afterLeave的执行会使以后实例触发updateEffect,进入更新阶段                  leavingHooks.afterLeave = () => {                      instance.update();                  };              } else if (mode === 'in-out') {                                    // 为以后元素新增delayLeave钩子,delayLeave钩子会推延以后元素的来到动效                                    // earlyRemove && delayedLeave 回调由渲染器传入                                    // earlyRemove负责元素的挪动或者移除                                    // delayedLeave负责leave钩子的调用                  leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => {                      // 获取缓存                                            const leavingVNodesCache = getLeavingNodesForType(state, oldChild);                        // 更新缓存                      leavingVNodesCache[String(oldChild.key)] = oldChild;                      // 为以后元素定义一个公有leave回调                      el._leaveCb = () => {                          earlyRemove();                          el._leaveCb = undefined;                          delete enterHooks.delayedLeave;                      };                        // 在新元素上绑定delayedLeave钩子,用于推延以后元素的离场动效                      enterHooks.delayedLeave = delayedLeave;                  };              }          }          return child;      };  }}

从下面的代码能够晓得,咱们通过getCurrentInstance获取以后组件实例,从以后实例获取须要进行离场解决的以后元素。

当新元素与以后元素是不同类型时,进行过渡模式的解决:

  • out-in模式下,为以后元素新增afterLeave钩子。afterLeave钩子通过手动调动update,最终的触发机会由patch逻辑决定或者作为leave钩子函数的回调函数,在以后元素还没有卸载时,也就是state.isUnmounting = true时执行。以后元素先实现离场过渡之后,新元素再开始入场过渡。
  • in-out模式下,为以后元素新增delayLeave钩子。其实是一个推延执行的leave钩子,回调earlyRemove, delayedLeave回调由渲染器传入。earlyRemove负责节点的挪动或者删除操作,delayedLeave是一个推延的leave钩子函数。会在新元素入场前执行。

更改useTransitionState函数

function useTransitionState {  const state = {    isMounted: false,    isUnmounting: false,    leavingVNodes: new Map() // 负责缓存以后(旧)元素vnode  }    // 省略局部代码...}// 负责获取缓存的旧vnodefunction getLeavingNodesForType(state, vnode) {  const { leavingVNodes } = state;  let leavingVNodesCache = leavingVNodes.get(vnode.type);  if (!leavingVNodesCache) {      leavingVNodesCache = Object.create(null);      leavingVNodes.set(vnode.type, leavingVNodesCache);  }  return leavingVNodesCache;}

从下面代码可知,在state中新增了leavingVNodes,用于记录须要进行离场过渡的VnodegetLeavingNodesForType函数则是依据以后元素类型获取Vnode缓存。

更改resolveTransitionHooks钩子,

function resolveTransitionHooks(vnode, props, state, instance) {  // 省略局部代码...  const key = String(vnode.key);  const leavingVNodesCache = getLeavingNodesForType(state, vnode);  const callHook = (hook, args) => {    hook && hook(...args)    };  const hooks = {      mode,      persisted,                beforeEnter(el) {          let hook = onBeforeEnter          // 省略局部代码...                     // 解决v-show          if (el._leaveCb) {            el._leaveCb(true)          }          // 解决具备形同key的Vnode在应用v-if的状况          const leavingVNode = leavingVNodesCache[key]          if (            leavingVNode &&            isSameVNodeType(vnode, leavingVNode) &&            leavingVNode.el!._leaveCb          ) {            leavingVNode.el!._leaveCb()          }          callHook(hook, [el])        },      leave(el, remove) {          // 省略局部代码...          const key = String(vnode.key);          // remove回调由渲染器传入                    // 会触发元素的挪动或者移除,并执行afterLeave钩子          if (state.isUnmounting) {            return remove();          }          callHook(onBeforeLeave, [el]);                    // 记录以后元素的Vnode          leavingVNodesCache[key] = vnode;      }  };  return hooks;}

至此,咱们曾经实现了MyTransition组件从class反对到javacsript钩子反对,再到过渡模式的反对工作。

总结

通过本文,咱们根本实现了一个demo版的Transition组件。MyTransition组件绝对于Vue内置Transition组件还有很多不足之处,Transition组件还做了很多更粗疏的解决,如被KeepAlive包裹的组件的动效解决、应用v-show或者v-if进行切换的组件动效解决等。

MyTransition组件曾经能够很好的帮咱们理解Transition组件的要害逻辑:

  • 通过在不同的渲染阶段为组件增加动效class实质是通过渲染器在渲染过程中执行对应的钩子函数实现的。
  • 利用嵌套的requestAnimationFrame实现在下一帧中增加对应动效class
  • leave阶段,通过监听transitionend || animationend事件,移除动效class
  • 通过setTimeout操作动效的继续成果。
  • 通过拆分TransitionBaseTransition组件,做css动效与JavaScript动效兼容。
  • 尽管供内部应用的JavaScript钩子很多,但在在BaseTransition组件外部,也就三个:beforeEnterenterleave,其余的钩子通过逻辑判断被整合到这三个次要的钩子中。
  • 过渡模式的重要之处在于获取以后元素与新元素,当是out-in时为以后元素增加afterLeave钩子,in-out时,为以后元素增加delayLeave钩子,新元素增加delayedLeave钩子。