如何实现优雅的删除动画

前言

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

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

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 组件内部维护了 presentChildrenrenderedChildren 两个状态,用于检测子组件的变化。

存在感知

当子组件发生变化时,会遍历 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 组件。

  1. 通过 usePresence hook 订阅 PresenceContext
  2. 根据 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 的实现主要包含以下几个部分:

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

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

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

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

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

相关推荐
知识分享小能手13 分钟前
微信小程序入门学习教程,从入门到精通,自定义组件与第三方 UI 组件库(以 Vant Weapp 为例) (16)
前端·学习·ui·微信小程序·小程序·vue·编程
trsoliu27 分钟前
多仓库 Workspace 协作机制完整方案
前端
啦工作呢41 分钟前
数据可视化 ECharts
前端·信息可视化·echarts
NoneSL44 分钟前
Uniapp UTS插件开发实战:引入第三方SDK
前端·uni-app
trsoliu1 小时前
Claude Code Templates
前端·人工智能
wangpq1 小时前
使用rerender-spa-plugin在构建时预渲染静态HTML文件优化SEO
前端·javascript·vue.js
KongHen1 小时前
完美解决请求跨域问题
前端
前端开发爱好者1 小时前
弃用 uni-app!Vue3 的原生 App 开发框架来了!
前端·javascript·vue.js
再吃一根胡萝卜1 小时前
VS Code Ctrl+/ 注释失效:两套快速修复与冲突排查方案(含可复制配置)
前端
聪明的笨猪猪1 小时前
Java Redis “Sentinel(哨兵)与集群”面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试