手摸手实现 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.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
阶段的钩子定义。然而会发现代码中会有很多冗余。代码逻辑有很多反复之处。为此咱们能够将代码进行优化。
重构
- 将过渡开始须要增加
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
属性的时候,能够使其中一些嵌套的外部元素相比于过渡成果的根元素具备提早的或更长的过渡成果。
应用的时候,你能够用 <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
钩子实现
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
,来判断是否是初始渲染状态。
如果没有挂载,则咱们在 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
钩子。