Abstract background

在 Next.js 14 App Router 中实现页面切换的动画效果

背景

由于 React 未提供组件卸载的生命周期钩子函数,创建元素退出动画非常不便。一般地,我们可以使用 Framer Motion 提供的 <AnimatePresense> 组件包裹需要切换的组件实现退出动画。在 Next.js 中,Page Router 路由可以正常地通过这种方式实现退出动画,但 App Router 情况有所不同,因为一些 bug,无法得到正确的组件退出效果。

解决方案

一般想要实现路由动画,解决思路有:

  1. 在路由切换时通过钩子函数延迟 DOM 卸载,直到到元素播放完退出动画。
  2. 修改所有 <Link> 的逻辑,点击后阻止跳转,播放动画完成后通过 router.push() 触发跳转。

方法 2 对使用浏览器前进/后退键触发的路由切换不生效,因此我们尝试用方法 1 解决问题。

[NEXT-1151] App router issue with Framer Motion shared layout animations 这个 issue 报告了 App Router 中实现跨路由动画相关的问题:Next.js 13 的 app 目录下使用 Framer Motion 的共享布局动画时,出现了多个场景无法正常工作的情况。用户报告了导航容器重新渲染时,样式发生变化的组件无法平滑过渡的问题。主要原因是 Next.js 的新 app 路由结构在布局和模板之间插入了 OuterLayoutRouter 组件,导致布局组件无法正常提供退出动画效果。建议的解决方案包括调整 Next.js 的渲染堆栈或公开导航开始时的 API 事件。

How to make a page transition with Framer Motion and Next.js 14? 这个帖子中的回答总结上述 issue 的讨论,提出了一个可行的解决方案。在目前版本(Next.js 14.2.2)中验证可用,是相对简单可行的。

1"use client"; 2 3import { motion, AnimatePresence } from "framer-motion"; 4import { usePathname } from "next/navigation"; 5import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime"; // hack 6import { useContext, useRef } from "react"; 7 8// FrozenRouter 组件,保持页面切换过程中上下文的持久化 9function FrozenRouter(props: { children: React.ReactNode }) { 10 const context = useContext(LayoutRouterContext ?? {}); // 使用 useContext 钩子获取当前的 LayoutRouterContext 上下文值。如果 LayoutRouterContext 为空,则使用空对象。 11 const frozen = useRef(context).current; // 使用 useRef 创建一个持久化的引用来存储 context,并通过 current 属性获取其当前值,这样可以确保 context 在组件的整个生命周期内保持不变。 12 // 将冻结的 context 作为值传递给 LayoutRouterContext.Provider,以确保子组件在页面切换过程中能够使用一致的上下文。 13 return ( 14 <LayoutRouterContext.Provider value={frozen}> 15 {props.children} 16 </LayoutRouterContext.Provider> 17 ); 18} 19 20// 页面切换动画的配置 21const variants = { 22 hidden: { opacity: 0, x: -200, y: 100 }, 23 enter: { opacity: 1, x: 0, y: 0 }, 24 exit: { opacity: 0, x: 0, y: -100 }, 25}; 26 27// PageTransitionEffect 组件,用于处理页面切换动画 28const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => { 29 // 使用 usePathname 钩子获取路径名作为 key,以在路由更改时触发重新渲染 30 const key = usePathname(); 31 32 return ( 33 <AnimatePresence mode="popLayout"> 34 <motion.div 35 key={key} 36 initial="hidden" 37 animate="enter" 38 exit="exit" 39 variants={variants} 40 transition={{ type: "linear" }} 41 className="overflow-hidden" 42 > 43 <FrozenRouter>{children}</FrozenRouter> 44 </motion.div> 45 </AnimatePresence> 46 ); 47}; 48 49export default PageTransitionEffect;

然后,在根布局组件 RootLayout 中应用:

1import React from "react"; 2import PageTransitionEffect from "@/src/components/transition/PageTransitionEffect"; 3 4export default function RootLayout({ 5 children, 6}: Readonly<{ 7 children: React.ReactNode; 8}>) { 9 return ( 10 <html lang="en" className="dark"> 11 <body className="relative"> 12 <PageTransitionEffect>{children}</PageTransitionEffect> 13 </body> 14 </html> 15 ); 16} 17

即可。

有人基于这个原理,开发了 mekuri 库,对上述原理做了进一步封装。具体效果有待测试。