首页
/ Nuxt 页面与布局过渡实战:pageTransition、layoutTransition 与 View Transitions API 全解析

Nuxt 页面与布局过渡实战:pageTransition、layoutTransition 与 View Transitions API 全解析

2026-09-05 13:54:33作者:柏廷章Berta

本篇技术指南基于 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 时接管等待,这正是“同时切换页面与布局时页面过渡不生效”的原因——外层布局的动画才是最终控制导航完成的边界。

全局配置与覆盖规则

pageTransitionlayoutTransition 两个配置键都接受 Vue Transition 组件的属性(TransitionProps),且要求是 JSON 可序列化的值,因此可以传 namemode 等任何合法的自定义 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> 还提供 onBeforeLeaveonLeaveonAfterLeaveonBeforeAppearonAppearonAfterAppearonEnterCancelledonLeaveCancelled 等完整钩子。

这里有一个细节需要注意: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,
  },
})

可能的取值为:falsetrue'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>

注意:typestoTypesfromTypes 的函数形式仅在 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 驱动,其核心流程可以完整走读:

  1. 能力探测:插件开头即检查 document.startViewTransition,不支持该 API 的浏览器直接退出(源码第 9-11 行),因此无需担心旧浏览器报错。
  2. 导航守卫决策:在 router.beforeResolve 中依次判断(第 42-58 行):
    • 目标页 meta.viewTransition(经 normalizeViewTransitionOptions 归一化)的 enabled 值,回退到 app.viewTransition 默认值;
    • prefers-reduced-motion: reduce 且模式不是 'always' 时跳过;
    • 浏览器用户代理(UA)自己触发了视觉过渡(hasUAVisualTransition,来自 popstate 事件的 hasUAVisualTransition 字段)时跳过,避免双重动画;
    • isChangingPage(to, from) 判断路由 key 与组件是否真正变化,仅参数变化不触发。
  3. 类型合并:将目标页 types、源页 fromTypes、目标页 toTypes 三类合并(支持函数求值,第 60-75 行),即 allTypes = [...toTypes(base), ...from.fromTypes, ...to.toTypes]
  4. 调用原生 API:指定了类型时走 Level 2 对象形式 document.startViewTransition({ update, types }),否则回退到兼容性更广的回调形式 document.startViewTransition(update)(第 90-94 行)。
  5. 生命周期衔接:过渡开始后触发 page:view-transition:start 钩子;page:finish 时 resolve 等待 Promise 完成过渡;router.onErrorapp:errorvue: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.pageTransitionnuxt.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 APImiddlewareNuxtPage 组件

登录后查看全文
热门项目推荐
相关项目推荐