前言
在日常开发中,删除动画往往比入场动画难实现的多。而且在很多例子中,尽管你写了很多删除动画,但他可能都不会生效。
比如我们在使用弹窗组件时,如果你想保留弹窗的离场动画,那么你可能会写出这样的代码:
tsx
<Modal open={open} onClose={onClose}>
<p>some content...</p>
<p>some content...</p>
<p>some content...</p>
</Modal>
但是,这就默认弹窗会直接渲染,但是我们很多时候希望打开弹窗时才去渲染 Modal
组件,于是我们就有了以下代码
tsx
{open && (
<Modal open={open} onClose={onClose}>
<p>some content...</p>
<p>some content...</p>
<p>some content...</p>
</Modal>
)}
在这种情况下,弹窗打开时动画还可以正常显示,但是关闭时候动画就直接失效了。
原因也很简单,当你关闭弹窗时,React 会立即卸载Modal
组件,动画根本没有机会执行,因为执行动画的元素已经不存在了。
那么,有什么方法可以让我们把动画执行完再卸载组件吗?
motion 组件
在鼎鼎大名的动画库 motion (framer-motion
) 中,有一个 AnimatePresence
组件, 通过使用 AnimatePresence
包裹一个或多个 motion
组件,可以让我们在动画执行完后再卸载组件。
那么,为什么使用 AnimatePresence
就可以实现这个效果呢?
原理
状态管理
typescript
//接收到的子组件
const presentChildren = useMemo(() => onlyElements(children), [children])
const presentKeys = presentChildren.map(getChildKey)
// 记录哪些子元素已经完成退出动画
const exitComplete = useConstant(() => new Map<ComponentKey, boolean>())
// 实际要渲染的字组件(可能包含已经卸载的组件)
const [renderedChildren, setRenderedChildren] = useState(presentChildren)
AnimatePresence
组件内部维护了 presentChildren
和 renderedChildren
两个状态,用于检测子组件的变化。
存在感知
当子组件发生变化时,会遍历 renderedChildren
如果某个子组件的 key
不在 presentKeys
中,说明该组件已经被卸载,此时会在 exitComplete
Map 中将该组件标记为未完成退出动画。
反之,如果子组件的 key
存在于 presentKeys
中,则从 exitComplete
中删除该组件的记录。
typescript
useIsomorphicLayoutEffect(() => {
for (let i = 0; i < renderedChildren.length; i++) {
const key = getChildKey(renderedChildren[i])
if (!presentKeys.includes(key)) {
if (exitComplete.get(key) !== true) {
exitComplete.set(key, false)
}
} else {
exitComplete.delete(key)
}
}
}, [renderedChildren, presentKeys.length, presentKeys.join("-")])
维持组件的渲染
在渲染时,即使组件已经卸载,AnimatePresence
会维持组件的渲染。
同时,它会为每个子组件创建一个 PresenceChild
包装组件。 通过向 PresenceChild
传递 isPresent
属性来标识组件的存在状态,并通过 onExitComplete
回调在离场动画结束后执行真正的卸载操作 onExit
。
typescript
<>
{renderedChildren.map((child) => {
const key = getChildKey(child)
const isPresent = presentKeys.includes(key)
return (
<PresenceChild
key={key}
isPresent={isPresent} // 告诉子组件它是否"存在"
onExitComplete={isPresent ? undefined : onExit}
>
{child}
</PresenceChild>
)
})}
</>
退出动画的协调
在 PresenceChild
组件中,会创建一个 context
, 通过 context
告诉当真正执行动画的 motion
组件。
- 通过 usePresence hook 订阅 PresenceContext
- 根据 isPresent 的值决定执行哪个动画:
- isPresent === true: 执行 animate 定义的动画
- isPresent === false: 执行 exit 定义的动画
typescript
const context = useMemo(
(): PresenceContextProps => ({
id,
isPresent,
onExitComplete: memoizedOnExitComplete,
register: (childId: string) => {
presenceChildren.set(childId, false)
return () => presenceChildren.delete(childId)
},
}),
// ...
)
最终卸载流程
motion
组件内部会监听 onExitComplete
回调,当动画结束后,会调用 onExit
方法,真正卸载组件。
typescript
const onExit = () => {
if (exitComplete.has(key)) {
exitComplete.set(key, true)
}
let isEveryExitComplete = true
exitComplete.forEach((isExitComplete) => {
if (!isExitComplete) isEveryExitComplete = false
})
if (isEveryExitComplete) {
// 所有退出动画完成,更新渲染状态
setRenderedChildren(pendingPresentChildren.current)
onExitComplete && onExitComplete()
}
}
总结
AnimatePresence
的实现主要包含以下几个部分:
-
内部维护了一个
renderedChildren
状态,这样即使父组件已经移除了子组件的渲染,AnimatePresence
依然能保持对它的控制 -
通过
context
向下传递"存在状态",子组件可以据此执行相应的动画效果 -
用
exitComplete
Map 来记录每个退出动画的完成情况,等所有动画都完成了才真正移除组件 -
如果组件没有退出动画,就直接移除,不会有多余的等待时间
通过这种巧妙的设计,我们可以在组件卸载时保持流畅的过渡动画效果,让用户体验更加自然。