乐趣区

关于vue.js:手摸手实现Transition

手摸手实现 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函数次要负责从父元素中移除元素。
// 移除 Vnode
const 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

// 负责解析 Hooks
function 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, 如果是组件类型,则递归绑定 hooks
function 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函数会将元素从父节点移除。

// 移除 Vnode
const 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
  }
    // 省略局部代码...
}

// 负责获取缓存的旧 vnode
function 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 钩子。
退出移动版