手摸手实现Transition
手摸手实现Transition
xdm
好,我是剑大瑞。
本篇内容旨在通过自己实现Transition
组件,从而了解其内部原理。
如果你还没有使用过前言 通过官方文档可以知道,当使用Transition
组件或者对其不熟悉,那么我建议你可以先学习官方文档,写一些demo
,当熟悉了Transition
组件之后,但是又对其原理有所好奇,就可以再回来学习这篇文章。官方文档传送门。
Transition
组件的时候,我们可以通过配置Transition
组件的props
控制组件的进场过渡、离场过渡状态、动画效果。配置
props
的过程中,重要的是指定name
。Vue
会将name
字段与不同的过渡阶段名称进行组合,在不同的阶段为我们的dom
添加类名或者移除类名。这里借用官网的示意图:
文章图片
这张图片对于
Transition
组件的过渡效果描述非常确切了:- 当组件挂载的时候,
class
由v-enter-from
过渡为v-enter-to
。切换的中间过程我们称它为v-enter-active
。 - 当组件卸载的时候,
class
由v-leave-from
过渡为v-leave-to
。切换的过程我们称它为v-leave-active
。 - 在由
enter-from?enter-to
或者leave-from?leave-to
的阶段,我们可以指定组件的初始和最终样式。在enter-active
&leave-active
阶段我们可以指定组件的过渡或者动画效果。
defineComponent
API来定义一个MyTransition
组件,通过setup
获取插槽中的内容。这里面有两点需要考虑:
MyTransition
只会把过渡效果应用到其包裹的内容上,而不会额外渲染DOM
元素,也不会出现在可被检查的组件层级中。
就是说组件并不需要有自己的template
,只做插槽的搬用工。
MyTransition
组件并不需要有自己的状态,只需将用户传入的props
处理后,再将处理后的newProps
传给子组件即可。
就是说MyTransition
组件并不需要有自己的状态,只做状态的搬运工。
Props
设计
但是我们怎么设计props
呢?考虑这个问题,还需要回到
Transition
组件的核心逻辑在于:- 在组件的挂载阶段,我们需要将
enter-from
至enter-to
阶段的过渡或者动画效果class
附加到DOM
元素上。 - 在组件的卸载阶段,我们需要将
leave-from
至leave-to
阶段的过渡或者动画效果class
附加到DOM
元素上。
文章图片
那我们是否需要通过
mounted
、unmounted
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
切换。v-enter-from
:定义进入过渡的开始状态。在元素被插入之前生效,在元素被插入之后的下一帧移除。v-enter-active
:定义进入过渡生效时的状态。在整个进入过渡的阶段中应用,在元素被插入之前生效,在过渡/动画完成之后移除。这个类可以被用来定义进入过渡的过程时间,延迟和曲线函数。v-enter-to
:定义进入过渡的结束状态。在元素被插入之后下一帧生效 (与此同时v-enter-from
被移除),在过渡/动画完成之后移除。v-leave-from
:定义离开过渡的开始状态。在离开过渡被触发时立刻生效,下一帧被移除。v-leave-active
:定义离开过渡生效时的状态。在整个离开过渡的阶段中应用,在离开过渡被触发时立刻生效,在过渡/动画完成之后移除。这个类可以被用来定义离开过渡的过程时间,延迟和曲线函数。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)
})
},
// 省略部分代码...
})
}
两个问题
上面的代码会有两个问题:
requestAnimationFrame
中的回调函数真的能如我们所期望的那样在下一帧中执行吗?- 如何实现动效结束之后,对
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.offsetHeightaddTransitionClass(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
阶段的钩子定义。但是会发现代码中会有很多冗余。代码逻辑有很多重复之处。为此我们可以将代码进行优化。重构
- 将过渡开始需要添加
class
的部分抽离为startBefore
,将过渡结束后需要移除class
的部分抽离为finishEnter
、finishLeave
函数,通过参数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);
};
- 将嵌套的
requestAnimationFrame
抽离为nextFrame
函数。
function nextFrame(cb) {
requestAnimationFrame(() => {
requestAnimationFrame(cb);
});
}
- 将监听
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);
}
onEnter
与onAppear
函数逻辑存在重复之处,我们可以定义一个高阶函数,用于返回钩子函数。
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
属性的时候,可以使其中一些嵌套的内部元素相比于过渡效果的根元素具有延迟的或更长的过渡效果。使用的时候,你可以用
组件上的 duration
prop 显式指定过渡持续时间 (以毫秒计):...
你也可以分别指定进入和离开的持续时间:
...
这意味着,用户可以传单个时间或者以对象的形式,执行
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
钩子实现
Vue
的Transition
组件除了可以使用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]);
}
});
}
上面代码省略的部分为原来就有的,调整的只是新增的部分。从上面的代码,可以发现:
- 因为在前面我们说过
MyTransition
组件没有自己的状态,所以我们可以通过渲染函数将其定义为一个函数式组件。 - 定义了一个
resolveMyTransitionProps
函数,用于做props
的处理。 - 如果用于配置的
css = false
,可以直接返回newProps
。 - 用户同时使用
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
,来判断是否是初始渲染状态。【手摸手实现Transition】如果没有挂载,则我们在
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
钩子函数,主要是在beforeEnter
、enter
、leave
阶段进行调用的。接下来,完成过渡模式的实现。
过渡模式 过渡模式主要是为了解决多个元素之间的过渡效果,在不使用过渡模式的时候,元素之间过渡时,会被同时绘制。
这里是因为
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
,用于记录需要进行离场过渡的Vnode
。getLeavingNodesForType
函数则是根据当前元素类型获取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
操作动效的持续效果。 - 通过拆分
Transition
与BaseTransition
组件,做css
动效与JavaScript
动效兼容。 - 虽然供外部使用的
JavaScript
钩子很多,但在在BaseTransition
组件内部,也就三个:beforeEnter
、enter
、leave
,其余的钩子通过逻辑判断被整合到这三个主要的钩子中。 - 过渡模式的重要之处在于获取当前元素与新元素,当是
out-in
时为当前元素添加afterLeave
钩子,in-out
时,为当前元素添加delayLeave
钩子,新元素添加delayedLeave
钩子。
推荐阅读
- 100行代码实现HarmonyOS“画图”应用,eTS开发走起!
- 京东一面(说说 CompletableFuture 的实现原理和使用场景(我懵了。。))
- C语言|【c语言strcpy、strcat、strlen函数实现】
- 多人后台博客管理
- 100行代码实现“画图”应用,eTS开发走起!
- 基于服务网格的分布式 ESB, 实现应用无关的传统 ESB 转型升级
- Java|Java LinkedList实现班级信息管理系统
- vue通过v-show实现回到顶部top效果
- mysql|mysql where中如何判断不为空的实现
- SpringBoot实现多环境配置文件切换教程详解