技术前端开发怎么实现优雅的删除动画

前言

在日常开发中,删除动画往往比入场动画难实现的多。而且在很多例子中,尽管你写了很多删除动画,但他可能都不会生效。

比如我们在使用弹窗组件时,如果你想保留弹窗的离场动画,那么你可能会写出这样的代码:

modal-with-animation
<Modal open={open} onClose={onClose}>
  <p>some content...</p>
  <p>some content...</p>
  <p>some content...</p>
</Modal>

但是,这就默认弹窗会直接渲染,但是我们很多时候希望打开弹窗时才去渲染 Modal 组件,于是我们就有了以下代码

{open && (
  <Modal open={open} onClose={onClose}>
    <p>some content...</p>
    <p>some content...</p>
    <p>some content...</p>
  </Modal>
)}

在这种情况下,弹窗打开时动画还可以正常显示,但是关闭时候动画就直接失效了。

modal-without-animation

原因也很简单,当你关闭弹窗时,React 会立即卸载Modal 组件,动画根本没有机会执行,因为执行动画的元素已经不存在了。

那么,有什么方法可以让我们把动画执行完再卸载组件吗?

motion 组件

在鼎鼎大名的动画库 motion (framer-motion) 中,有一个 AnimatePresence 组件, 通过使用 AnimatePresence 包裹一个或多个 motion 组件,可以让我们在动画执行完后再卸载组件。

animate-presence

那么,为什么使用 AnimatePresence 就可以实现这个效果呢?

原理

状态管理

//接收到的子组件
const presentChildren = useMemo(() => onlyElements(children), [children])
const presentKeys = presentChildren.map(getChildKey)
 
// 记录哪些子元素已经完成退出动画
const exitComplete = useConstant(() => new Map<ComponentKey, boolean>())
 
// 实际要渲染的字组件(可能包含已经卸载的组件)
const [renderedChildren, setRenderedChildren] = useState(presentChildren)

AnimatePresence 组件内部维护了 presentChildrenrenderedChildren 两个状态,用于检测子组件的变化。

存在感知

当子组件发生变化时,会遍历 renderedChildren

如果某个子组件的 key 不在 presentKeys 中,说明该组件已经被卸载,此时会在 exitComplete Map 中将该组件标记为未完成退出动画。

反之,如果子组件的 key 存在于 presentKeys 中,则从 exitComplete 中删除该组件的记录。

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

<>
  {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 组件。

  1. 通过 usePresence hook 订阅 PresenceContext
  2. 根据 isPresent 的值决定执行哪个动画:
    • isPresent === true: 执行 animate 定义的动画
    • isPresent === false: 执行 exit 定义的动画
const context = useMemo(
  (): PresenceContextProps => ({
      id,
      isPresent,
      onExitComplete: memoizedOnExitComplete,
      register: (childId: string) => {
          presenceChildren.set(childId, false)
          return () => presenceChildren.delete(childId)
      },
  }),
  // ...
)

最终卸载流程

motion 组件内部会监听 onExitComplete 回调,当动画结束后,会调用 onExit 方法,真正卸载组件。

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 的实现主要包含以下几个部分:

  1. 内部维护了一个 renderedChildren 状态,这样即使父组件已经移除了子组件的渲染,AnimatePresence 依然能保持对它的控制

  2. 通过 context 向下传递”存在状态”,子组件可以据此执行相应的动画效果

  3. exitComplete Map 来记录每个退出动画的完成情况,等所有动画都完成了才真正移除组件

  4. 如果组件没有退出动画,就直接移除,不会有多余的等待时间

通过这种巧妙的设计,我们可以在组件卸载时保持流畅的过渡动画效果,让用户体验更加自然。