Nuxt 页面与布局过渡实战:pageTransition、layoutTransition 与 View Transitions API 全解析
本篇技术指南基于 Nuxt 官方文档 docs/1.getting-started/09.transitions.md 展开,系统讲解在 Nuxt 应用中为页面切换与布局切换添加过渡效果的完整方案:从 nuxt.config.ts 全局配置 app.pageTransition / app.layoutTransition,到 definePageMeta 的按页面定制、内联 middleware 的动态过渡、JavaScript 钩子,再到实验性的原生 View Transitions API 及其 View Transition Types。读完后你不仅能复制可用的 CSS 与配置代码,还能结合 Nuxt 源码理解过渡属性的合并优先级、<Transition> 包装时机以及 View Transitions 插件的运行时执行链路。
前置条件:为什么必须有单一根元素
Nuxt 的页面过渡(Page Transitions)和布局过渡(Layout Transitions)都构建在 Vue 内置的 <Transition> 组件之上,这一点直接决定了一个硬性约束:想要被动画化(animate)的页面或布局组件,模板必须只有一个根元素。
多根节点(fragment)的页面或布局无法运行过渡——过渡不会执行,而且路由切换时可能直接报错。Nuxt 在开发模式下会对这种情况发出警告。解决办法很简单:用 <div> 之类的单一根元素包裹整个模板。
从源码可以印证这一点:<NuxtPage /> 内部通过 _wrapInTransition 将页面 vnode 包裹进 Vue 的 <Transition>,而该函数仅在客户端生效(见 utils.ts):
export const _wrapInTransition = (props: any, children: any): { default: () => VNode | undefined } => {
return { default: () => import.meta.client && props ? h(Transition, props === true ? {} : props, children) : children.default?.() }
}
import.meta.client 意味着过渡是纯客户端行为,SSR 渲染的 HTML 中不会出现 <Transition> 包装。
页面过渡(Page Transitions)
要开启全局页面过渡,只需在 nuxt.config.ts 中设置 app.pageTransition:
export default defineNuxtConfig({
app: {
pageTransition: { name: 'page', mode: 'out-in' },
},
})
注意:name 决定 CSS 类名前缀(即生成 .page-enter-active、.page-leave-from 等类),mode 控制新旧页面动画的时序,默认值为 out-in(旧页面先离场,再进入新页面)。
然后按该 name 添加对应的 CSS。把样式写在 app/app.vue 中:
<template>
<NuxtPage />
</template>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(1rem);
}
</style>
配合两个最简页面(注意各有一个 div 单根元素):
<template>
<div>
<h1>Home page</h1>
<NuxtLink to="/about">About page</NuxtLink>
</div>
</template>
<template>
<div>
<h1>About page</h1>
<NuxtLink to="/">Home page</NuxtLink>
</div>
</template>
此时在两个页面之间导航,旧页面会以 0.4 秒内“淡出 + 模糊”的方式离场,新页面以相反过程进入。
为单个页面设置不同过渡
如果想让某个页面使用不同的过渡效果,只需在该页面通过 definePageMeta 声明 pageTransition 键:
<script setup lang="ts">
definePageMeta({
pageTransition: {
name: 'rotate',
},
})
</script>
并在 app/app.vue 中补充对应的 CSS:
<template>
<NuxtPage />
</template>
<style>
/* ... */
.rotate-enter-active,
.rotate-leave-active {
transition: all 0.4s;
}
.rotate-enter-from,
.rotate-leave-to {
opacity: 0;
transform: rotate3d(1, 1, 1, 15deg);
}
</style>
导航到 about 页面时就会呈现 3D 旋转入场效果。
源码印证:过渡属性的合并优先级
过渡属性并非简单取值,而是按“页面组件 prop > 路由 meta > 全局配置”的顺序合并。见 page.ts:
const hasTransition = !!(props.transition ?? routeProps.route.meta.pageTransition ?? defaultPageTransition)
const transitionProps = hasTransition && _mergeTransitionProps([
props.transition,
routeProps.route.meta.pageTransition,
defaultPageTransition,
{
onAfterLeave () {
nuxtApp['~transitionFinish']?.()
delete nuxtApp['~transitionFinish']
delete nuxtApp['~transitionPromise']
nuxtApp.callHook('page:transition:finish', routeProps.Component)
},
},
])
三个来源依次是:<NuxtPage> 的 transition prop、route.meta.pageTransition(即 definePageMeta 的声明)、nuxt.config.ts 中的 app.pageTransition(构建时固化为 defaultPageTransition)。合并后 Nuxt 还会注入 onAfterLeave 钩子,用于解除由 Suspense 数据等待与过渡动画交织产生的导航等待(~transitionPromise),并在离场动画结束后触发 page:transition:finish 钩子——这意味着你可以监听页面过渡的完整生命周期。
布局过渡(Layout Transitions)
同理,app.layoutTransition 为所有布局切换添加全局过渡:
export default defineNuxtConfig({
app: {
layoutTransition: { name: 'layout', mode: 'out-in' },
},
})
重要提示:如果你在路由切换中同时改变页面和布局,app.pageTransition 设置的效果不会运行——此时应当配置布局过渡。布局是包裹页面的外层元素,布局过渡才是最外层的动画容器。
完整示例——app.vue、两个布局、两个页面:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style>
.layout-enter-active,
.layout-leave-active {
transition: all 0.4s;
}
.layout-enter-from,
.layout-leave-to {
filter: grayscale(1);
}
</style>
<template>
<div>
<pre>default layout</pre>
<slot />
</div>
</template>
<style scoped>
div {
background-color: lightgreen;
}
</style>
<template>
<div>
<pre>orange layout</pre>
<slot />
</div>
</template>
<style scoped>
div {
background-color: #eebb90;
padding: 20px;
height: 100vh;
}
</style>
<template>
<div>
<h1>Home page</h1>
<NuxtLink to="/about">About page</NuxtLink>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'orange',
})
</script>
<template>
<div>
<h1>About page</h1>
<NuxtLink to="/">Home page</NuxtLink>
</div>
</template>
从首页(默认布局)导航到 about 页(orange 布局)时,旧的绿色布局会先以灰度过渡离场,再进入橙色布局。
与 pageTransition 类似,也可以用 definePageMeta 为页面单独指定布局过渡:
<script setup lang="ts">
definePageMeta({
layout: 'orange',
layoutTransition: {
name: 'slide-in',
},
})
</script>
源码印证:布局过渡的等待协调
<NuxtLayout> 的过渡处理在 nuxt-layout.ts:
const hasTransition = hasLayout && !!(route?.meta.layoutTransition ?? defaultLayoutTransition)
const transitionProps = hasTransition && _mergeTransitionProps([
route?.meta.layoutTransition,
defaultLayoutTransition,
{
onBeforeLeave () {
// 布局是最外层过渡包装,
// 因此布局的 leave 动画会覆盖页面过渡的等待 Promise
nuxtApp['~transitionPromise'] = new Promise((resolve) => {
nuxtApp['~transitionFinish'] = resolve
})
},
onAfterLeave () {
nuxtApp['~transitionFinish']?.()
delete nuxtApp['~transitionFinish']
delete nuxtApp['~transitionPromise']
},
},
])
从源码结构看,布局过渡与页面过渡共享同一套 ~transitionPromise 等待机制:布局过渡在 onBeforeLeave 时接管等待,这正是“同时切换页面与布局时页面过渡不生效”的原因——外层布局的动画才是最终控制导航完成的边界。
全局配置与覆盖规则
pageTransition 和 layoutTransition 两个配置键都接受 Vue Transition 组件的属性(TransitionProps),且要求是 JSON 可序列化的值,因此可以传 name、mode 等任何合法的自定义 CSS 过渡属性:
export default defineNuxtConfig({
app: {
pageTransition: {
name: 'fade',
mode: 'out-in', // 默认值
},
layoutTransition: {
name: 'slide',
mode: 'out-in', // 默认值
},
},
})
注意:如果修改了 name,必须同步重命名 CSS 类名,否则找不到对应样式,过渡将无可见效果。
优先级方面,definePageMeta 中声明的过渡会覆盖 nuxt.config.ts 中的全局设置:
<script setup lang="ts">
definePageMeta({
pageTransition: {
name: 'bounce',
mode: 'out-in', // 默认值
},
})
</script>
在 schema 定义 中可以看到两者的默认值均为 false,即 Nuxt 默认不启用任何页面/布局过渡:
layoutTransition: false,
pageTransition: false,
禁用过渡
可以针对特定路由关闭过渡:
<script setup lang="ts">
definePageMeta({
pageTransition: false,
layoutTransition: false,
})
</script>
也可以在全局关闭:
export default defineNuxtConfig({
app: {
pageTransition: false,
layoutTransition: false,
},
})
由于默认值本来就是 false,显式写 false 主要用于覆盖上层配置(例如在 monorepo 的共享配置或 preset 中开启了过渡时)。
JavaScript 钩子:面向高级场景的动态过渡
对于高度动态的自定义过渡,可以使用 Vue <Transition> 组件提供的 JavaScript 钩子。这类方案特别适合配合 GSAP 等 JS 动画库实现 CSS 难以表达的动画(例如 FLIP 技巧):
<script setup lang="ts">
definePageMeta({
pageTransition: {
name: 'custom-flip',
mode: 'out-in',
onBeforeEnter: (el) => {
console.log('Before enter...')
},
onEnter: (el, done) => {},
onAfterEnter: (el) => {},
},
})
</script>
除 onBeforeEnter / onEnter / onAfterEnter 外,Vue 的 <Transition> 还提供 onBeforeLeave、onLeave、onAfterLeave、onBeforeAppear、onAppear、onAfterAppear、onEnterCancelled、onLeaveCancelled 等完整钩子。
这里有一个细节需要注意:definePageMeta 会被 Nuxt 序列化为路由 meta 数据供运行时使用,因此能写在这里的钩子需要是可序列化的场景下工作;如果你的钩子强依赖函数引用,建议放在插件或组合式函数中处理,或优先使用 View Transitions API(见下文)。此外,Nuxt 自身也会向合并结果注入 onAfterLeave(如前述 page.ts 所示),你的自定义钩子不会阻止其内部生命周期处理。
动态过渡:根据路由条件切换动画
要依据条件逻辑应用不同的过渡,可以在 definePageMeta 中使用内联 middleware,在导航守卫阶段动态改写 to.meta.pageTransition:
<script setup lang="ts">
definePageMeta({
pageTransition: {
name: 'slide-right',
mode: 'out-in',
},
middleware (to, from) {
if (to.meta.pageTransition && typeof to.meta.pageTransition !== 'boolean') {
to.meta.pageTransition.name = +to.params.id! > +from.params.id! ? 'slide-left' : 'slide-right'
}
},
})
</script>
<template>
<h1>#{{ $route.params.id }}</h1>
</template>
<style>
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.2s;
}
.slide-left-enter-from {
opacity: 0;
transform: translate(50px, 0);
}
.slide-left-leave-to {
opacity: 0;
transform: translate(-50px, 0);
}
.slide-right-enter-from {
opacity: 0;
transform: translate(-50px, 0);
}
.slide-right-leave-to {
opacity: 0;
transform: translate(50px, 0);
}
</style>
配合带前后翻页链接的布局:
<script setup lang="ts">
const route = useRoute()
const id = computed(() => Number(route.params.id || 1))
const prev = computed(() => '/' + (id.value - 1))
const next = computed(() => '/' + (id.value + 1))
</script>
<template>
<div>
<slot />
<div v-if="$route.params.id">
<NuxtLink :to="prev">⬅️</NuxtLink> |
<NuxtLink :to="next">➡️</NuxtLink>
</div>
</div>
</template>
效果是:访问“下一个” id 时应用 slide-left 过渡,访问“上一个” id 时应用 slide-right,从而获得方向感知的滑动动画。这个技巧的关键在于:middleware 在 beforeResolve 之前执行,而 <NuxtPage> 读取的正是 route.meta.pageTransition(见前述合并逻辑),所以在守卫中改写 meta 可以直接影响随后渲染所用到的过渡名。
通过 <NuxtPage> 的 transition prop 配置过渡
当 app.vue 中使用 <NuxtPage /> 时,可以直接通过其 transition prop 全局激活过渡:
<template>
<div>
<NuxtLayout>
<NuxtPage
:transition="{
name: 'bounce',
mode: 'out-in',
}"
/>
</NuxtLayout>
</div>
</template>
该 prop 的类型定义见 page.ts:
export interface NuxtPageProps extends RouterViewProps {
/**
* Define global transitions for all pages rendered with the `NuxtPage` component.
*/
transition?: boolean | TransitionProps
// ...
}
即可以传 transition="bounce"(字符串会归一化为 { name: 'bounce' } 的语义)或直接传对象。
注意:通过该 prop 设置的页面过渡,无法被各个页面里的 definePageMeta 覆盖——从合并代码看,props.transition 优先级最高,一旦提供就不会再回退到 route.meta.pageTransition。
实验特性:View Transitions API
Nuxt 内置了浏览器原生 View Transitions API 的实验性实现。这是实现原生浏览器过渡的新方式,与 Vue <Transition> 方案最大的不同在于:它能够在不同页面上不相干的元素之间建立过渡(例如上一页的商品卡片“移动”到详情页的大图位置),而不仅仅是整页淡入淡出。
开启方式
通过配置项 experimental.viewTransition 启用:
export default defineNuxtConfig({
experimental: {
viewTransition: true,
},
})
可能的取值为:false、true、'always'。
true(推荐):当用户浏览器设置了prefers-reduced-motion: reduce时,Nuxt 不会应用过渡,尊重系统级的减弱动态效果偏好;'always':始终应用过渡,尊重用户偏好由开发者自行负责。
experimental 配置 中该项默认值为 false。
全局默认值与页面级覆盖
默认情况下,View Transitions 对所有页面生效,但可以通过 app.viewTransition 设置不同的全局默认值,例如全局关闭、按页面选择加入:
export default defineNuxtConfig({
app: {
// 全局禁用 view transitions,然后按页面逐个开启
viewTransition: false,
},
})
也可以在某个页面的 definePageMeta 中覆盖默认值:
<script setup lang="ts">
definePageMeta({
viewTransition: false,
})
</script>
注意:按页面覆盖 viewTransition 只有在启用 experimental.viewTransition 后才有效果。
app.viewTransition 的解析逻辑见 app.ts:它支持布尔值、'always' 字符串,或形如 { enabled, types } 的对象,并且会与 experimental.viewTransition 的取值做合并——app 中未显式提供的字段回退到 experimental 配置。
View Transition Types(v4.4+)
View Transition Types 允许你根据导航类型应用不同的 CSS 动画,非常适合做非对称过渡(例如前进与后退使用不同动画)。类型被设置在 ViewTransition 对象上,并在 CSS 中通过 :active-view-transition-type() 伪类选择器命中。
全局设置默认类型:
export default defineNuxtConfig({
app: {
viewTransition: {
enabled: true,
types: ['slide'],
},
},
})
按页面配置类型,types 作用于“涉及本页面的任何过渡”,toTypes 仅作用于“导航到该页面”,fromTypes 仅作用于“从该页面离开”:
<script setup lang="ts">
definePageMeta({
viewTransition: {
enabled: true,
// 应用于涉及本页面的任意过渡
types: ['slide'],
// 仅应用于导航到该页面的过渡
toTypes: ['slide-in'],
// 仅应用于从该页面离开的过渡
fromTypes: ['slide-out'],
},
})
</script>
还支持用函数动态决定类型(基于路由):
<script setup lang="ts">
definePageMeta({
viewTransition: {
enabled: true,
toTypes: (to, from) => {
// 去往更大 ID 时向左滑,否则向右滑
return Number(to.params.id) > Number(from.params.id)
? ['slide-left']
: ['slide-right']
},
},
})
</script>
注意:types、toTypes、fromTypes 的函数形式仅在 definePageMeta 中可用;在 nuxt.config.ts 中只支持静态 string[]。
在 CSS 中按类型命中并应用不同动画:
/* 默认交叉淡入淡出 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
/* slide-left 动画 */
html:active-view-transition-type(slide-left) {
&::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in-out;
}
&::view-transition-new(root) {
animation: slide-in-right 0.3s ease-in-out;
}
}
/* slide-right 动画 */
html:active-view-transition-type(slide-right) {
&::view-transition-old(root) {
animation: slide-out-right 0.3s ease-in-out;
}
&::view-transition-new(root) {
animation: slide-in-left 0.3s ease-in-out;
}
}
page:view-transition:start 钩子可以拿到原生 ViewTransition 对象,其 types 属性(ViewTransitionTypeSet)可以在运行时读取或修改:
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('page:view-transition:start', (transition) => {
// 在运行时读取或修改 types
console.log([...transition.types])
})
})
源码印证:View Transitions 插件的运行时链路
整套 View Transitions 集成由客户端插件 view-transitions.client.ts 驱动,其核心流程可以完整走读:
- 能力探测:插件开头即检查
document.startViewTransition,不支持该 API 的浏览器直接退出(源码第 9-11 行),因此无需担心旧浏览器报错。 - 导航守卫决策:在
router.beforeResolve中依次判断(第 42-58 行):- 目标页
meta.viewTransition(经normalizeViewTransitionOptions归一化)的enabled值,回退到app.viewTransition默认值; prefers-reduced-motion: reduce且模式不是'always'时跳过;- 浏览器用户代理(UA)自己触发了视觉过渡(
hasUAVisualTransition,来自popstate事件的hasUAVisualTransition字段)时跳过,避免双重动画; isChangingPage(to, from)判断路由 key 与组件是否真正变化,仅参数变化不触发。
- 目标页
- 类型合并:将目标页
types、源页fromTypes、目标页toTypes三类合并(支持函数求值,第 60-75 行),即allTypes = [...toTypes(base), ...from.fromTypes, ...to.toTypes]。 - 调用原生 API:指定了类型时走 Level 2 对象形式
document.startViewTransition({ update, types }),否则回退到兼容性更广的回调形式document.startViewTransition(update)(第 90-94 行)。 - 生命周期衔接:过渡开始后触发
page:view-transition:start钩子;page:finish时 resolve 等待 Promise 完成过渡;router.onError、app:error、vue:error时 reject/中断过渡并清理状态(第 103-121 行)。
从源码结构看,这意味着 View Transition 的“完成”时机与 Nuxt 的路由生命周期(page:finish)严格绑定:Suspense 数据解析完毕、页面渲染完成后过渡才真正结束,与 Vue 过渡方案中 page:transition:finish 钩子的设计思路一致。
与 Vue 过渡共存:按浏览器能力自动降级
如果你的项目同时使用 pageTransition / layoutTransition 和原生 View Transitions API 来实现相似效果,理想策略是:在支持原生 API 的浏览器中关闭 Vue 过渡,避免“双重过渡”。官方建议创建一个全局 middleware 文件 ~/middleware/disable-vue-transitions.global.ts:
export default defineNuxtRouteMiddleware((to) => {
if (import.meta.server || !document.startViewTransition) {
return
}
// 关闭内置 Vue 过渡
to.meta.pageTransition = false
to.meta.layoutTransition = false
})
服务端直接跳过;客户端只在浏览器支持 startViewTransition 时把当前路由的 Vue 过渡关闭,不支持的浏览器仍走 CSS 过渡方案,实现平滑降级。
已知限制
- 如果你在页面 setup 函数中执行数据获取(数据获取),需要谨慎考虑当前采用 View Transitions:按照设计,View Transitions 在进行期间会完全冻结 DOM 更新。Nuxt 团队正在探索将 View Transition 限制在
<Suspense>解析前的最后时刻,但在此之前,如果页面依赖 top-level await 数据获取,建议慎重评估是否启用该特性。
小结
| 场景 | 配置位置 | 取值 |
|---|---|---|
| 全局页面过渡 | app.pageTransition(nuxt.config.ts) |
false | TransitionProps(如 { name: 'page', mode: 'out-in' }) |
| 全局布局过渡 | app.layoutTransition |
false | TransitionProps |
| 单页覆盖 | definePageMeta({ pageTransition / layoutTransition }) |
同上,或 false 关闭 |
| NuxtPage prop | <NuxtPage :transition="{ name, mode }" /> |
boolean | TransitionProps,不可被页面 meta 覆盖 |
| 原生过渡 | experimental.viewTransition |
false | true | 'always' |
| 原生过渡全局默认 | app.viewTransition |
false | true | 'always' | { enabled, types } |
| 原生过渡类型 | definePageMeta({ viewTransition: { types, toTypes, fromTypes } }) |
静态数组或函数(函数仅限 meta) |
核心要点回顾:页面/布局过渡本质是 Vue <Transition> 的封装,务必保证单根元素并按 name 写对 CSS 类;优先级为 <NuxtPage> prop > definePageMeta > 全局配置;动态过渡可在内联 middleware 中改写 to.meta.pageTransition;需要更激进的动画能力时,实验性 View Transitions API 提供原生方案、方向感知的 Types 与 page:view-transition:start 运行时钩子,并通过 prefers-reduced-motion 自动尊重无障碍偏好。相关参考文档见 页面结构、definePageMeta API、middleware 与 NuxtPage 组件。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00