Next.js App Router 页面切换动画:踩坑与最终方案
为什么 AnimatePresence 会闪、template.tsx 没效果,以及 pathname 作 key 为什么能 work
目录
给博客 main 区域加一个简单的 fade 切换动画,听起来是前端基本功。但在 Next.js App Router 里,我折腾了三种方案才搞定——中间还经历了「有内容 → 空白 → 有内容」的三连闪。
这篇文章记录完整的踩坑过程和最终方案。
目标
页面切换时,main 区域做一个最简单的淡入效果。Nav 和 Footer 保持不动,只有内容区变化。
参考 cheyuwu.com 的交互感受:不花哨,但能让路由变化有「过渡感」。
方案一:layout + AnimatePresence(失败,会闪)
最直觉的写法:在 layout.tsx 里用 framer-motion 的 AnimatePresence 包住 children,做淡出再淡入。
<AnimatePresence mode="wait"> <motion.div key={pathname} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} > {children} </motion.div></AnimatePresence>现象: 每次切换都闪一下,像「有内容了 → 没内容 → 再显示内容」。
原因: layout.tsx 里的 children 是一个共享插槽。点击导航时,Next.js 会立刻把新页面内容塞进这个插槽——不会等 exit 动画跑完。
时序是这样的:
- 新内容已经渲染进来了(闪一下)
- 旧内容的 exit 动画在跑(变空白)
- 新内容的 enter 动画再跑一遍(再出现)
这不是渲染太快,而是 layout + AnimatePresence 在 App Router 下架构不兼容。children 的更新时机和动画的生命周期是冲突的。
方案二:template.tsx(失败,没效果)
查文档后改用 template.tsx——它会在导航时重新挂载,听起来很适合做入场动画。
'use client'
export default function Template({ children }) { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}> {children} </motion.div> )}现象: 完全没动画。首页 ↔ 写作页切换,什么都看不到。
原因: template.tsx 只在自己这一层 segment 变化时才重新挂载。
我们的文件结构是 app/[locale]/template.tsx。从 / 切到 /writing 时,locale 没变(都是 zh),变的是子 segment。所以 template 不会 remount,动画根本不会触发。
Next.js 文档里的规则:
Templates remount when that segment (including its dynamic params) changes. Navigations within deeper segments do not remount higher-level templates.
要让 template 生效,得把 template 放在每次路由都会变的 segment 层级——或者接受它只管 locale 切换这种场景。
方案三:layout + pathname 作 key 淡入(成功)
回到 layout,但换一个思路:不做 exit 动画,只做 fade-in。用 key={pathname} 让 motion.div 在路由变化时整个重新挂载。
'use client'
import { usePathname } from '@/i18n/routing'import { motion } from 'framer-motion'
const isFirst = { current: true }
export default function PageTransition({ children }: { children: React.ReactNode }) { const pathname = usePathname() const skipInitial = isFirst.current
useEffect(() => { if (isFirst.current) isFirst.current = false }, [])
return ( <motion.div key={pathname} initial={skipInitial ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3, ease: 'easeOut' }} > {children} </motion.div> )}在 layout.tsx 里:
<main className="flex-1"> <PageTransition>{children}</PageTransition></main>效果: 旧页瞬间消失,新页 300ms 淡入。干净,不闪。
为什么这个能 work?
| 要点 | 作用 |
|---|---|
key={pathname} | 路由一变,React 销毁旧的 motion.div、创建新的,触发 initial → animate |
不用 AnimatePresence | 不和 children 抢更新时机,避免三连闪 |
initial={false} 跳过首次 | 第一次打开页面不播动画 |
usePathname 来自 @/i18n/routing | 不带 locale 前缀,/writing 就是 /writing |
核心逻辑:不和 App Router 的 children 更新机制对抗,顺着它来——新页面进来时,用一个全新的容器做淡入就好。
如果要做双向动画呢?
淡入 only 是目前 App Router 下最稳的方案。如果还想要淡出 + 淡入,选项有:
- React 19 的
<ViewTransition>+ Next.js 16 的experimental.viewTransition(浏览器原生 View Transitions API) - 接受 trade-off:旧页瞬间消失,新页淡入(大多数博客够用了)
我在这个博客里选了后者。简单、零闪烁、好维护。
总结
| 方案 | 结果 | 原因 |
|---|---|---|
| layout + AnimatePresence | 三连闪 | children 立刻更新,exit/enter 时序冲突 |
[locale]/template.tsx | 无效果 | 同 locale 内导航不触发 remount |
layout + key={pathname} 淡入 | 正常 | 新容器挂载时做 fade-in,不对抗框架 |
App Router 的页面切换动画,关键不是选哪个动画库,而是理解 layout / template / children 三者的生命周期。搞清楚了,十几行代码就够用。